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.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # Oftone Server Agent
2
+
3
+ Agent Node.js 22+ de liaison avec le dashboard. Le relais est hébergé sur Cloudflare Workers avec un Durable Object SQLite ; les bots Discord s’exécutent sur le serveur utilisateur.
4
+
5
+ Générez la commande d’installation depuis **Liaison du serveur** dans le dashboard. Elle utilise un token à usage unique de dix minutes, puis l’agent dispose de sa propre identité. Exécutez-la avec un compte système dédié, sans privilèges administrateur.
6
+
7
+ Les fichiers privés et l’installation persistante se trouvent par défaut dans `~/.oftone-agent`. La variable `OFTONE_AGENT_HOME` permet de choisir un autre dossier, notamment pour héberger un deuxième bot. Ne partagez jamais le dossier privé entre utilisateurs.
8
+
9
+ Après installation, l’agent affiche la commande locale pour redémarrer sans télécharger le paquet :
10
+
11
+ ```text
12
+ node <dossier-utilisateur>/.oftone-agent/installation/cli.mjs run
13
+ ```
14
+
15
+ Le processus doit rester actif. Cette version n’installe pas de service système de démarrage automatique. Une coupure du réseau Cloudflare n’arrête pas le bot déjà lancé.
16
+
17
+ Le token Discord est configuré depuis le dashboard après liaison, chiffré pour la clé publique de cet agent et conservé localement sous forme d’enveloppe chiffrée. La clé privée locale permet au processus de le déchiffrer pour lancer le bot. Les permissions du compte système restent essentielles ; sous Windows, vérifier les ACL du dossier.
18
+
19
+ Le bouton **Déployer** transmet un snapshot des commandes enregistrées. L’agent valide ce JSON, le compile avec les fonctions embarquées et démarre une version locale. Aucun JavaScript, script shell ou paquet npm fourni par le JSON n’est exécuté. Les dépendances proviennent du paquet de l’agent, verrouillées par `npm-shrinkwrap.json`.
20
+
21
+ La commande de déploiement remplace les slash commands de l’application Discord : utiliser une application dédiée. Les changements de token et la rotation de l’identité de l’agent restent à compléter. Les processus des bots ne sont pas encore isolés dans des conteneurs.
22
+
23
+ Pour reconstruire le paquet : `npm pack --ignore-scripts`. Le dossier du builder contient `server-link/README.md`, les tests du relais et la configuration Wrangler. Le paquet public est servi par le Worker ; aucun envoi au registre npm n’est nécessaire.
24
+
25
+
26
+ ## Version 0.5.2
27
+
28
+ Les envois vers un salon vérifient désormais le serveur et les droits de
29
+ l’utilisateur, ainsi que l’appartenance aux fils privés. Le moteur adapte sa
30
+ concurrence à la charge CPU globale du PC, à la mémoire disponible et à sa
31
+ réactivité. Les pauses libèrent une place de traitement ; la file et les
32
+ invocations conservées restent bornées pour protéger l’hébergement.
33
+
34
+ Installation : `npm install -g oft-agt` (Node.js 22 ou supérieur).
35
+ La mise à jour du paquet npm ne remplace pas automatiquement les installations
36
+ persistantes déjà liées dans le dossier privé de l’agent.
package/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import { localStatus, startBackground, stopBackground, managedDaemon, readLogs, removeLocalInstance, ensureLocalLauncher } from './runtime/lifecycle.mjs';
3
3
  import { completeCredentialRotation, rotateCredential } from './runtime/agent-auth.mjs';
4
4
  import { authorizeDevice } from './runtime/device-login.mjs';
@@ -13,9 +13,9 @@ import WebSocket from 'ws';
13
13
  import { writeBot, compile } from './compiler.mjs';
14
14
  import { acceptSecret } from './secret.mjs';
15
15
  import { createUsageCollector } from './usage.mjs';
16
- import { instanceDirectory, resolveInstance, listInstances, rememberInstance } from './instance.mjs';
16
+ import { instanceDirectory, resolveInstance, listInstances, rememberInstance } from './instance.mjs';
17
17
  import { AGENT_VERSION } from './version.mjs';
18
- import { installUpdate } from './update.mjs';
18
+ import { installOfficialUpdate } from './update.mjs';
19
19
  import { supervise } from './supervisor.mjs';
20
20
 
21
21
  const baseHome = process.env.OFTONE_AGENT_HOME || path.join(homedir(), '.oftone-agent');
