oft-agt 0.5.0 → 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.
- package/README.md +36 -0
- package/cli.mjs +12 -13
- package/compile-plan.mjs +177 -7
- package/compiler.mjs +55 -55
- package/instance.mjs +13 -1
- package/npm-shrinkwrap.json +3 -3
- package/package.json +2 -2
- package/runtime/button-router.mjs +42 -0
- package/runtime/conditions.mjs +14 -1
- package/runtime/context-variables.mjs +14 -0
- package/runtime/custom-variables.mjs +184 -0
- package/runtime/discord-actions.mjs +64 -0
- package/runtime/discord-blocks.mjs +53 -0
- package/runtime/errors.mjs +85 -85
- package/runtime/execute.mjs +143 -20
- package/runtime/functions.mjs +92 -4
- package/runtime/lifecycle.mjs +1 -1
- package/runtime/message-buttons.mjs +17 -0
- package/runtime/message-config.mjs +80 -3
- package/runtime/scheduler.mjs +91 -0
- package/runtime/variable-actions.mjs +42 -0
- package/runtime/variable-store-worker.mjs +69 -0
- package/runtime/variable-store.mjs +26 -0
- package/templates/bot.mjs +17 -6
- package/update.mjs +24 -2
- package/usage.mjs +42 -39
- package/version.mjs +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { buttonComponent, validateButton } from './message-buttons.mjs';
|
|
3
|
+
|
|
4
|
+
export function createButtonRouter({ limit=4096, memoryBytes=16*1024*1024, now=Date.now }={}) {
|
|
5
|
+
const records=new Map();
|
|
6
|
+
function prune(){for(const [id,r] of records)if(r.expires<=now())records.delete(id);}
|
|
7
|
+
return {
|
|
8
|
+
prepare(command, buttons, context, variables) {
|
|
9
|
+
prune();
|
|
10
|
+
const interactive=buttons.filter(b=>Number(b.config.style)<5);
|
|
11
|
+
const cost=1024+JSON.stringify(variables).length*2+(context.messages.size+context.channels.size)*128;
|
|
12
|
+
const retained=[...records.values()].reduce((n,r)=>n+r.cost,0);
|
|
13
|
+
if(records.size+interactive.length>limit||retained+cost*interactive.length>memoryBytes)throw Object.assign(Error('Trop de boutons actifs.'),{code:'BOT_BUSY'});
|
|
14
|
+
const ids=[],rows=[[],[],[],[],[]];
|
|
15
|
+
try { for(const button of [...buttons].sort((a,b)=>(a.config.order||0)-(b.config.order||0))) {
|
|
16
|
+
const config=validateButton(button.config,false);
|
|
17
|
+
const id='oftb:'+randomBytes(24).toString('base64url');
|
|
18
|
+
if(config.style<5){
|
|
19
|
+
const branch=command.buttonBranches?.find(b=>b.id===button.id);
|
|
20
|
+
if(!branch)throw Error('Actions du bouton introuvables.');
|
|
21
|
+
records.set(id,{cost,command,branch,config,author:context.interaction.user.id,guildId:context.interaction.guildId||null,expires:now()+config.lifetime*1000,busy:false});ids.push(id);
|
|
22
|
+
}
|
|
23
|
+
rows[config.row].push(buttonComponent(config,id));
|
|
24
|
+
}}catch(error){for(const id of ids)records.delete(id);throw error;}
|
|
25
|
+
return {components:rows.filter(r=>r.length).map(components=>({type:1,components})),
|
|
26
|
+
bind(message){if(!message?.id){for(const id of ids)records.delete(id);throw Error('Message du bouton introuvable.');}const snapshot={variables:{...variables},messages:new Map(context.messages),channels:new Map(context.channels)};for(const id of ids)Object.assign(records.get(id),{messageId:message.id,channelId:message.channelId||context.interaction.channelId,snapshot});},
|
|
27
|
+
cancel(){for(const id of ids)records.delete(id);}};
|
|
28
|
+
},
|
|
29
|
+
async handle(interaction, execute) {
|
|
30
|
+
prune();const r=records.get(interaction.customId);
|
|
31
|
+
const deny=content=>interaction.reply({content,flags:64});
|
|
32
|
+
if(!r||!r.messageId||r.messageId!==interaction.message?.id||r.channelId!==interaction.channelId||r.guildId!==(interaction.guildId||null))return deny('Ce bouton n’est plus disponible. Relancez la commande.');
|
|
33
|
+
if(r.config.disabled||r.used)return deny('Ce bouton est désactivé.');
|
|
34
|
+
if(r.config.authorOnly&&r.author!==interaction.user.id)return deny('Ce bouton est réservé à l’auteur de la commande.');
|
|
35
|
+
if(r.busy)return deny('Une action est déjà en cours pour ce bouton.');
|
|
36
|
+
r.busy=true;
|
|
37
|
+
try{const result=await execute(r);if(r.config.once)r.used=true;return result;}finally{r.busy=false;}
|
|
38
|
+
},
|
|
39
|
+
get size(){prune();return records.size;},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export const buttonRouter=createButtonRouter();
|
package/runtime/conditions.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { parseCollection } from './custom-variables.mjs';
|
|
1
2
|
export const CONDITION_TYPES = {
|
|
3
|
+
randomCondition: { label: 'Choix aléatoire', help: 'Exécuter une seule branche, avec des poids facultatifs.' },
|
|
2
4
|
comparisonCondition: { label: 'Comparaison', help: 'Comparer des valeurs, du texte ou des variables.' },
|
|
3
5
|
userCondition: { label: 'Utilisateur', help: 'Vérifier qui exécute la commande.' },
|
|
4
6
|
channelCondition: { label: 'Salon', help: 'Vérifier dans quel salon la commande est exécutée.' },
|
|
5
7
|
permissionCondition: { label: 'Permissions', help: 'Vérifier les permissions Discord de l’auteur.' },
|
|
6
8
|
};
|
|
7
9
|
export const isCondition = kind => Object.hasOwn(CONDITION_TYPES, kind || '');
|
|
8
|
-
export const COMPARISONS = { eq: 'Égal à', ne: 'Différent de', lt: 'Inférieur à', lte: 'Inférieur ou égal à', gt: 'Supérieur à', gte: 'Supérieur ou égal à', contains: 'Contient', notContains: 'Ne contient pas', startsWith: 'Commence par', endsWith: 'Se termine par', empty: 'Est vide', notEmpty: 'N’est pas vide' };
|
|
10
|
+
export const COMPARISONS = { eq: 'Égal à', ne: 'Différent de', lt: 'Inférieur à', lte: 'Inférieur ou égal à', gt: 'Supérieur à', gte: 'Supérieur ou égal à', collectionContains: 'Collection contient', collectionNotContains: 'Collection ne contient pas', contains: 'Contient', notContains: 'Ne contient pas', startsWith: 'Commence par', endsWith: 'Se termine par', empty: 'Est vide', notEmpty: 'N’est pas vide' };
|
|
9
11
|
// Discord permission flags: https://docs.discord.com/developers/topics/permissions
|
|
10
12
|
const permissionNames = [
|
|
11
13
|
['CreateInstantInvite','Créer des invitations'],['KickMembers','Expulser des membres'],['BanMembers','Bannir des membres'],['Administrator','Administrateur'],['ManageChannels','Gérer les salons'],['ManageGuild','Gérer le serveur'],['AddReactions','Ajouter des réactions'],['ViewAuditLog','Voir les logs du serveur'],['PrioritySpeaker','Voix prioritaire'],['Stream','Diffuser une vidéo'],['ViewChannel','Voir les salons'],['SendMessages','Envoyer des messages'],['SendTTSMessages','Envoyer des messages vocaux TTS'],['ManageMessages','Gérer les messages'],['EmbedLinks','Intégrer des liens'],['AttachFiles','Joindre des fichiers'],['ReadMessageHistory','Voir les anciens messages'],['MentionEveryone','Mentionner everyone et here'],['UseExternalEmojis','Utiliser des emojis externes'],['ViewGuildInsights','Voir les statistiques du serveur'],['Connect','Se connecter au vocal'],['Speak','Parler'],['MuteMembers','Rendre des membres muets'],['DeafenMembers','Mettre des membres en sourdine'],['MoveMembers','Déplacer des membres'],['UseVAD','Utiliser la détection de voix'],['ChangeNickname','Changer son pseudo'],['ManageNicknames','Gérer les pseudos'],['ManageRoles','Gérer les rôles'],['ManageWebhooks','Gérer les webhooks'],['ManageGuildExpressions','Gérer les expressions du serveur'],['UseApplicationCommands','Utiliser les commandes des applications'],['RequestToSpeak','Demander la parole'],['ManageEvents','Gérer les événements'],['ManageThreads','Gérer les fils'],['CreatePublicThreads','Créer des fils publics'],['CreatePrivateThreads','Créer des fils privés'],['UseExternalStickers','Utiliser des stickers externes'],['SendMessagesInThreads','Envoyer des messages dans les fils'],['UseEmbeddedActivities','Utiliser les activités'],['ModerateMembers','Exclure temporairement des membres'],['ViewCreatorMonetizationAnalytics','Voir les statistiques de monétisation'],['UseSoundboard','Utiliser la soundboard'],['CreateGuildExpressions','Créer des expressions du serveur'],['CreateEvents','Créer des événements'],['UseExternalSounds','Utiliser des sons externes'],['SendVoiceMessages','Envoyer des messages vocaux'],null,['SetVoiceChannelStatus','Définir le statut du salon vocal'],['SendPolls','Envoyer des sondages'],['UseExternalApps','Utiliser des applications externes'],['PinMessages','Épingler des messages'],['BypassSlowmode','Contourner le mode lent'],
|
|
@@ -22,6 +24,7 @@ export function validateCondition(kind, config) {
|
|
|
22
24
|
if (typeof value !== 'string' || value.length > 6000 || (required && !value.trim())) throw new Error(`${name} : renseignez une valeur (6 000 caractères maximum).`);
|
|
23
25
|
return value;
|
|
24
26
|
};
|
|
27
|
+
if (kind === 'randomCondition') { const weight = Number(rule.weight ?? 1); if (!Number.isFinite(weight) || weight <= 0 || weight > 1000) throw Error('Poids : un nombre supérieur à 0, au maximum 1 000.'); return {id:rule.id,weight}; }
|
|
25
28
|
if (kind === 'comparisonCondition') {
|
|
26
29
|
if (!Object.hasOwn(COMPARISONS, rule.operator)) throw new Error('Comparaison inconnue.');
|
|
27
30
|
return { id: rule.id, value: string(rule.value, 'Valeur', false), operator: rule.operator, compare: string(rule.compare ?? '', 'Valeur de comparaison', false) };
|
|
@@ -38,6 +41,7 @@ export function validateCondition(kind, config) {
|
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export function conditionLabel(kind, rule, index) {
|
|
44
|
+
if (kind === 'randomCondition') return `Sortie ${index + 1} · poids ${rule.weight}`;
|
|
41
45
|
if (kind === 'comparisonCondition') return `${rule.value || 'Valeur vide'} ${COMPARISONS[rule.operator]?.toLowerCase() || ''} ${['empty', 'notEmpty'].includes(rule.operator) ? '' : rule.compare || 'valeur vide'}`.trim();
|
|
42
46
|
if (kind === 'permissionCondition') return `${rule.mode === 'isNot' ? 'Sans' : 'Avec'} ${rule.permissions.length} permission(s)`;
|
|
43
47
|
return `${rule.mode === 'isNot' ? 'Tout sauf' : kind === 'userCondition' ? 'Utilisateur' : 'Salon'} ${rule.value || index + 1}`;
|
|
@@ -56,6 +60,8 @@ export function matchesCondition(kind, rule, interaction) {
|
|
|
56
60
|
case 'lte': return numeric(a) && numeric(b) && left <= right;
|
|
57
61
|
case 'gt': return numeric(a) && numeric(b) && left > right;
|
|
58
62
|
case 'gte': return numeric(a) && numeric(b) && left >= right;
|
|
63
|
+
case 'collectionContains': return parseCollection(a).includes(b);
|
|
64
|
+
case 'collectionNotContains': return !parseCollection(a).includes(b);
|
|
59
65
|
case 'contains': return a.includes(b);
|
|
60
66
|
case 'notContains': return !a.includes(b);
|
|
61
67
|
case 'startsWith': return a.startsWith(b);
|
|
@@ -78,3 +84,10 @@ export function matchesCondition(kind, rule, interaction) {
|
|
|
78
84
|
if (!actual) return false;
|
|
79
85
|
return rule.mode === 'isNot' ? actual !== id : actual === id;
|
|
80
86
|
}
|
|
87
|
+
|
|
88
|
+
export function selectConditionBranch(kind, rules, interaction, random = Math.random) {
|
|
89
|
+
if(kind !== 'randomCondition') return rules.findIndex(rule => matchesCondition(kind,rule,interaction));
|
|
90
|
+
if(!rules.length) return -1;
|
|
91
|
+
let draw = random() * rules.reduce((total,rule)=>total+rule.weight,0);
|
|
92
|
+
return rules.findIndex(rule => (draw-=rule.weight)<0);
|
|
93
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const CONTEXT_VARIABLES = {
|
|
2
|
+
user_id:'ID de l’auteur',user_name:'Nom utilisateur',user_display_name:'Nom affiché',user_mention:'Mention de l’auteur',user_avatar:'URL de l’avatar',user_created_at:'Création du compte (ISO)',user_created_timestamp:'Création du compte (Unix)',user_bot:'Compte robot',user_locale:'Langue de l’auteur',
|
|
3
|
+
member_nickname:'Surnom sur le serveur',member_joined_at:'Arrivée sur le serveur (ISO)',member_joined_timestamp:'Arrivée sur le serveur (Unix)',member_roles:'Mentions des rôles',member_role_ids:'IDs des rôles',member_boost_since:'Début du boost (ISO)',
|
|
4
|
+
channel_id:'ID du salon courant',channel_name:'Nom du salon',channel_mention:'Mention du salon',channel_topic:'Sujet du salon',channel_type:'Type du salon',channel_parent_id:'ID de la catégorie',
|
|
5
|
+
guild_id:'ID du serveur',guild_name:'Nom du serveur',guild_icon:'Icône du serveur',guild_owner_id:'Propriétaire du serveur',guild_member_count:'Nombre de membres',guild_locale:'Langue du serveur',
|
|
6
|
+
command_name:'Nom de la commande',interaction_id:'ID de l’interaction',bot_id:'ID du bot',bot_name:'Nom du bot',timestamp:'Horodatage Unix',date_iso:'Date actuelle (ISO)',
|
|
7
|
+
};
|
|
8
|
+
export function contextVariables(interaction, client) {
|
|
9
|
+
const u=interaction.user||{},m=interaction.member||{},c=interaction.channel||{},g=interaction.guild||{};
|
|
10
|
+
const iso = value => value ? new Date(value).toISOString() : '';
|
|
11
|
+
const unix = value => value ? String(Math.floor(value/1000)) : '';
|
|
12
|
+
const roles=m.roles?.cache ? [...m.roles.cache.values()].filter(r=>r.id!==g.id) : [];
|
|
13
|
+
return {user_id:u.id||'',user_name:u.username||'',user_display_name:m.displayName||m.nick||u.globalName||u.username||'',user_mention:u.id?`<@${u.id}>`:'',user_avatar:u.displayAvatarURL?.()||'',user_created_at:iso(u.createdTimestamp),user_created_timestamp:unix(u.createdTimestamp),user_bot:String(Boolean(u.bot)),user_locale:interaction.locale||'',member_nickname:m.nickname||m.nick||'',member_joined_at:iso(m.joinedTimestamp),member_joined_timestamp:unix(m.joinedTimestamp),member_roles:roles.map(r=>`<@&${r.id}>`).join(' '),member_role_ids:roles.map(r=>r.id).join(','),member_boost_since:iso(m.premiumSinceTimestamp),channel_id:interaction.channelId||'',channel_name:c.name||'',channel_mention:interaction.channelId?`<#${interaction.channelId}>`:'',channel_topic:c.topic||'',channel_type:String(c.type??''),channel_parent_id:c.parentId||'',guild_id:interaction.guildId||'',guild_name:g.name||'',guild_icon:g.iconURL?.()||'',guild_owner_id:g.ownerId||'',guild_member_count:String(g.memberCount??''),guild_locale:g.preferredLocale||'',command_name:interaction.commandName||'',interaction_id:interaction.id||'',bot_id:client.user?.id||'',bot_name:client.user?.username||'',timestamp:unix(Date.now()),date_iso:new Date().toISOString()};
|
|
14
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { CONTEXT_VARIABLES } from './context-variables.mjs';
|
|
2
|
+
|
|
3
|
+
// Technical limits, not pricing promises. Definitions never contain stored values.
|
|
4
|
+
export const VARIABLE_LIMITS = Object.freeze({ baseDefinitions: 50, name: 32, textBytes: 16384, collectionLines: 500, valueBytes: 131072, databaseBytes: 67108864, records: 100000, templateBytes: 65536, depth: 8, references: 200 });
|
|
5
|
+
export const VARIABLE_WARNING_CODES = ['VARIABLE_UNKNOWN','VARIABLE_PARAMETER','VARIABLE_UNAVAILABLE','VARIABLE_LIMIT','VARIABLE_LINE_OUTSIDE'];
|
|
6
|
+
export const VARIABLE_TYPES = { text: 'Texte', number: 'Nombre', boolean: 'Booléen', collection: 'Collection' };
|
|
7
|
+
export const VARIABLE_LEVELS = { common: 'Commune', user: 'Par utilisateur', channel: 'Par salon', channelUser: 'Par salon + utilisateur' };
|
|
8
|
+
export const VARIABLE_SCOPES = { guild: 'Serveur', global: 'Générale' };
|
|
9
|
+
export const VARIABLE_BLOCKS = { setVariable: { label: 'Définir une variable', category: 'variables' }, deleteVariable: { label: 'Supprimer une variable', category: 'variables' } };
|
|
10
|
+
export const isVariableBlock = kind => Object.hasOwn(VARIABLE_BLOCKS, kind || '');
|
|
11
|
+
const bytes = value => new TextEncoder().encode(value).length;
|
|
12
|
+
const reserved = new Set([...Object.keys(CONTEXT_VARIABLES), 'error_name', 'error_desc', 'error_reason', 'error_code', 'error_block_id']);
|
|
13
|
+
export const RESERVED_VARIABLE_NAMES = [...reserved];
|
|
14
|
+
export function variableNameError(name) {
|
|
15
|
+
if (typeof name !== 'string' || !/^[A-Za-z0-9_]{1,32}$/.test(name)) return 'Nom : 1 à 32 lettres, chiffres ou underscores, sans espace.';
|
|
16
|
+
if (reserved.has(name.toLowerCase())) return 'Ce nom est réservé à une variable de base.';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function validateVariableDefinition(raw) {
|
|
20
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw Error('Définition de variable invalide.');
|
|
21
|
+
if (Object.keys(raw).some(key => !['id', 'name', 'type', 'level', 'scope', 'revision'].includes(key))) throw Error('Les valeurs et les défauts personnalisés restent uniquement sur le serveur du bot.');
|
|
22
|
+
const error = variableNameError(raw.name); if (error) throw Error(error);
|
|
23
|
+
if (typeof raw.id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(raw.id) || !Object.hasOwn(VARIABLE_TYPES, raw.type) || !Object.hasOwn(VARIABLE_LEVELS, raw.level) || !Object.hasOwn(VARIABLE_SCOPES, raw.scope) || !Number.isInteger(raw.revision ?? 1) || (raw.revision ?? 1) < 1) throw Error('Type, niveau ou portée invalide.');
|
|
24
|
+
return { id: raw.id, name: raw.name, type: raw.type, level: raw.level, scope: raw.scope, revision: raw.revision ?? 1 };
|
|
25
|
+
}
|
|
26
|
+
export function validateVariableDefinitions(raw = []) {
|
|
27
|
+
if (!Array.isArray(raw) || raw.length > VARIABLE_LIMITS.baseDefinitions) throw Error(`Limite technique : ${VARIABLE_LIMITS.baseDefinitions} variables par bot.`);
|
|
28
|
+
const names = new Set(), ids = new Set();
|
|
29
|
+
return raw.map(item => { const definition = validateVariableDefinition(item); if (names.has(definition.name.toLowerCase()) || ids.has(definition.id)) throw Error('Nom ou identifiant de variable dupliqué.'); names.add(definition.name.toLowerCase()); ids.add(definition.id); return definition; });
|
|
30
|
+
}
|
|
31
|
+
export const emptyVariableValue = type => ({ text: '', number: 0, boolean: false, collection: [] })[type];
|
|
32
|
+
export function validateVariableValue(type, value) {
|
|
33
|
+
if (type === 'text' && (typeof value !== 'string' || bytes(value) > VARIABLE_LIMITS.textBytes)) throw Error('Texte de variable trop long ou invalide.');
|
|
34
|
+
if (type === 'number' && (typeof value !== 'number' || !Number.isFinite(value))) throw Error('La valeur doit être un nombre fini.');
|
|
35
|
+
if (type === 'boolean' && typeof value !== 'boolean') throw Error('La valeur doit être un booléen.');
|
|
36
|
+
if (type === 'collection' && (!Array.isArray(value) || value.length > VARIABLE_LIMITS.collectionLines || value.some(line => typeof line !== 'string' || bytes(line) > VARIABLE_LIMITS.textBytes))) throw Error('Collection invalide ou trop grande.');
|
|
37
|
+
if (!Object.hasOwn(VARIABLE_TYPES, type) || bytes(JSON.stringify(value)) > VARIABLE_LIMITS.valueBytes) throw Error('Valeur de variable trop grande ou invalide.');
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
export function parseCollection(text) {
|
|
41
|
+
if (Array.isArray(text)) return validateVariableValue('collection', text);
|
|
42
|
+
if (typeof text !== 'string') throw Error('Collection invalide.');
|
|
43
|
+
if (!text.startsWith('[') || !text.endsWith(']')) return validateVariableValue('collection', text === '' ? [] : text.split(/\r?\n/));
|
|
44
|
+
const body = text.slice(1, -1); if (!body) return [];
|
|
45
|
+
const lines = []; let line = '';
|
|
46
|
+
for (let i = 0; i < body.length; i++) {
|
|
47
|
+
if (body[i] === '\\' && ['\\', ';'].includes(body[i + 1])) line += body[++i];
|
|
48
|
+
else if (body[i] === ';') { lines.push(line); line = ''; } else line += body[i];
|
|
49
|
+
}
|
|
50
|
+
lines.push(line); return validateVariableValue('collection', lines);
|
|
51
|
+
}
|
|
52
|
+
export const formatCollection = lines => '[' + lines.map(line => line.replace(/\\/g, '\\\\').replace(/;/g, '\\;')).join(';') + ']';
|
|
53
|
+
// Reversed ranges are normalized. Bounds are positive, 1-based, deduplicated.
|
|
54
|
+
export function parseVariableLines(text, length = VARIABLE_LIMITS.collectionLines) {
|
|
55
|
+
if (typeof text !== 'string' || !text.trim() || text.length > 4096) throw Error('Renseignez des lignes : 1, 2-5, 8.');
|
|
56
|
+
const indices = new Set(); let outside = false;
|
|
57
|
+
for (const part of text.split(',')) {
|
|
58
|
+
const match = part.trim().match(/^([1-9]\d*)(?:\s*-\s*([1-9]\d*))?$/); if (!match) throw Error('Lignes invalides : utilisez des nombres positifs et des plages.');
|
|
59
|
+
const a = Number(match[1]), b = Number(match[2] || match[1]);
|
|
60
|
+
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b)) throw Error('Indice de ligne invalide.');
|
|
61
|
+
const low = Math.min(a, b), high = Math.max(a, b); if (high > length) outside = true;
|
|
62
|
+
for (let i = low; i <= Math.min(high, length); i++) indices.add(i - 1);
|
|
63
|
+
}
|
|
64
|
+
return { indices: [...indices].sort((a, b) => b - a), outside };
|
|
65
|
+
}
|
|
66
|
+
export function variableStorageKey(definition, context = {}) {
|
|
67
|
+
const id = (value, label) => { if (typeof value !== 'string' || !/^\d{17,20}$/.test(value)) throw Error(`Contexte ${label} indisponible.`); return value; };
|
|
68
|
+
// A separate database per bot. Revision isolates schema changes and permits rollback.
|
|
69
|
+
return JSON.stringify([definition.id, definition.revision ?? 1, definition.scope === 'guild' ? id(context.guildId, 'serveur') : '', ['channel', 'channelUser'].includes(definition.level) ? id(context.channelId, 'salon') : '', ['user', 'channelUser'].includes(definition.level) ? id(context.userId, 'utilisateur') : '']);
|
|
70
|
+
}
|
|
71
|
+
export function mutateVariableValue(definition, current, action) {
|
|
72
|
+
const value = action.value ?? '';
|
|
73
|
+
if (action.kind === 'deleteVariable') {
|
|
74
|
+
if (definition.type !== 'collection' || action.operation === 'reset') return emptyVariableValue(definition.type);
|
|
75
|
+
const result = [...current]; for (const index of parseVariableLines(action.lines, result.length).indices) result.splice(index, 1); return result;
|
|
76
|
+
}
|
|
77
|
+
if (definition.type === 'text') return validateVariableValue('text', String(value));
|
|
78
|
+
if (definition.type === 'boolean') return action.operation === 'invert' ? !current : action.operation === 'true';
|
|
79
|
+
if (definition.type === 'number') {
|
|
80
|
+
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) throw Error('Valeur numérique invalide.');
|
|
81
|
+
const number = Number(value); if (!Number.isFinite(number)) throw Error('Valeur numérique invalide.');
|
|
82
|
+
return validateVariableValue('number', ({ add: () => current + number, subtract: () => current - number, multiply: () => current * number, set: () => number })[action.operation || 'set']?.());
|
|
83
|
+
}
|
|
84
|
+
let result = [...current];
|
|
85
|
+
if (action.operation === 'replace') result = parseCollection(value);
|
|
86
|
+
else if (action.operation === 'append') result.push(String(value));
|
|
87
|
+
else if (action.operation === 'prepend') result.unshift(String(value));
|
|
88
|
+
else {
|
|
89
|
+
const position = Number(action.position); if (!Number.isInteger(position) || position < 1 || position > result.length + (action.operation === 'insert' ? 1 : 0)) throw Error('Position de collection invalide.');
|
|
90
|
+
if (action.operation === 'insert') result.splice(position - 1, 0, String(value)); else if (action.operation === 'update') result[position - 1] = String(value); else throw Error('Opération de collection invalide.');
|
|
91
|
+
}
|
|
92
|
+
return validateVariableValue('collection', result);
|
|
93
|
+
}
|
|
94
|
+
export function validateVariableBlock(kind, raw = {}, definitions) {
|
|
95
|
+
if (!isVariableBlock(kind) || typeof raw.variableId !== 'string' || !raw.variableId) throw Error('Choisissez une variable.');
|
|
96
|
+
const definition = definitions?.find(item => item.id === raw.variableId);
|
|
97
|
+
if (definitions && !definition) throw Error('Variable introuvable.');
|
|
98
|
+
const c = { variableId: raw.variableId, operation: raw.operation || (kind === 'deleteVariable' ? 'reset' : 'set'), value: String(raw.value ?? ''), userId: String(raw.userId || ''), channelId: String(raw.channelId || ''), guildId: String(raw.guildId || ''), position: String(raw.position || '1'), lines: String(raw.lines || '') };
|
|
99
|
+
if (Object.values(c).some(value => value.length > VARIABLE_LIMITS.textBytes)) throw Error('Paramètre de variable trop long.');
|
|
100
|
+
for (const key of ['userId', 'channelId', 'guildId']) if (c[key] && !/^\d{17,20}$/.test(c[key]) && !/\{[^{}]+\}/.test(c[key])) throw Error('Cible : ID Discord ou variable attendu.');
|
|
101
|
+
const allowed = kind === 'deleteVariable' ? definition && definition.type !== 'collection' ? ['reset'] : ['reset', 'lines'] : definition ? ({ text: ['set'], number: ['set', 'add', 'subtract', 'multiply'], boolean: ['true', 'false', 'invert'], collection: ['append', 'prepend', 'insert', 'update', 'replace'] })[definition.type] : ['set', 'add', 'subtract', 'multiply', 'true', 'false', 'invert', 'append', 'prepend', 'insert', 'update', 'replace'];
|
|
102
|
+
if (!allowed.includes(c.operation)) throw Error('Opération incompatible avec le type de variable.');
|
|
103
|
+
if (kind === 'deleteVariable' && c.operation === 'lines' && !c.lines.includes('{')) parseVariableLines(c.lines);
|
|
104
|
+
if (definition?.type === 'number' && kind === 'setVariable' && !c.value.includes('{') && (!c.value.trim() || !Number.isFinite(Number(c.value)))) throw Error('Renseignez un nombre ou une variable.');
|
|
105
|
+
if (['insert', 'update'].includes(c.operation) && !c.position.includes('{') && !/^[1-9]\d*$/.test(c.position)) throw Error('Position : nombre positif ou variable attendu.');
|
|
106
|
+
return c;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function splitSyntax(text, delimiter) {
|
|
110
|
+
const parts = []; let current = '', depth = 0;
|
|
111
|
+
for (let i = 0; i < text.length; i++) {
|
|
112
|
+
if (text[i] === '\\' && i + 1 < text.length) { current += text[i] + text[++i]; continue; }
|
|
113
|
+
if (text[i] === '{') depth++; else if (text[i] === '}') depth--;
|
|
114
|
+
if (!depth && text[i] === delimiter) { parts.push(current); current = ''; } else current += text[i];
|
|
115
|
+
}
|
|
116
|
+
parts.push(current); return parts;
|
|
117
|
+
}
|
|
118
|
+
export function referencedVariables(value) {
|
|
119
|
+
if (typeof value === 'string') return [...value.matchAll(/(\\*)\{var_([A-Za-z0-9_]+)/g)].filter(match => match[1].length % 2 === 0).map(match => match[2]);
|
|
120
|
+
if (Array.isArray(value)) return value.flatMap(referencedVariables);
|
|
121
|
+
if (value && typeof value === 'object') return Object.values(value).flatMap(referencedVariables);
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
export async function resolveVariableTemplate(source, { base = {}, definitions = [], read, warn = () => {}, random = Math.random } = {}) {
|
|
125
|
+
if (typeof source !== 'string') return source;
|
|
126
|
+
if (bytes(source) > VARIABLE_LIMITS.templateBytes) throw Error('Texte à résoudre trop grand.');
|
|
127
|
+
let references = 0;
|
|
128
|
+
const warning = code => warn(code); // Never include resolved values or target IDs.
|
|
129
|
+
async function token(raw, depth) {
|
|
130
|
+
if (++references > VARIABLE_LIMITS.references || depth > VARIABLE_LIMITS.depth) { warning('VARIABLE_LIMIT'); return ''; }
|
|
131
|
+
if (!raw.startsWith('var_')) return Object.hasOwn(base, raw) ? String(base[raw]) : '{' + raw + '}';
|
|
132
|
+
const sections = splitSyntax(raw, ':'), name = sections.shift().slice(4), paramText = sections.join(':');
|
|
133
|
+
const params = Object.create(null); let invalid = false;
|
|
134
|
+
for (const part of paramText ? splitSyntax(paramText, '|') : []) {
|
|
135
|
+
const equals = part.indexOf('='), key = (equals < 0 ? part : part.slice(0, equals)).trim();
|
|
136
|
+
if (!key || Object.hasOwn(params, key)) { invalid = true; continue; }
|
|
137
|
+
params[key] = equals < 0 ? true : await text(part.slice(equals + 1), depth + 1);
|
|
138
|
+
}
|
|
139
|
+
const fallback = typeof params.default === 'string' ? params.default : '';
|
|
140
|
+
const definition = definitions.find(item => item.name === name);
|
|
141
|
+
if (!definition) { warning('VARIABLE_UNKNOWN'); return fallback; }
|
|
142
|
+
const allowed = ['user', 'channel', 'server', 'default', ...(definition.type === 'collection' ? ['line', 'count', 'join'] : definition.type === 'text' ? ['upper', 'lower', 'length'] : definition.type === 'number' ? ['format'] : [])];
|
|
143
|
+
const modes = ['line', 'count', 'join', 'upper', 'lower', 'length', 'format'].filter(key => Object.hasOwn(params, key));
|
|
144
|
+
if (invalid || Object.keys(params).some(key => !allowed.includes(key)) || modes.length > 1 || ['upper', 'lower', 'length', 'count'].some(key => key in params && params[key] !== true) || ['line', 'join', 'format', 'default'].some(key => key in params && typeof params[key] !== 'string') || 'user' in params && !['user', 'channelUser'].includes(definition.level) || 'channel' in params && !['channel', 'channelUser'].includes(definition.level) || 'server' in params && definition.scope !== 'guild' || ['user', 'channel', 'server'].some(key => key in params && (typeof params[key] !== 'string' || !/^\d{17,20}$/.test(params[key].replace(/^<[@#]!?|>$/g, ''))))) { warning('VARIABLE_PARAMETER'); return fallback; }
|
|
145
|
+
for (const key of ['user', 'channel', 'server']) if (key in params) params[key] = params[key].replace(/^<[@#]!?|>$/g, '');
|
|
146
|
+
let value;
|
|
147
|
+
try { value = read ? await read(definition, params) : emptyVariableValue(definition.type); }
|
|
148
|
+
catch { warning('VARIABLE_UNAVAILABLE'); return fallback; }
|
|
149
|
+
if ((value === '' || Array.isArray(value) && !value.length) && Object.hasOwn(params, 'default')) return fallback;
|
|
150
|
+
if (definition.type === 'collection') {
|
|
151
|
+
if (params.count) return String(value.length);
|
|
152
|
+
if ('join' in params) return value.join(params.join);
|
|
153
|
+
if ('line' in params) {
|
|
154
|
+
const index = params.line === 'first' ? 0 : params.line === 'last' ? value.length - 1 : params.line === 'random' ? Math.floor(random() * value.length) : /^[1-9]\d*$/.test(params.line) ? Number(params.line) - 1 : NaN;
|
|
155
|
+
if (Number.isNaN(index)) warning('VARIABLE_PARAMETER');
|
|
156
|
+
return value[index] ?? '';
|
|
157
|
+
}
|
|
158
|
+
return formatCollection(value);
|
|
159
|
+
}
|
|
160
|
+
if (definition.type === 'text') return params.upper ? value.toUpperCase() : params.lower ? value.toLowerCase() : params.length ? String([...value].length) : value;
|
|
161
|
+
if (definition.type === 'number' && 'format' in params) { if (params.format !== '1 000') { warning('VARIABLE_PARAMETER'); return fallback; } return new Intl.NumberFormat('fr-FR').format(value).replace(/[\u202f\u00a0]/g, ' '); }
|
|
162
|
+
return String(value);
|
|
163
|
+
}
|
|
164
|
+
async function text(input, depth) {
|
|
165
|
+
let output = '';
|
|
166
|
+
for (let i = 0; i < input.length; i++) {
|
|
167
|
+
if (input[i] === '\\' && ['{', '}', '\\', '|'].includes(input[i + 1])) { output += input[++i]; continue; }
|
|
168
|
+
if (input[i] !== '{') { output += input[i]; continue; }
|
|
169
|
+
let level = 1, end = i + 1;
|
|
170
|
+
for (; end < input.length; end++) { if (input[end] === '\\') { end++; continue; } if (input[end] === '{') { if (++level > VARIABLE_LIMITS.depth) throw Error('Imbrication de variables trop profonde.'); } else if (input[end] === '}' && --level === 0) break; }
|
|
171
|
+
if (end === input.length) { output += input.slice(i); break; }
|
|
172
|
+
output += await token(input.slice(i + 1, end), depth + 1); i = end;
|
|
173
|
+
if (bytes(output) > VARIABLE_LIMITS.templateBytes) throw Error('Résultat de variable trop grand.');
|
|
174
|
+
}
|
|
175
|
+
return output;
|
|
176
|
+
}
|
|
177
|
+
return text(source, 0);
|
|
178
|
+
}
|
|
179
|
+
export async function interpolateVariables(value, options) {
|
|
180
|
+
if (typeof value === 'string') return resolveVariableTemplate(value, options);
|
|
181
|
+
if (Array.isArray(value)) return Promise.all(value.map(item => interpolateVariables(item, options)));
|
|
182
|
+
if (value && typeof value === 'object') return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([key, item]) => [key, await interpolateVariables(item, options)])));
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { DISCORD_BLOCKS, validateDiscordBlock } from './discord-blocks.mjs';
|
|
2
|
+
|
|
3
|
+
async function execute(kind, context, input) {
|
|
4
|
+
const args=validateDiscordBlock(kind,input,false), {interaction}=context, guild=interaction.guild;
|
|
5
|
+
if (!guild || !interaction.guildId) throw new Error('Cette action nécessite un serveur Discord.');
|
|
6
|
+
const actor=await guild.members.fetch({user:interaction.user.id,force:true});
|
|
7
|
+
const bot=await guild.members.fetchMe({force:true});
|
|
8
|
+
const permission=DISCORD_BLOCKS[kind].permission;
|
|
9
|
+
if (!actor.permissions.has(permission) || !bot.permissions.has(permission)) throw new Error(`Permission requise : ${permission}.`);
|
|
10
|
+
const outranks = (member, target) => member.id===guild.ownerId || member.roles.highest.comparePositionTo(target.roles.highest)>0;
|
|
11
|
+
let target;
|
|
12
|
+
if (args.memberId && kind!=='unbanMember') {
|
|
13
|
+
target=await guild.members.fetch({user:args.memberId,force:true}).catch(error => { if(kind==='banMember' && error.code===10007) return null; throw error; });
|
|
14
|
+
if (!['addRole','removeRole'].includes(kind) && (args.memberId===guild.ownerId || args.memberId===actor.id || args.memberId===bot.id || (target && (!outranks(actor,target)||!outranks(bot,target))))) throw new Error('La hiérarchie des membres interdit cette action.');
|
|
15
|
+
}
|
|
16
|
+
let role;
|
|
17
|
+
if(args.roleId) {
|
|
18
|
+
role=await guild.roles.fetch(args.roleId);
|
|
19
|
+
if(!role || role.managed || role.id===guild.id || bot.roles.highest.comparePositionTo(role)<=0 || (actor.id!==guild.ownerId && actor.roles.highest.comparePositionTo(role)<=0)) throw new Error('Rôle inaccessible ou supérieur à votre rôle.');
|
|
20
|
+
}
|
|
21
|
+
if(kind==='banMember') return guild.members.ban(args.memberId,{reason:args.reason,deleteMessageSeconds:args.deleteMessageSeconds});
|
|
22
|
+
if(kind==='unbanMember') return guild.members.unban(args.memberId,args.reason);
|
|
23
|
+
if(kind==='kickMember') return target.kick(args.reason);
|
|
24
|
+
if(kind==='timeoutMember'||kind==='removeTimeout') return target.timeout(kind==='removeTimeout'?null:args.seconds*1000,args.reason);
|
|
25
|
+
if(kind==='nicknameMember') return target.setNickname(args.nickname||null,args.reason);
|
|
26
|
+
if(kind==='addRole'||kind==='removeRole') return target.roles[kind==='addRole'?'add':'remove'](role,args.reason);
|
|
27
|
+
if(kind==='deleteRole') return role.delete(args.reason);
|
|
28
|
+
if(kind==='createRole') {
|
|
29
|
+
if(!actor.permissions.has(args.permissions)||!bot.permissions.has(args.permissions)) throw new Error('Vous ne pouvez pas accorder ces permissions.');
|
|
30
|
+
const {icon,unicodeEmoji,color,colorStyle,secondaryColor,...rest}=args;
|
|
31
|
+
const colors=colorStyle==='holographic'?{primaryColor:11127295,secondaryColor:16759788,tertiaryColor:16761760}:{primaryColor:color||0,...(colorStyle==='gradient'?{secondaryColor}: {})};
|
|
32
|
+
return guild.roles.create({...rest,colors,...(icon?{icon}:{}),...(unicodeEmoji?{unicodeEmoji}:{})});
|
|
33
|
+
}
|
|
34
|
+
if(kind==='createChannel') {
|
|
35
|
+
const {type,parent,rtcRegion,availableTags,defaultReactionEmoji,defaultSortOrder,defaultForumLayout,defaultAutoArchiveDuration,defaultThreadRateLimitPerUser,rateLimitPerUser,bitrate,userLimit,videoQualityMode,topic,permissionOverwrites,...rest}=args;
|
|
36
|
+
const channelType=Number(type);
|
|
37
|
+
// Custom overwrites require ManageRoles as well; never grant permissions the caller lacks.
|
|
38
|
+
if(permissionOverwrites.length && (!actor.permissions.has('ManageRoles')||!bot.permissions.has('ManageRoles'))) throw new Error('ManageRoles est requis pour les permissions du salon.');
|
|
39
|
+
for(const overwrite of permissionOverwrites) {
|
|
40
|
+
if(!/^\d{17,20}$/.test(overwrite.id||'') || ![0,1].includes(overwrite.type) || !Array.isArray(overwrite.allow)||!Array.isArray(overwrite.deny)||!actor.permissions.has(overwrite.allow)||!bot.permissions.has(overwrite.allow)) throw new Error('Permissions de salon invalides ou non autorisées.');
|
|
41
|
+
}
|
|
42
|
+
if(parent) { const category=await guild.channels.fetch(parent); if(!category || category.type!==4 || !category.permissionsFor(actor)?.has(['ViewChannel','ManageChannels'])) throw new Error('Catégorie inaccessible.'); }
|
|
43
|
+
return guild.channels.create({...rest,type:channelType,permissionOverwrites,...([0,2,5,13,15,16].includes(channelType)?{rateLimitPerUser}:{}),...([0,5,15,16].includes(channelType)?{defaultAutoArchiveDuration:Number(defaultAutoArchiveDuration),defaultThreadRateLimitPerUser}:{}),...(parent?{parent}:{}),...([0,5,15,16].includes(channelType)?{topic}:{}),...([2,13].includes(channelType)?{bitrate,userLimit,videoQualityMode:Number(videoQualityMode),rtcRegion:rtcRegion||null}:{}),...([15,16].includes(channelType)?{availableTags,defaultReactionEmoji,defaultSortOrder:Number(defaultSortOrder),defaultForumLayout:Number(defaultForumLayout)}:{})});
|
|
44
|
+
}
|
|
45
|
+
const destination=await guild.channels.fetch(args.channelId);
|
|
46
|
+
if(!destination || !destination.permissionsFor(actor)?.has(['ViewChannel',permission]) || !destination.permissionsFor(bot)?.has(permission)) throw new Error('Salon inaccessible ou permissions insuffisantes.');
|
|
47
|
+
if(['addReaction','removeReaction','editMessage'].includes(kind)) {
|
|
48
|
+
if(!destination.isTextBased?.() || !destination.messages || !destination.permissionsFor(actor)?.has('ReadMessageHistory') || !destination.permissionsFor(bot)?.has(['ViewChannel','ReadMessageHistory'])) throw new Error('Message inaccessible.');
|
|
49
|
+
const message=await destination.messages.fetch(args.messageId);
|
|
50
|
+
if(kind==='editMessage') {
|
|
51
|
+
if(message.author.id!==bot.id) throw new Error('Seuls les messages de ce bot peuvent être modifiés.');
|
|
52
|
+
return message.edit({content:args.content,allowedMentions:{parse:[],repliedUser:false}});
|
|
53
|
+
}
|
|
54
|
+
const emoji=args.emoji.replace(/^<a?:[^:]+:(\d{17,20})>$/, '$1');
|
|
55
|
+
if(kind==='addReaction') return message.react(emoji);
|
|
56
|
+
const reaction=message.reactions.resolve(emoji);
|
|
57
|
+
if(!reaction) throw new Error('Réaction introuvable.');
|
|
58
|
+
return reaction.users.remove(args.userId || bot.id);
|
|
59
|
+
}
|
|
60
|
+
if(kind==='deleteChannel') return destination.delete(args.reason);
|
|
61
|
+
if(kind==='createInvite') return destination.createInvite(args);
|
|
62
|
+
throw new Error('Action inconnue.');
|
|
63
|
+
}
|
|
64
|
+
export const DISCORD_ACTIONS = Object.fromEntries(Object.keys(DISCORD_BLOCKS).map(kind=>[kind,(context,args)=>execute(kind,context,args)]));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { DISCORD_PERMISSIONS } from './conditions.mjs';
|
|
2
|
+
const f = (key, label, type = 'text', extra = {}) => ({ key, label, type, ...extra });
|
|
3
|
+
const member = f('memberId', 'ID membre', 'id', { required:true });
|
|
4
|
+
const role = f('roleId', 'ID rôle', 'id', { required:true });
|
|
5
|
+
const channel = f('channelId', 'ID salon', 'id', { required:true });
|
|
6
|
+
const reason = f('reason', 'Raison', 'text', { max:512 });
|
|
7
|
+
const permissions = f('permissions', 'Permissions', 'permissions');
|
|
8
|
+
const roleFields = [f('name','Nom','text',{required:true,max:100}), f('color','Couleur principale','color'), f('colorStyle','Style de couleur','select',{default:'solid',options:{solid:'Classique',gradient:'Dégradé',holographic:'Holographique'}}), f('secondaryColor','Couleur secondaire du dégradé','color'), f('hoist','Afficher séparément','boolean'), f('mentionable','Mentionnable','boolean'), permissions, f('icon','Icône (image data URI base64)','text',{max:300000}), f('unicodeEmoji','Emoji Unicode','text',{max:100}), reason];
|
|
9
|
+
export const COMPLEMENTARY_MESSAGE_KINDS = ['addReaction', 'removeReaction', 'editMessage'];
|
|
10
|
+
const message = f('messageId','ID message','id',{required:true});
|
|
11
|
+
const emoji = f('emoji','Emoji Unicode ou ID emoji','text',{required:true,max:100});
|
|
12
|
+
export const DISCORD_BLOCKS = {
|
|
13
|
+
addReaction:{label:'Ajouter une réaction',category:'messages',permission:'AddReactions',fields:[channel,message,emoji]},
|
|
14
|
+
removeReaction:{label:'Retirer une réaction',category:'messages',permission:'ManageMessages',fields:[channel,message,emoji,f('userId','ID utilisateur (vide = le bot)','id')]},
|
|
15
|
+
editMessage:{label:'Modifier un message',category:'messages',permission:'SendMessages',fields:[channel,message,f('content','Nouveau texte','text',{required:true,max:2000})]},
|
|
16
|
+
banMember:{label:'Bannir',category:'members',permission:'BanMembers',fields:[member,reason,f('deleteMessageSeconds','Messages à effacer (secondes)','number',{min:0,max:604800,default:0})]},
|
|
17
|
+
unbanMember:{label:'Débannir',category:'members',permission:'BanMembers',fields:[member,reason]},
|
|
18
|
+
kickMember:{label:'Expulser',category:'members',permission:'KickMembers',fields:[member,reason]},
|
|
19
|
+
timeoutMember:{label:'Exclure temporairement',category:'members',permission:'ModerateMembers',fields:[member,f('seconds','Durée (secondes)','number',{min:1,max:2419200,default:60}),reason]},
|
|
20
|
+
removeTimeout:{label:'Retirer l’exclusion temporaire',category:'members',permission:'ModerateMembers',fields:[member,reason]},
|
|
21
|
+
nicknameMember:{label:'Modifier le surnom',category:'members',permission:'ManageNicknames',fields:[member,f('nickname','Surnom (vide pour réinitialiser)','text',{max:32}),reason]},
|
|
22
|
+
addRole:{label:'Ajouter un rôle',category:'roles',permission:'ManageRoles',fields:[role,member,reason]},
|
|
23
|
+
removeRole:{label:'Retirer un rôle',category:'roles',permission:'ManageRoles',fields:[role,member,reason]},
|
|
24
|
+
createRole:{label:'Créer un rôle',category:'roles',permission:'ManageRoles',fields:roleFields},
|
|
25
|
+
deleteRole:{label:'Supprimer un rôle',category:'roles',permission:'ManageRoles',fields:[role,reason]},
|
|
26
|
+
createChannel:{label:'Créer un salon',category:'channels',permission:'ManageChannels',fields:[f('name','Nom','text',{required:true,max:100}),f('type','Type','select',{default:'0',options:{0:'Texte',2:'Vocal',4:'Catégorie',5:'Annonces',13:'Scène',15:'Forum',16:'Média'}}),f('parent','ID catégorie','id'),f('topic','Sujet','text',{max:4096}),f('nsfw','Contenu sensible','boolean'),f('position','Position','number',{min:0,max:10000,default:0}),f('rateLimitPerUser','Mode lent (secondes)','number',{min:0,max:21600,default:0}),f('bitrate','Débit vocal (bits/s)','number',{min:8000,max:384000,default:64000}),f('userLimit','Limite de membres (0 = illimité)','number',{min:0,max:99,default:0}),f('rtcRegion','Région vocale (vide = automatique)'),f('videoQualityMode','Qualité vidéo','select',{default:'1',options:{1:'Automatique',2:'720p'}}),f('defaultAutoArchiveDuration','Archivage des fils (minutes)','select',{default:'1440',options:{60:'60',1440:'1440',4320:'4320',10080:'10080'}}),f('permissionOverwrites','Permissions du salon (tableau JSON)','json',{default:'[]'}),f('availableTags','Tags du forum (tableau JSON)','json',{default:'[]'}),f('defaultReactionEmoji','Réaction du forum (objet JSON)','json',{default:'{}'}),f('defaultThreadRateLimitPerUser','Mode lent des fils (secondes)','number',{min:0,max:21600,default:0}),f('defaultSortOrder','Tri du forum','select',{default:'0',options:{0:'Activité récente',1:'Date de création'}}),f('defaultForumLayout','Disposition du forum','select',{default:'0',options:{0:'Automatique',1:'Liste',2:'Galerie'}}),reason]},
|
|
27
|
+
deleteChannel:{label:'Supprimer un salon',category:'channels',permission:'ManageChannels',fields:[channel,reason]},
|
|
28
|
+
createInvite:{label:'Créer une invitation',category:'channels',permission:'CreateInstantInvite',fields:[channel,f('maxAge','Expiration (secondes, 0 = permanente)','number',{min:0,max:604800,default:86400}),f('maxUses','Utilisations (0 = illimité)','number',{min:0,max:100,default:0}),f('temporary','Adhésion temporaire','boolean'),f('unique','Créer un lien unique','boolean',{default:true}),reason]},
|
|
29
|
+
};
|
|
30
|
+
export const isDiscordBlock = kind => Object.hasOwn(DISCORD_BLOCKS, kind || '');
|
|
31
|
+
export const blockDefaults = kind => Object.fromEntries(DISCORD_BLOCKS[kind].fields.map(field => [field.key,field.default ?? (field.type === 'boolean' ? false : field.type === 'permissions' ? [] : '')]));
|
|
32
|
+
export function validateDiscordBlock(kind, config, templates = true) {
|
|
33
|
+
const schema = DISCORD_BLOCKS[kind];
|
|
34
|
+
if (!schema || !config || typeof config !== 'object') throw new Error('Bloc Discord invalide.');
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const field of schema.fields) {
|
|
37
|
+
let value = config[field.key] ?? blockDefaults(kind)[field.key];
|
|
38
|
+
const template = templates && typeof value === 'string' && /\{[^{}]+\}/.test(value);
|
|
39
|
+
const fail = () => { throw new Error(`${schema.label} : vérifiez ${field.label}.`); };
|
|
40
|
+
if (field.required && (typeof value !== 'string' || !value.trim())) fail();
|
|
41
|
+
if (field.type === 'number') { if (!template) { value = Number(value); if (!Number.isInteger(value) || value < field.min || value > field.max) fail(); } }
|
|
42
|
+
else if (field.type === 'boolean') { if (typeof value !== 'boolean') fail(); }
|
|
43
|
+
else if (field.type === 'permissions') { if (!Array.isArray(value) || value.length > DISCORD_PERMISSIONS.length || value.some(key => !DISCORD_PERMISSIONS.some(p => p.key === key))) fail(); }
|
|
44
|
+
else if (field.type === 'json') { try { value = typeof value === 'string' ? JSON.parse(value) : value; } catch { fail(); } if (!value || typeof value !== 'object' || JSON.stringify(value).length > 20000) fail(); if (['permissionOverwrites','availableTags'].includes(field.key) && !Array.isArray(value)) fail(); }
|
|
45
|
+
else { if (typeof value !== 'string' || value.length > (field.max || 1000)) fail(); if (field.type === 'id' && value && !template && !/^\d{17,20}$/.test(value)) fail(); if (field.type === 'color' && value && !template && !/^#[a-f\d]{6}$/i.test(value)) fail(); if (field.type === 'select' && !Object.hasOwn(field.options,value)) fail(); }
|
|
46
|
+
result[field.key] = value;
|
|
47
|
+
}
|
|
48
|
+
if (kind === 'createRole' && result.colorStyle==='gradient' && !result.secondaryColor) throw new Error('Choisissez la couleur secondaire du dégradé.');
|
|
49
|
+
if (kind === 'createRole' && result.icon && !/^data:image\/(png|jpeg|webp);base64,[a-zA-Z0-9+/=]+$/.test(result.icon)) throw new Error('L’icône doit être une image PNG, JPEG ou WebP encodée en data URI base64.');
|
|
50
|
+
if (kind === 'createRole' && result.icon && result.unicodeEmoji) throw new Error('Choisissez une icône ou un emoji pour le rôle.');
|
|
51
|
+
if (kind === 'createChannel' && [0,5].includes(Number(result.type)) && result.topic.length>1024) throw new Error('Le sujet d’un salon texte est limité à 1024 caractères.');
|
|
52
|
+
return result;
|
|
53
|
+
}
|