oft-agt 0.5.1 → 0.5.8

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, eventsVersion: 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,305 @@
1
+ import { validateEventGraph } from './runtime/events.mjs';
2
+ import { isVariableBlock, validateVariableBlock, validateVariableDefinitions } from './runtime/custom-variables.mjs';
3
+ import { validateButton } from './runtime/message-buttons.mjs';
4
+ import { isDiscordBlock, validateDiscordBlock } from './runtime/discord-blocks.mjs';
5
+
1
6
  import { isCondition, validateCondition } from './runtime/conditions.mjs';
7
+
2
8
  import { isOption, compileOptions } from './runtime/command-options.mjs';
9
+
3
10
  import { normalizeMessageConfig, messageConfigError } from './runtime/message-config.mjs';
4
11
 
12
+
13
+
5
14
  const ID = /^\d{17,20}$/;
15
+
6
16
  const plain = value => value && typeof value === 'object' && !Array.isArray(value);
17
+
7
18
  function fail(message) { throw new Error(message); }
19
+
8
20
  function text(value, name, max, required = false) {
21
+
9
22
  if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail(`${name} : texte ${required ? 'non vide ' : ''}de ${max} caractères maximum attendu.`);
23
+
10
24
  return value;
25
+
11
26
  }
12
27
 
28
+
29
+
13
30
  /** Pure JSON -> validated function call plan. No filesystem, network or code evaluation. */
31
+
14
32
  export function compile(input) {
33
+
15
34
  if (!plain(input) || !Array.isArray(input.commands) || input.commands.length > 100) fail('commands doit être un tableau de 100 commandes maximum.');
35
+
36
+ const variableDefinitions = validateVariableDefinitions(input.variableDefinitions || []);
37
+ 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 }; });
38
+ if (variableDeletions.length > 10000) fail('Trop de suppressions de variables.');
16
39
  const names = new Set();
40
+
17
41
  const commands = input.commands.map((entry, commandIndex) => {
42
+
18
43
  const command = Array.isArray(entry?.blocks) ? entry : entry?.config;
44
+
19
45
  if (!plain(command)) fail(`Commande ${commandIndex + 1} : configuration absente.`);
46
+
47
+ validateEventGraph(command);
20
48
  const name = text(command.name ?? entry.name, 'Nom de commande', 32, true);
49
+
21
50
  if (!/^[a-z0-9_-]{1,32}$/.test(name)) fail(`/${name} : utilisez des minuscules, chiffres, tirets ou underscores.`);
51
+
22
52
  if (names.has(name)) fail(`Commande dupliquée : /${name}.`);
53
+
23
54
  names.add(name);
55
+
24
56
  const description = text(command.description ?? entry.description, `/${name} description`, 100, true);
57
+
25
58
  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).`);
59
+
26
60
  const blocks = new Map();
61
+
27
62
  for (const block of command.blocks) {
63
+
28
64
  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}.`);
65
+
66
+ 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}.`);
67
+
30
68
  if (!plain(block.config || {})) fail(`/${name}/${block.id} : paramètres invalides.`);
69
+
31
70
  blocks.set(block.id, block);
71
+
32
72
  }
73
+
33
74
  const roots = [...blocks.values()].filter(block => block.type === 'root');
75
+
34
76
  if (roots.length !== 1) fail(`/${name} doit contenir exactement un bloc racine.`);
77
+
35
78
  const errorRoots = [...blocks.values()].filter(block => block.type === 'errorRoot');
79
+
36
80
  if (errorRoots.length > 1) fail(`/${name} : une seule racine d’erreur est autorisée.`);
81
+
37
82
  const options = compileOptions(command.blocks);
83
+
38
84
  const optionLinks = new Set();
85
+
39
86
  const conditionConfigs = new Map([...blocks.values()].filter(block => isCondition(block.type)).map(block => [block.id, validateCondition(block.type, block.config)]));
87
+
40
88
  const branchLinks = new Map();
89
+
90
+ const buttonLinks=new Map();
91
+ const buttonBranches=[];
41
92
  const next = new Map();
93
+
42
94
  const incoming = new Set();
95
+
43
96
  for (const edge of command.edges) {
97
+ if (blocks.get(edge?.target)?.type==='messageButton') {
98
+ const child=blocks.get(edge.target);
99
+ if(blocks.get(edge.source)?.type!=='sendMessage'||child.config.parentId!==edge.source||incoming.has(edge.target)) fail('Un bouton reste lié à son message.');
100
+ const list=buttonLinks.get(edge.source)||[];list.push(child);buttonLinks.set(edge.source,list);incoming.add(edge.target);continue;
101
+ }
102
+ 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.');
103
+
44
104
  if (plain(edge) && isOption(blocks.get(edge.source)?.type)) {
105
+
45
106
  if (edge.target !== roots[0].id || optionLinks.has(edge.source)) fail(`/${name} : chaque option doit être liée uniquement à la racine principale.`);
107
+
46
108
  optionLinks.add(edge.source); continue;
109
+
47
110
  }
111
+
48
112
  if (plain(edge) && isOption(blocks.get(edge.target)?.type)) fail(`/${name} : une action ne peut pas être liée à une option.`);
113
+
49
114
  if (!plain(edge) || !blocks.has(edge.source) || !blocks.has(edge.target) || ['root', 'errorRoot'].includes(blocks.get(edge.target)?.type)) fail(`/${name} : liaison invalide.`);
115
+
50
116
  if (isCondition(blocks.get(edge.source).type)) {
117
+
51
118
  const branch = blocks.get(edge.target);
119
+
52
120
  const ruleId = branch.config?.ruleId;
121
+
53
122
  const allowed = ['else', ...conditionConfigs.get(edge.source).rules.map(rule => rule.id)];
123
+
54
124
  const linked = branchLinks.get(edge.source) || new Map();
125
+
55
126
  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.`);