@@ -56,7 +56,7 @@ async function restartBot(config) {
56
56
  if (!/^[a-f0-9-]{36}$/.test(release.id)) throw new Error('INVALID_RELEASE');
57
57
  const secret = await credentials(config);
58
58
  await stopBot();
59
- child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: path.join(home, 'releases', release.id), env: { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP'].filter(key => process.env[key]).map(key => [key, process.env[key]])), DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
59
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: path.join(home, 'releases', release.id), env: { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP'].filter(key => process.env[key]).map(key => [key, process.env[key]])), DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId, OFTONE_VARIABLES_PATH: path.join(home, 'data', 'variables.sqlite') }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
60
60
  secret.token = '';
61
61
  child.stderr.on('data', () => {});
62
62
  await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.kill(); reject(new Error('BOT_START_TIMEOUT')); }, 45000); const fail = () => { clearTimeout(timer); reject(new Error('BOT_START_FAILED')); }; child.once('error', fail); child.once('exit', fail); child.stdout.on('data', bytes => { if (bytes.toString().includes('Bot connecté :')) { clearTimeout(timer); child.off('error', fail); child.off('exit', fail); resolve(); } }); });
@@ -80,6 +80,7 @@ async function deploy(config, message, notify) {
80
80
  const env = { ...Object.fromEntries(['PATH', 'HOME', 'USERPROFILE', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'LANG', 'TZ'].filter(key => process.env[key]).map(key => [key, process.env[key]])), DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId };
81
81
  // Remove credentials unrelated to running the Discord bot.
82
82
  for (const key of Object.keys(env)) if (/OFTONE|AGENT_RELAY|CLOUDFLARE|SESSION_SECRET/.test(key)) delete env[key];
83
+ env.OFTONE_VARIABLES_PATH = path.join(home, 'data', 'variables.sqlite');
83
84
  await run(process.execPath, ['bot.mjs', '--deploy'], { cwd: target, env, stdio: 'ignore' });
84
85
  const previous = child;
85
86
  const previousId = active;
@@ -136,7 +137,7 @@ async function start(config) {
136
137
  if (!/^[a-f0-9-]{36}$/.test(previous.id)) throw new Error('État local invalide.');
137
138
  const secret = await credentials(config);
138
139
  const target = path.join(home, 'releases', previous.id);
139
- child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: target, env: { PATH: process.env.PATH, DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, shell: false }));
140
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: target, env: { PATH: process.env.PATH, DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId, OFTONE_VARIABLES_PATH: path.join(home, 'data', 'variables.sqlite') }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, shell: false }));
140
141
  child.on('error', () => {});
141
142
  active = previous.id;
142
143
  }
@@ -156,7 +157,7 @@ async function start(config) {
156
157
  try { const response = await fetch('https://discord.com/api/v10/users/@me', { headers: { Authorization: 'Bot ' + secret.token }, signal: AbortSignal.timeout(15000) }); if (response.ok) { const user = await response.json(); profile = { id: user.id, username: user.username, avatar: user.avatar }; } } finally { secret.token = ''; }
157
158
  } catch {}
158
159
  }
159
- send({ type: 'hello', agentVersion: AGENT_VERSION, updatesVersion: 1, protocol: 2, errorBranch: true, diagnosticsVersion: 2, optionsVersion: 1, conditionsVersion: 1, metricsVersion: usageReady ? 1 : 0, profile, running: Boolean(child && child.exitCode === null && !child.signalCode), hasToken: await exists(secretPath), deploymentId: active });
160
+ send({ type: 'hello', agentVersion: AGENT_VERSION, updatesVersion: 1, officialUpdatesVersion: 1, protocol: 2, errorBranch: true, diagnosticsVersion: 2, optionsVersion: 1, conditionsVersion: 1, complementaryBlocksVersion: 1, discordActionsVersion: 1, messageButtonsVersion: 2, variablesVersion: Number(process.versions.node.split('.')[0]) > 22 || (Number(process.versions.node.split('.')[0]) === 22 && Number(process.versions.node.split('.')[1]) >= 5) ? 1 : 0, metricsVersion: usageReady ? 1 : 0, profile, running: Boolean(child && child.exitCode === null && !child.signalCode), hasToken: await exists(secretPath), deploymentId: active });
160
161
  if (process.connected) process.send?.({ type: 'agent-ready' }, () => {});
161
162
  await usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.'));