127
+
56
128
  linked.set(ruleId, edge.target); branchLinks.set(edge.source, linked); incoming.add(edge.target); continue;
129
+
57
130
  }
131
+
58
132
  if (blocks.get(edge.target).type === 'conditionBranch') fail(`/${name} : les branches restent liées à leur condition.`);
133
+
59
134
  if (next.has(edge.source)) fail(`/${name}/${edge.source} : plusieurs sorties ne sont pas encore prises en charge. Reliez les actions en séquence.`);
135
+
60
136
  if (incoming.has(edge.target)) fail(`/${name}/${edge.target} : plusieurs entrées ne sont pas prises en charge.`);
137
+
61
138
  next.set(edge.source, edge.target);
139
+
62
140
  incoming.add(edge.target);
141
+
63
142
  }
143
+
64
144
  if (optionLinks.size !== options.length) fail(`/${name} : toutes les options doivent être liées à la racine.`);
145
+
65
146
  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.`);
147
+
148
+ for(const block of blocks.values()) if(block.type==='messageButton'&&!incoming.has(block.id))fail('Bouton détaché de son message.');
149
+ for(const list of buttonLinks.values()) {
150
+ const rows=[0,0,0,0,0]; if(list.length>25)fail('25 boutons maximum par message.');
151
+ for(const b of list){const c=validateButton(b.config);if(++rows[c.row]>5)fail('5 boutons maximum par ligne.');}
152
+ }
66
153
  const visited = new Set(options.map(option => option.id));
154
+
67
155
  function compilePath(rootId, inheritedMessages = new Set(), inheritedWait = 0, depth = 0) {
156
+
68
157
  if (depth > 32) fail(`/${name} : conditions imbriquées trop profondément (32 maximum).`);
158
+
69
159
  const emittedMessages = new Set(inheritedMessages);
160
+
70
161
  const calls = [];
162
+
71
163
  let current = rootId;
164
+
72
165
  let waitBudget = inheritedWait;
166
+
73
167
  while (current) {
168
+
74
169
  if (visited.has(current)) fail(`/${name} : boucle détectée.`);
170
+
75
171
  visited.add(current);
172
+
76
173
  const block = blocks.get(current);
174
+
77
175
  const config = block.config || {};
176
+
78
177
  let args;
178
+
79
179
  let fn;
180
+
80
181
  if (isCondition(block.type)) {
182
+
81
183
  const branches = branchLinks.get(current);
184
+
82
185
  const rules = conditionConfigs.get(current).rules;
186
+
83
187
  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) } });
188
+
189
+ } else if (isVariableBlock(block.type)) {
190
+ fn = block.type; args = validateVariableBlock(block.type, config, variableDefinitions);
191
+ } else if (isDiscordBlock(block.type)) {
192
+
193
+ fn = block.type; args = validateDiscordBlock(block.type,config);
194
+
195
+ if (block.type === 'createChannel') emittedMessages.add(current);
196
+
84
197
  } else if (block.type === 'sendMessage') {
198
+
85
199
  text(config.text ?? '', `/${name}/${current} message`, 2000);
200
+
86
201
  if (config.embeds !== undefined && (!Array.isArray(config.embeds) || config.embeds.some(embed => !plain(embed)))) fail(`/${name}/${current} : embeds invalides.`);
202
+
87
203
  const normal = normalizeMessageConfig(config);
204
+
88
205
  if (!['reply', 'channel', 'dm'].includes(normal.sendMode)) fail(`/${name}/${current} : type d’envoi inconnu.`);
206
+
89
207
  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.`);
208
+
209
+ if (config.channelMode && !['current', 'id', 'created'].includes(config.channelMode)) fail(`/${name}/${current} : mode de salon inconnu.`);
210
+
91
211
  if (config.dmMode && !['author', 'id'].includes(config.dmMode)) fail(`/${name}/${current} : destinataire MP inconnu.`);
92
- const error = messageConfigError(normal, command.blocks);
212
+
213
+ 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.');
214
+
215
+ const error = messageConfigError({...normal,buttons:buttonLinks.get(current)}, command.blocks);
216
+
93
217
  if (error) fail(`/${name}/${current} : ${error}`);
218
+
94
219
  if (normal.sendMode === 'reply' && normal.replyMode === 'block' && !emittedMessages.has(normal.targetBlockId)) fail(`/${name}/${current} : le message cible doit être envoyé avant la réponse.`);
220
+
95
221
  // 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]]));
222
+
223
+ args = Object.fromEntries(['text', 'embeds', 'sendMode', 'replyMode', 'targetBlockId', 'messageId', 'channelMode', 'channelId', 'createdChannelBlockId', 'dmMode', 'userId', 'ephemeral'].map(key => [key, normal[key]]));
224
+
97
225
  args.embeds = normal.embeds.map(embed => ({
226
+
98
227
  ...Object.fromEntries(['title', 'description', 'url', 'color', 'timestamp'].filter(key => embed[key] !== undefined).map(key => [key, embed[key]])),
228
+
99
229
  ...(embed.author ? { author: Object.fromEntries(['name', 'url', 'icon_url'].filter(key => embed.author[key] !== undefined).map(key => [key, embed.author[key]])) } : {}),
230
+
100
231
  ...(embed.footer ? { footer: Object.fromEntries(['text', 'icon_url'].filter(key => embed.footer[key] !== undefined).map(key => [key, embed.footer[key]])) } : {}),
232
+
101
233
  ...(embed.image ? { image: { url: embed.image.url } } : {}),
234
+
102
235
  ...(embed.thumbnail ? { thumbnail: { url: embed.thumbnail.url } } : {}),
236
+
103
237
  ...(embed.fields ? { fields: embed.fields.map(field => ({ name: field.name, value: field.value, inline: Boolean(field.inline) })) } : {}),
238
+
104
239
  }));
240
+
105
241
  fn = 'sendMessage';
242
+
106
243
  emittedMessages.add(current);
244
+ const children=buttonLinks.get(current)||[];
245
+ if(children.length) args.buttons=children.map(child=>({id:child.id,config:validateButton(child.config)}));
246
+ for(const child of children){
247
+ const config=validateButton(child.config);
248
+ const childCalls=compilePath(child.id,emittedMessages,0,depth+1);
249
+ buttonBranches.push({id:child.id,parentId:current,config,calls:childCalls});
250
+ }
251
+
107
252
  } else if (block.type === 'wait') {
253
+
108
254
  const seconds = Number(config.seconds);
255
+
109
256
  if (!Number.isFinite(seconds) || seconds < 0 || seconds > 300) fail(`/${name}/${current} : délai attendu entre 0 et 300 secondes.`);
257
+
110
258
  waitBudget += seconds;
259
+
111
260
  if (waitBudget > 840) fail(`/${name} : les délais cumulés dépassent 14 minutes.`);
261
+
112
262
  fn = 'wait'; args = { seconds };
263
+
113
264
  } else if (block.type === 'deleteMessage') {
265
+
114
266
  const deleteMode = config.deleteMode || 'block';
267
+
115
268
  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é.`);
269
+
270
+ 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é.`);
271
+
117
272
  if (deleteMode === 'manual' && !ID.test(config.messageId || '') && !/^\{[a-z0-9_-]+_option(?:_id)?\}$/.test(config.messageId || '')) fail(`/${name}/${current} : ID de message invalide.`);
273
+
118
274
  fn = 'deleteMessage'; args = deleteMode === 'block' ? { deleteMode, targetBlockId: config.targetBlockId } : { deleteMode, messageId: config.messageId };
275
+
119
276
  }
277
+
120
278
  if (fn) calls.push({ id: current, fn, args });
279
+
121
280
  current = next.get(current);
281
+
122
282
  }
283
+
123
284
  return calls;
285
+
124
286
  }
287
+
125
288
  const calls = compilePath(roots[0].id);
289
+
126
290
  const errorCalls = errorRoots.length ? compilePath(errorRoots[0].id) : undefined;
291
+
127
292
  if (visited.size !== blocks.size) fail(`/${name} : blocs non reliés à une racine ou boucle dans une partie non accessible.`);
293
+
128
294
  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 } : {}) };
295
+
296
+ return { name, description, ...(command.trigger ? {trigger:command.trigger} : {}), ...(options.length ? { options } : {}), calls, ...(buttonBranches.length?{buttonBranches}:{}), ...(errorCalls ? { errorCalls } : {}) };
297
+
130
298
  });
131
- return JSON.parse(JSON.stringify({ schemaVersion: 1, bot: { name: typeof input.bot?.name === 'string' ? input.bot.name.slice(0, 100) : 'Oftone Bot' }, commands }));
299
+
300
+ 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 }));
301
+
132
302
  }
133
303
 
304
+
305
+
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
+ }