162
163
  usageTimer = setInterval(() => usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.')), 60000);
@@ -170,7 +171,7 @@ async function start(config) {
170
171
  if (message.type === 'update') {
171
172
  console.log(`[${new Date().toISOString()}] Mise à jour de l’agent demandée.`);
172
173
  try {
173
- await installUpdate(message.release, { home, currentVersion: AGENT_VERSION, source: path.dirname(fileURLToPath(import.meta.url)), report: state => { send({ type: 'update-status', state }); console.log(({ downloading: 'Téléchargement du paquet signé.', installing: 'Signature vérifiée. Installation de la mise à jour.', restarting: 'Installation vérifiée. Redémarrage de l’agent.' })[state]); } });
174
+ await installOfficialUpdate(message.version || message.release?.version, { home, currentVersion: AGENT_VERSION, source: path.dirname(fileURLToPath(import.meta.url)), report: state => { send({ type: 'update-status', state }); console.log(({ downloading: 'Téléchargement du paquet signé.', installing: 'Signature vérifiée. Installation de la mise à jour.', restarting: 'Installation vérifiée. Redémarrage de l’agent.' })[state]); } });
174
175
  updating = true; stopped = true; shutdownBot = true; socket.close(1000, 'Mise à jour');
175
176
  } catch { send({ type: 'update-status', state: 'failed' }); console.error('Mise à jour refusée ou échouée. La version actuelle est conservée.'); }
176
177
  } else if (message.type === 'secret') {
@@ -276,16 +277,16 @@ async function main() {
276
277
  for (const entry of ['cli.mjs', 'instance.mjs', 'version.mjs', 'release-key.mjs', 'update.mjs', 'supervisor.mjs', 'secret.mjs', 'usage.mjs', 'compiler.mjs', 'compile-plan.mjs', 'package.json', 'npm-shrinkwrap.json', 'runtime', 'templates']) await cp(path.join(source, entry), path.join(install, entry), { recursive: true });
277
278
  const modules = path.resolve(path.dirname(require.resolve('discord.js')), '../..');
278
279
  await cp(modules, path.join(install, 'node_modules'), { recursive: true, filter: filename => path.resolve(filename) !== path.resolve(source) });
279
- await rememberInstance(baseHome, config);
280
- console.log('Agent lié : ' + config.agentId.slice(0, 8) + '.');
280
+ await rememberInstance(baseHome, config);
281
+ console.log('Agent lié : ' + config.agentId.slice(0, 8) + '.');
281
282
  if (pendingFile) await unlink(pendingFile).catch(() => {});
282
283
  if (args.includes('--foreground')) { process.env.OFTONE_FOREGROUND = '1'; return supervise(home, source); }
283
- await startBackground(home, install); console.log('Agent démarré.'); return;
284
+ await startBackground(home, install); console.log('Agent démarré.'); return;
284
285
  }
285
286
  if (['run', 'start', 'stop', 'restart', 'status', 'logs', 'remove', 'token', 'upgrade'].includes(command)) {
286
287
  const selector = args.find(arg => arg.startsWith('--bot='))?.slice(6) || args.find(arg => arg.startsWith('--instance='))?.slice('--instance='.length);
287
288
  useHome(await resolveInstance(baseHome, path.dirname(fileURLToPath(import.meta.url)), selector));
288
- if (selector) await rememberInstance(baseHome, JSON.parse(await readFile(configPath, 'utf8')));
289
+ if (selector) await rememberInstance(baseHome, JSON.parse(await readFile(configPath, 'utf8')));
289
290
  }
290
291
  if (command === 'upgrade') {
291
292
  await stopBackground(home);
package/compile-plan.mjs CHANGED
@@ -1,133 +1,303 @@
1
+ import { isVariableBlock, validateVariableBlock, validateVariableDefinitions } from './runtime/custom-variables.mjs';
2
+ import { validateButton } from './runtime/message-buttons.mjs';
3
+ import { isDiscordBlock, validateDiscordBlock } from './runtime/discord-blocks.mjs';
4
+
1
5
  import { isCondition, validateCondition } from './runtime/conditions.mjs';
6
+
2
7
  import { isOption, compileOptions } from './runtime/command-options.mjs';
8
+
3
9
  import { normalizeMessageConfig, messageConfigError } from './runtime/message-config.mjs';
4
10
 
11
+
12
+
5
13
  const ID = /^\d{17,20}$/;
14
+
6
15
  const plain = value => value && typeof value === 'object' && !Array.isArray(value);
16
+
7
17
  function fail(message) { throw new Error(message); }
18
+
8
19
  function text(value, name, max, required = false) {
20
+
9
21
  if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail(`${name} : texte ${required ? 'non vide ' : ''}de ${max} caractères maximum attendu.`);
22
+
10
23
  return value;
24
+
11
25
  }
12
26
 
27
+
28
+
13
29
  /** Pure JSON -> validated function call plan. No filesystem, network or code evaluation. */
30
+
14
31
  export function compile(input) {
32
+
15
33
  if (!plain(input) || !Array.isArray(input.commands) || input.commands.length > 100) fail('commands doit être un tableau de 100 commandes maximum.');
34
+
35
+ const variableDefinitions = validateVariableDefinitions(input.variableDefinitions || []);
36
+ const variableDeletions = (input.variableDeletions || []).map(item => { if (!item || typeof item.id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(item.id) || typeof item.purge !== 'boolean') fail('Suppression de variable invalide.'); return { id: item.id, purge: item.purge }; });
37
+ if (variableDeletions.length > 10000) fail('Trop de suppressions de variables.');
16
38
  const names = new Set();
39
+
17
40
  const commands = input.commands.map((entry, commandIndex) => {
41
+
18
42
  const command = Array.isArray(entry?.blocks) ? entry : entry?.config;
43
+
19
44
  if (!plain(command)) fail(`Commande ${commandIndex + 1} : configuration absente.`);
45
+
20
46
  const name = text(command.name ?? entry.name, 'Nom de commande', 32, true);
47
+
21
48
  if (!/^[a-z0-9_-]{1,32}$/.test(name)) fail(`/${name} : utilisez des minuscules, chiffres, tirets ou underscores.`);
49
+
22
50
  if (names.has(name)) fail(`Commande dupliquée : /${name}.`);
51
+
23
52
  names.add(name);
53
+
24
54
  const description = text(command.description ?? entry.description, `/${name} description`, 100, true);
55
+
25
56
  if (!Array.isArray(command.blocks) || !command.blocks.length || command.blocks.length > 500 || !Array.isArray(command.edges)) fail(`/${name} : blocs ou liaisons invalides (500 blocs maximum).`);
57
+
26
58
  const blocks = new Map();
59
+
27
60
  for (const block of command.blocks) {
61
+
28
62
  if (!plain(block) || typeof block.id !== 'string' || !block.id || block.id.length > 128 || blocks.has(block.id)) fail(`/${name} : identifiant de bloc absent, invalide ou dupliqué.`);
29
- if (!isOption(block.type) && !isCondition(block.type) && !['root', 'errorRoot', 'conditionBranch', 'sendMessage', 'wait', 'deleteMessage'].includes(block.type)) fail(`/${name}/${block.id} : type de bloc inconnu : ${block.type}.`);
63
+
64
+ if (!isVariableBlock(block.type) && !isDiscordBlock(block.type) && !isOption(block.type) && !isCondition(block.type) && !['root', 'errorRoot', 'conditionBranch', 'messageButton', 'sendMessage', 'wait', 'deleteMessage'].includes(block.type)) fail(`/${name}/${block.id} : type de bloc inconnu : ${block.type}.`);
65
+
30
66
  if (!plain(block.config || {})) fail(`/${name}/${block.id} : paramètres invalides.`);
67
+
31
68
  blocks.set(block.id, block);
69
+
32
70
  }
71
+
33
72
  const roots = [...blocks.values()].filter(block => block.type === 'root');
73
+
34
74
  if (roots.length !== 1) fail(`/${name} doit contenir exactement un bloc racine.`);
75
+
35
76
  const errorRoots = [...blocks.values()].filter(block => block.type === 'errorRoot');
77
+
36
78
  if (errorRoots.length > 1) fail(`/${name} : une seule racine d’erreur est autorisée.`);
79
+
37
80
  const options = compileOptions(command.blocks);
81
+
38
82
  const optionLinks = new Set();
83
+
39
84
  const conditionConfigs = new Map([...blocks.values()].filter(block => isCondition(block.type)).map(block => [block.id, validateCondition(block.type, block.config)]));
85
+
40
86
  const branchLinks = new Map();
87
+
88
+ const buttonLinks=new Map();
89
+ const buttonBranches=[];
41
90
  const next = new Map();
91
+
42
92
  const incoming = new Set();
93
+
43
94
  for (const edge of command.edges) {
95
+ if (blocks.get(edge?.target)?.type==='messageButton') {
96
+ const child=blocks.get(edge.target);
97
+ if(blocks.get(edge.source)?.type!=='sendMessage'||child.config.parentId!==edge.source||incoming.has(edge.target)) fail('Un bouton reste lié à son message.');
98
+ const list=buttonLinks.get(edge.source)||[];list.push(child);buttonLinks.set(edge.source,list);incoming.add(edge.target);continue;
99
+ }
100
+ if(blocks.get(edge?.source)?.type==='messageButton'&&Number(blocks.get(edge.source).config.style||1)>=5) fail('Un bouton lien ou achat ne déclenche pas de blocs.');
101
+
44
102
  if (plain(edge) && isOption(blocks.get(edge.source)?.type)) {
103
+
45
104
  if (edge.target !== roots[0].id || optionLinks.has(edge.source)) fail(`/${name} : chaque option doit être liée uniquement à la racine principale.`);
105
+
46
106
  optionLinks.add(edge.source); continue;
107
+
47
108
  }
109
+
48
110
  if (plain(edge) && isOption(blocks.get(edge.target)?.type)) fail(`/${name} : une action ne peut pas être liée à une option.`);
111
+
49
112
  if (!plain(edge) || !blocks.has(edge.source) || !blocks.has(edge.target) || ['root', 'errorRoot'].includes(blocks.get(edge.target)?.type)) fail(`/${name} : liaison invalide.`);
113
+
50
114
  if (isCondition(blocks.get(edge.source).type)) {
115
+
51
116
  const branch = blocks.get(edge.target);
117
+
52
118
  const ruleId = branch.config?.ruleId;
119
+
53
120
  const allowed = ['else', ...conditionConfigs.get(edge.source).rules.map(rule => rule.id)];
121
+
54
122
  const linked = branchLinks.get(edge.source) || new Map();
123
+
55
124
  if (branch.type !== 'conditionBranch' || branch.config.parentId !== edge.source || !allowed.includes(ruleId) || linked.has(ruleId) || incoming.has(edge.target)) fail(`/${name} : branche de condition invalide.`);
125
+
56
126
  linked.set(ruleId, edge.target); branchLinks.set(edge.source, linked); incoming.add(edge.target); continue;
127
+
57
128
  }
129
+
58
130
  if (blocks.get(edge.target).type === 'conditionBranch') fail(`/${name} : les branches restent liées à leur condition.`);
131
+
59
132
  if (next.has(edge.source)) fail(`/${name}/${edge.source} : plusieurs sorties ne sont pas encore prises en charge. Reliez les actions en séquence.`);
133
+
60
134
  if (incoming.has(edge.target)) fail(`/${name}/${edge.target} : plusieurs entrées ne sont pas prises en charge.`);
135
+
61
136
  next.set(edge.source, edge.target);
137
+
62
138
  incoming.add(edge.target);
139
+
63
140
  }
141
+
64
142
  if (optionLinks.size !== options.length) fail(`/${name} : toutes les options doivent être liées à la racine.`);
143
+
65
144
  for (const [id, config] of conditionConfigs) if (branchLinks.get(id)?.size !== config.rules.length + 1) fail(`/${name} : chaque condition nécessite ses branches et une branche Sinon.`);
145
+
146
+ for(const block of blocks.values()) if(block.type==='messageButton'&&!incoming.has(block.id))fail('Bouton détaché de son message.');
147
+ for(const list of buttonLinks.values()) {
148
+ const rows=[0,0,0,0,0]; if(list.length>25)fail('25 boutons maximum par message.');
149
+ for(const b of list){const c=validateButton(b.config);if(++rows[c.row]>5)fail('5 boutons maximum par ligne.');}
150
+ }
66
151
  const visited = new Set(options.map(option => option.id));
152
+
67
153
  function compilePath(rootId, inheritedMessages = new Set(), inheritedWait = 0, depth = 0) {
154
+
68
155
  if (depth > 32) fail(`/${name} : conditions imbriquées trop profondément (32 maximum).`);
156
+
69
157
  const emittedMessages = new Set(inheritedMessages);
158
+
70
159
  const calls = [];
160
+
71
161
  let current = rootId;
162
+
72
163
  let waitBudget = inheritedWait;
164
+
73
165
  while (current) {
166
+
74
167
  if (visited.has(current)) fail(`/${name} : boucle détectée.`);
168
+
75
169
  visited.add(current);
170
+
76
171
  const block = blocks.get(current);
172
+
77
173
  const config = block.config || {};
174
+
78
175
  let args;
176
+
79
177
  let fn;
178
+
80
179
  if (isCondition(block.type)) {
180
+
81
181
  const branches = branchLinks.get(current);
182
+
82
183
  const rules = conditionConfigs.get(current).rules;
184
+
83
185
  calls.push({ id: current, fn: 'condition', args: { kind: block.type, rules }, branches: rules.map(rule => ({ id: branches.get(rule.id), ruleId: rule.id, calls: compilePath(branches.get(rule.id), emittedMessages, waitBudget, depth + 1) })), otherwise: { id: branches.get('else'), calls: compilePath(branches.get('else'), emittedMessages, waitBudget, depth + 1) } });
186
+
187
+ } else if (isVariableBlock(block.type)) {
188
+ fn = block.type; args = validateVariableBlock(block.type, config, variableDefinitions);
189
+ } else if (isDiscordBlock(block.type)) {
190
+
191
+ fn = block.type; args = validateDiscordBlock(block.type,config);
192
+
193
+ if (block.type === 'createChannel') emittedMessages.add(current);
194
+
84
195
  } else if (block.type === 'sendMessage') {
196
+
85
197
  text(config.text ?? '', `/${name}/${current} message`, 2000);
198
+
86
199
  if (config.embeds !== undefined && (!Array.isArray(config.embeds) || config.embeds.some(embed => !plain(embed)))) fail(`/${name}/${current} : embeds invalides.`);
200
+
87
201
  const normal = normalizeMessageConfig(config);
202
+
88
203
  if (!['reply', 'channel', 'dm'].includes(normal.sendMode)) fail(`/${name}/${current} : type d’envoi inconnu.`);
204
+
89
205
  if (config.sendMode && config.replyMode && !['command', 'block', 'message'].includes(config.replyMode)) fail(`/${name}/${current} : mode de réponse inconnu.`);
90
- if (config.channelMode && !['current', 'id'].includes(config.channelMode)) fail(`/${name}/${current} : mode de salon inconnu.`);
206
+
207
+ if (config.channelMode && !['current', 'id', 'created'].includes(config.channelMode)) fail(`/${name}/${current} : mode de salon inconnu.`);
208
+
91
209
  if (config.dmMode && !['author', 'id'].includes(config.dmMode)) fail(`/${name}/${current} : destinataire MP inconnu.`);
92
- const error = messageConfigError(normal, command.blocks);
210
+
211
+ if (normal.channelMode === 'created' && (!emittedMessages.has(normal.createdChannelBlockId) || blocks.get(normal.createdChannelBlockId)?.type !== 'createChannel')) fail('Le salon créé doit provenir d’un bloc exécuté avant ce message.');
212
+
213
+ const error = messageConfigError({...normal,buttons:buttonLinks.get(current)}, command.blocks);
214
+
93
215
  if (error) fail(`/${name}/${current} : ${error}`);
216
+
94
217
  if (normal.sendMode === 'reply' && normal.replyMode === 'block' && !emittedMessages.has(normal.targetBlockId)) fail(`/${name}/${current} : le message cible doit être envoyé avant la réponse.`);
218
+
95
219
  // Only accepted arguments are emitted; tokens and unrelated JSON properties are discarded.
96
- args = Object.fromEntries(['text', 'embeds', 'sendMode', 'replyMode', 'targetBlockId', 'messageId', 'channelMode', 'channelId', 'dmMode', 'userId', 'ephemeral'].map(key => [key, normal[key]]));
220
+
221
+ args = Object.fromEntries(['text', 'embeds', 'sendMode', 'replyMode', 'targetBlockId', 'messageId', 'channelMode', 'channelId', 'createdChannelBlockId', 'dmMode', 'userId', 'ephemeral'].map(key => [key, normal[key]]));
222
+
97
223
  args.embeds = normal.embeds.map(embed => ({
224
+
98
225
  ...Object.fromEntries(['title', 'description', 'url', 'color', 'timestamp'].filter(key => embed[key] !== undefined).map(key => [key, embed[key]])),
226
+
99
227
  ...(embed.author ? { author: Object.fromEntries(['name', 'url', 'icon_url'].filter(key => embed.author[key] !== undefined).map(key => [key, embed.author[key]])) } : {}),
228
+
100
229
  ...(embed.footer ? { footer: Object.fromEntries(['text', 'icon_url'].filter(key => embed.footer[key] !== undefined).map(key => [key, embed.footer[key]])) } : {}),
230
+
101
231
  ...(embed.image ? { image: { url: embed.image.url } } : {}),
232
+
102
233
  ...(embed.thumbnail ? { thumbnail: { url: embed.thumbnail.url } } : {}),
234
+
103
235
  ...(embed.fields ? { fields: embed.fields.map(field => ({ name: field.name, value: field.value, inline: Boolean(field.inline) })) } : {}),
236
+
104
237
  }));
238
+
105
239
  fn = 'sendMessage';
240
+
106
241
  emittedMessages.add(current);
242
+ const children=buttonLinks.get(current)||[];
243
+ if(children.length) args.buttons=children.map(child=>({id:child.id,config:validateButton(child.config)}));
244
+ for(const child of children){
245
+ const config=validateButton(child.config);
246
+ const childCalls=compilePath(child.id,emittedMessages,0,depth+1);
247
+ buttonBranches.push({id:child.id,parentId:current,config,calls:childCalls});
248
+ }
249
+
107
250
  } else if (block.type === 'wait') {
251
+
108
252
  const seconds = Number(config.seconds);
253
+
109
254
  if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) fail(`/${name}/${current} : délai attendu entre 0 et 300 secondes.`);
255
+
110
256
  waitBudget += seconds;
257
+
111
258
  if (waitBudget > 840) fail(`/${name} : les délais cumulés dépassent 14 minutes.`);
259
+
112
260
  fn = 'wait'; args = { seconds };
261
+
113
262
  } else if (block.type === 'deleteMessage') {
263
+
114
264
  const deleteMode = config.deleteMode || 'block';
265
+
115
266
  if (!['block', 'manual'].includes(deleteMode)) fail(`/${name}/${current} : mode de suppression invalide.`);
116
- if (deleteMode === 'block' && !emittedMessages.has(config.targetBlockId)) fail(`/${name}/${current} : le message à supprimer doit déjà avoir été envoyé.`);
267
+
268
+ if (deleteMode === 'block' && (!emittedMessages.has(config.targetBlockId) || blocks.get(config.targetBlockId)?.type !== 'sendMessage')) fail(`/${name}/${current} : le message à supprimer doit déjà avoir été envoyé.`);
269
+
117
270
  if (deleteMode === 'manual' && !ID.test(config.messageId || '') && !/^\{[a-z0-9_-]+_option(?:_id)?\}$/.test(config.messageId || '')) fail(`/${name}/${current} : ID de message invalide.`);
271
+
118
272
  fn = 'deleteMessage'; args = deleteMode === 'block' ? { deleteMode, targetBlockId: config.targetBlockId } : { deleteMode, messageId: config.messageId };
273
+
119
274
  }
275
+
120
276
  if (fn) calls.push({ id: current, fn, args });
277
+
121
278
  current = next.get(current);
279
+
122
280
  }
281
+
123
282
  return calls;
283
+
124
284
  }
285
+
125
286
  const calls = compilePath(roots[0].id);
287
+
126
288
  const errorCalls = errorRoots.length ? compilePath(errorRoots[0].id) : undefined;
289
+
127
290
  if (visited.size !== blocks.size) fail(`/${name} : blocs non reliés à une racine ou boucle dans une partie non accessible.`);
291
+
128
292
  if (!calls.length || (errorCalls && !errorCalls.length)) fail(`/${name} : ajoutez au moins une action à chaque racine.`);
129
- return { name, description, ...(options.length ? { options } : {}), calls, ...(errorCalls ? { errorCalls } : {}) };
293
+
294
+ return { name, description, ...(options.length ? { options } : {}), calls, ...(buttonBranches.length?{buttonBranches}:{}), ...(errorCalls ? { errorCalls } : {}) };
295
+
130
296
  });
131
- return JSON.parse(JSON.stringify({ schemaVersion: 1, bot: { name: typeof input.bot?.name === 'string' ? input.bot.name.slice(0, 100) : 'Oftone Bot' }, commands }));
297
+
298
+ return JSON.parse(JSON.stringify({ schemaVersion: 1, ...(input.variableDefinitions !== undefined ? { variableDefinitions, variableDeletions } : {}), bot: { name: typeof input.bot?.name === 'string' ? input.bot.name.slice(0, 100) : 'Oftone Bot' }, commands }));
299
+
132
300
  }
133
301
 
302
+
303
+
package/compiler.mjs CHANGED
@@ -1,55 +1,55 @@
1
- #!/usr/bin/env node
2
- import { readFile, writeFile, mkdir, cp, access } from 'node:fs/promises';
3
- import path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
- import { compile } from './compile-plan.mjs';
6
- export { compile } from './compile-plan.mjs';
7
- function fail(message) { throw new Error(message); }
8
-
9
- const ROOT = path.dirname(fileURLToPath(import.meta.url));
10
- /** Emit a standalone Node.js project. Refuse to replace an existing directory. */
11
- export async function writeBot(input, outputDirectory) {
12
- const plan = compile(input);
13
- const output = path.resolve(outputDirectory);
14
- try { await access(output); fail(`Le dossier de sortie existe déjà : ${output}`); }
15
- catch (error) { if (error.code !== 'ENOENT') throw error; }
16
- await mkdir(output, { recursive: false });
17
- await cp(path.join(ROOT, 'runtime'), path.join(output, 'runtime'), { recursive: true });
18
- await cp(path.join(ROOT, 'templates/bot.mjs'), path.join(output, 'bot.mjs'));
19
- await writeFile(path.join(output, 'plan.json'), JSON.stringify(plan, null, 2) + '\n');
20
- await writeFile(path.join(output, 'package.json'), JSON.stringify({ name: 'oftone-generated-bot', version: '1.0.0', private: true, type: 'module', engines: { node: '>=22' }, scripts: { start: 'node --env-file=.env bot.mjs', deploy: 'node --env-file=.env bot.mjs --deploy' }, dependencies: { 'discord.js': '^14.14.0' } }, null, 2) + '\n');
21
- await writeFile(path.join(output, '.env.example'), '# Token saisi uniquement sur votre serveur.\nDISCORD_TOKEN=\n# Application ID Discord (pas l’identifiant du projet Builder).\nDISCORD_CLIENT_ID=\n# Optionnel : serveur de test. Vide = commandes globales.\nDISCORD_GUILD_ID=\n');
22
- await writeFile(path.join(output, '.gitignore'), '.env\nnode_modules/\n');
23
- return { output, plan };
24
- }
25
-
26
- async function main() {
27
- const args = process.argv.slice(2);
28
- if (!args.length || args.includes('--help')) {
29
- console.log('Oftone Compiler\n node compiler.mjs <builder.json> --out <nouveau-dossier>\n node compiler.mjs --stdin --out <nouveau-dossier>\n node compiler.mjs <builder.json> --check\n\nAucun token requis. --stdin permet une invocation par un futur service.');
30
- return;
31
- }
32
- const allowed = new Set(['--stdin', '--check', '--out']);
33
- for (const arg of args) if (arg.startsWith('--') && !allowed.has(arg)) fail(`Option inconnue : ${arg}`);
34
- let source;
35
- if (args[0] === '--stdin') {
36
- const chunks = [];
37
- let size = 0;
38
- for await (const chunk of process.stdin) {
39
- size += chunk.length;
40
- if (size > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
41
- chunks.push(chunk);
42
- }
43
- source = Buffer.concat(chunks).toString('utf8');
44
- } else source = await readFile(args[0], 'utf8');
45
- if (Buffer.byteLength(source) > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
46
- const input = JSON.parse(source);
47
- if (args.includes('--check')) { const plan = compile(input); console.log(`JSON valide : ${plan.commands.length} commande(s).`); return; }
48
- const outIndex = args.indexOf('--out');
49
- if (outIndex < 0 || !args[outIndex + 1] || args[outIndex + 1].startsWith('--')) fail('Précisez --out <nouveau-dossier>.');
50
- const { output, plan } = await writeBot(input, args[outIndex + 1]);
51
- console.log(`Bot généré : ${output}\n${plan.commands.length} commande(s). Renseignez .env, puis npm install et npm run deploy avant npm start.`);
52
- }
53
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
54
- main().catch(error => { console.error(`Compilation refusée : ${error.message}`); process.exitCode = 1; });
55
- }
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile, mkdir, cp, access } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { compile } from './compile-plan.mjs';
6
+ export { compile } from './compile-plan.mjs';
7
+ function fail(message) { throw new Error(message); }
8
+
9
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
10
+ /** Emit a standalone Node.js project. Refuse to replace an existing directory. */
11
+ export async function writeBot(input, outputDirectory) {
12
+ const plan = compile(input);
13
+ const output = path.resolve(outputDirectory);
14
+ try { await access(output); fail(`Le dossier de sortie existe déjà : ${output}`); }
15
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
16
+ await mkdir(output, { recursive: false });
17
+ await cp(path.join(ROOT, 'runtime'), path.join(output, 'runtime'), { recursive: true });
18
+ await cp(path.join(ROOT, 'templates/bot.mjs'), path.join(output, 'bot.mjs'));
19
+ await writeFile(path.join(output, 'plan.json'), JSON.stringify(plan, null, 2) + '\n');
20
+ await writeFile(path.join(output, 'package.json'), JSON.stringify({ name: 'oftone-generated-bot', version: '1.0.0', private: true, type: 'module', engines: { node: '>=22.5.0' }, scripts: { start: 'node --env-file=.env bot.mjs', deploy: 'node --env-file=.env bot.mjs --deploy' }, dependencies: { 'discord.js': '^14.14.0' } }, null, 2) + '\n');
21
+ await writeFile(path.join(output, '.env.example'), '# Token saisi uniquement sur votre serveur.\nDISCORD_TOKEN=\n# Application ID Discord (pas l’identifiant du projet Builder).\nDISCORD_CLIENT_ID=\n# Optionnel : serveur de test. Vide = commandes globales.\nDISCORD_GUILD_ID=\n');
22
+ await writeFile(path.join(output, '.gitignore'), '.env\nnode_modules/\ndata/\n');
23
+ return { output, plan };
24
+ }
25
+
26
+ async function main() {
27
+ const args = process.argv.slice(2);
28
+ if (!args.length || args.includes('--help')) {
29
+ console.log('Oftone Compiler\n node compiler.mjs <builder.json> --out <nouveau-dossier>\n node compiler.mjs --stdin --out <nouveau-dossier>\n node compiler.mjs <builder.json> --check\n\nAucun token requis. --stdin permet une invocation par un futur service.');
30
+ return;
31
+ }
32
+ const allowed = new Set(['--stdin', '--check', '--out']);
33
+ for (const arg of args) if (arg.startsWith('--') && !allowed.has(arg)) fail(`Option inconnue : ${arg}`);
34
+ let source;
35
+ if (args[0] === '--stdin') {
36
+ const chunks = [];
37
+ let size = 0;
38
+ for await (const chunk of process.stdin) {
39
+ size += chunk.length;
40
+ if (size > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
41
+ chunks.push(chunk);
42
+ }
43
+ source = Buffer.concat(chunks).toString('utf8');
44
+ } else source = await readFile(args[0], 'utf8');
45
+ if (Buffer.byteLength(source) > 5 * 1024 * 1024) fail('Le JSON dépasse 5 Mo.');
46
+ const input = JSON.parse(source);
47
+ if (args.includes('--check')) { const plan = compile(input); console.log(`JSON valide : ${plan.commands.length} commande(s).`); return; }
48
+ const outIndex = args.indexOf('--out');
49
+ if (outIndex < 0 || !args[outIndex + 1] || args[outIndex + 1].startsWith('--')) fail('Précisez --out <nouveau-dossier>.');
50
+ const { output, plan } = await writeBot(input, args[outIndex + 1]);
51
+ console.log(`Bot généré : ${output}\n${plan.commands.length} commande(s). Renseignez .env, puis npm install et npm run deploy avant npm start.`);
52
+ }
53
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
54
+ main().catch(error => { console.error(`Compilation refusée : ${error.message}`); process.exitCode = 1; });
55
+ }