oft-agt 0.5.10 → 0.5.12

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/cli.mjs CHANGED
@@ -10,6 +10,8 @@ import { createRequire } from 'node:module';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { spawn } from 'node:child_process';
12
12
  import WebSocket from 'ws';
13
+ import {secretOperation,readApiSecret} from './runtime/api-secrets.mjs';
14
+ import {requestApi} from './runtime/api-request.mjs';
13
15
  import { writeBot, compile } from './compiler.mjs';
14
16
  import { acceptSecret } from './secret.mjs';
15
17
  import { createUsageCollector } from './usage.mjs';
@@ -56,7 +58,7 @@ async function restartBot(config) {
56
58
  if (!/^[a-f0-9-]{36}$/.test(release.id)) throw new Error('INVALID_RELEASE');
57
59
  const secret = await credentials(config);
58
60
  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, OFTONE_VARIABLES_PATH: path.join(home, 'data', 'variables.sqlite') }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
61
+ 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_API_HOME: home, OFTONE_VARIABLES_PATH: path.join(home, 'data', 'variables.sqlite') }, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], windowsHide: true, shell: false }));
60
62
  secret.token = '';
61
63
  child.stderr.on('data', () => {});
62
64
  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 +82,7 @@ async function deploy(config, message, notify) {
80
82
  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
83
  // Remove credentials unrelated to running the Discord bot.
82
84
  for (const key of Object.keys(env)) if (/OFTONE|AGENT_RELAY|CLOUDFLARE|SESSION_SECRET/.test(key)) delete env[key];
85
+ env.OFTONE_API_HOME = home;
83
86
  env.OFTONE_VARIABLES_PATH = path.join(home, 'data', 'variables.sqlite');
84
87
  await run(process.execPath, ['bot.mjs', '--deploy'], { cwd: target, env, stdio: 'ignore' });
85
88
  const previous = child;
@@ -137,7 +140,7 @@ async function start(config) {
137
140
  if (!/^[a-f0-9-]{36}$/.test(previous.id)) throw new Error('État local invalide.');
138
141
  const secret = await credentials(config);
139
142
  const target = path.join(home, 'releases', previous.id);
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 }));
143
+ child = watchUsage(spawn(process.execPath, ['bot.mjs'], { cwd: target, env: { PATH: process.env.PATH, DISCORD_TOKEN: secret.token, DISCORD_CLIENT_ID: secret.applicationId, OFTONE_API_HOME: home, OFTONE_VARIABLES_PATH: path.join(home, 'data', 'variables.sqlite') }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true, shell: false }));
141
144
  child.on('error', () => {});
142
145
  active = previous.id;
143
146
  }
@@ -157,7 +160,7 @@ async function start(config) {
157
160
  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 = ''; }
158
161
  } catch {}
159
162
  }
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 });
163
+ send({ type: 'hello', agentVersion: AGENT_VERSION, updatesVersion: 1, officialUpdatesVersion: 1, protocol: 2, errorBranch: true, diagnosticsVersion: 2, optionsVersion: 1, conditionsVersion: 1, eventsVersion: 1, apiVersion: 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 });
161
164
  if (process.connected) process.send?.({ type: 'agent-ready' }, () => {});
162
165
  await usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.'));
163
166
  usageTimer = setInterval(() => usage.flush(send).catch(() => console.error('Statistiques locales indisponibles.')), 60000);
@@ -192,7 +195,8 @@ async function start(config) {
192
195
  if (message.command === 'stop') { await save(path.join(home, 'paused.json'), {}); await stopBot(); await usage.flush(send).catch(() => {}); send({ type: 'runtime-status', state: 'stopped' }); }
193
196
  else if (message.command === 'restart') { await restartBot(config); if (await exists(path.join(home, 'paused.json'))) await unlink(path.join(home, 'paused.json')); send({ type: 'runtime-status', state: 'running' }); }
194
197
  } catch { send({ type: 'runtime-status', state: 'failed' }); }
195
- } else if (message.type === 'deploy') {
198
+ } else if(message.type==='api-tool'){try{const result=message.payload.op==='test'?await requestApi(message.payload.config,{resolveSecret:name=>readApiSecret(name,home)}):await secretOperation(home,config.agentId,message.payload);const encoded=Buffer.from(JSON.stringify({result})).toString('base64');if(encoded.length>560000)throw Error('Réponse trop volumineuse.');const total=Math.ceil(encoded.length/1400);for(let index=0;index<total;index++)send({type:'api-result',id:message.id,index,total,chunk:encoded.slice(index*1400,(index+1)*1400)});}catch{send({type:'api-result',id:message.id,error:'Opération API impossible. Vérifiez la requête et les secrets.'});}return;}
199
+ if (message.type === 'deploy') {
196
200
  console.log(`[${new Date().toISOString()}] Nouveau déploiement reçu.`);
197
201
  if (await exists(path.join(home, 'paused.json'))) await unlink(path.join(home, 'paused.json'));
198
202
  try { await deploy(config, message, state => { send({ type: 'deployment-status', id: message.id, state }); console.log(({ received: 'Déploiement validé.', installing: 'Préparation du bot.', running: 'Déploiement terminé. Bot démarré.', failed: 'Échec du déploiement.' })[state] || 'État du déploiement modifié.'); }); }
package/compile-plan.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {validateApiRequest} from './runtime/api-config.mjs';
1
2
  import { validateEventGraph } from './runtime/events.mjs';
2
3
  import { isVariableBlock, validateVariableBlock, validateVariableDefinitions } from './runtime/custom-variables.mjs';
3
4
  import { validateButton } from './runtime/message-buttons.mjs';
@@ -63,7 +64,7 @@ export function compile(input) {
63
64
 
64
65
  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é.`);
65
66
 
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
+ if (!isVariableBlock(block.type) && !isDiscordBlock(block.type) && !isOption(block.type) && !isCondition(block.type) && !['root', 'errorRoot', 'conditionBranch', 'apiRequest', 'messageButton', 'sendMessage', 'wait', 'deleteMessage'].includes(block.type)) fail(`/${name}/${block.id} : type de bloc inconnu : ${block.type}.`);
67
68
 
68
69
  if (!plain(block.config || {})) fail(`/${name}/${block.id} : paramètres invalides.`);
69
70
 
@@ -249,6 +250,7 @@ export function compile(input) {
249
250
  buttonBranches.push({id:child.id,parentId:current,config,calls:childCalls});
250
251
  }
251
252
 
253
+ } else if (block.type === 'apiRequest') { fn='apiRequest'; args=validateApiRequest(config); if([...blocks.values()].some(b=>b.id!==block.id&&b.type==='apiRequest'&&b.config?.name===args.name))fail('Chaque requête API doit avoir un nom unique.');
252
254
  } else if (block.type === 'wait') {
253
255
 
254
256
  const seconds = Number(config.seconds);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "oft-agt",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "oft-agt",
9
- "version": "0.5.10",
9
+ "version": "0.5.12",
10
10
  "dependencies": {
11
11
  "discord.js": "^14.14.0",
12
12
  "ws": "^8.18.0"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oft-agt",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,11 @@
1
+ export function validateApiRequest(c={}) {
2
+ if(!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(c.name||''))throw Error('Nom de requête invalide.');
3
+ if(!['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'].includes(c.method))throw Error('Méthode invalide.');
4
+ if(typeof c.url!=='string'||c.url.length>4000||!/^https?:\/\//.test(c.url))throw Error('URL HTTP ou HTTPS requise.');
5
+ const pairs=value=>{if(!Array.isArray(value)||value.length>40)throw Error('Trop de paramètres.');return value.map(p=>{if(typeof p.key!=='string'||typeof p.value!=='string'||p.key.length>200||p.value.length>8000)throw Error('Paramètre invalide.');return {key:p.key,value:p.value};});};
6
+ const outputs=(c.outputs||[]).map(o=>{if(!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(o.name)||!Array.isArray(o.path)||o.path.length>16||o.path.some(k=>typeof k!=='string'||['__proto__','prototype','constructor'].includes(k)))throw Error('Champ JSON invalide.');return {name:o.name,path:o.path};});
7
+ if(outputs.some(o=>o.name==='response')||outputs.length>50||new Set(outputs.map(o=>o.name)).size!==outputs.length)throw Error('Variables de résultat invalides.');
8
+ if(typeof(c.body||'')!=='string'||(c.body||'').length>64000)throw Error('Corps trop volumineux.');
9
+ return {name:c.name,method:c.method,url:c.url,params:pairs(c.params||[]),headers:pairs(c.headers||[]),body:c.body||'',outputs,timeout:Math.min(30000,Math.max(1000,Number(c.timeout)||15000))};
10
+ }
11
+ export const apiVariables=c=>[{name:c.name+'_status-code',label:'Code HTTP'},{name:c.name+'_status-text',label:'Statut HTTP'},{name:c.name+'_response',label:'Réponse complète'},...(c.outputs||[]).map(o=>({name:c.name+'_'+o.name,label:o.path.join('.')}))];
@@ -0,0 +1,20 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import {lookup} from 'node:dns';
4
+ import {isIP} from 'node:net';
5
+ import {validateApiRequest} from './api-config.mjs';
6
+ export function publicAddress(ip){if(ip.includes(':'))return /^2[0-9a-f]{3}:/i.test(ip)&&!/^2001:(db8|0):/i.test(ip);const a=ip.split('.').map(Number);return a.length===4&&a[0]>0&&a[0]<224&&![10,127].includes(a[0])&&!(a[0]===169&&a[1]===254)&&!(a[0]===172&&a[1]>=16&&a[1]<=31)&&!(a[0]===192&&a[1]===168)&&!(a[0]===100&&a[1]>=64&&a[1]<=127);}
7
+ export async function requestApi(raw,{resolveSecret=async()=>{throw Error('Secret indisponible.');},signal}={}){
8
+ const resolvedSecrets=[];const c=validateApiRequest(raw),replace=async s=>{let out=s;for(const m of s.matchAll(/\{secret\.([A-Za-z][A-Za-z0-9_]*)\}/g)){const value=await resolveSecret(m[1]);resolvedSecrets.push(value);out=out.replaceAll(m[0],value);}return out;};
9
+ const url=new URL(await replace(c.url));if(url.username||url.password||!['http:','https:'].includes(url.protocol))throw Error('URL refusée.');
10
+ for(const p of c.params)if(p.key)url.searchParams.append(p.key,await replace(p.value));
11
+ const headers={'User-Agent':'Oftone-Agent/1'};for(const h of c.headers)if(h.key){if(/^(host|content-length|connection|transfer-encoding|upgrade)$/i.test(h.key)||/[\r\n]/.test(h.key))throw Error('En-tête refusé.');headers[h.key]=await replace(h.value);}
12
+ const body=await replace(c.body);
13
+ const result=await new Promise((resolve,reject)=>{
14
+ if(isIP(url.hostname.replaceAll(/[\[\]]/g,''))&&!publicAddress(url.hostname.replaceAll(/[\[\]]/g,'')))return reject(Error('Adresse locale refusée.'));
15
+ const req=(url.protocol==='https:'?https:http).request(url,{method:c.method,headers,signal:signal?AbortSignal.any([signal,AbortSignal.timeout(c.timeout)]):AbortSignal.timeout(c.timeout),lookup:(host,options,cb)=>lookup(host,{all:true},(err,addresses)=>{if(err||!addresses.length||addresses.some(a=>!publicAddress(a.address)))return cb(Error('Adresse réseau refusée.'));const a=addresses[0];cb(null,options.all?[a]:a.address,a.family);})},res=>{let size=0;const chunks=[];res.on('data',chunk=>{size+=chunk.length;if(size>262144){req.destroy(Error('Réponse trop volumineuse.'));return;}chunks.push(chunk);});res.on('end',()=>{const text=Buffer.concat(chunks).toString('utf8');let response;try{response=JSON.parse(text);}catch{response=text;}resolve({status:res.statusCode,statusText:res.statusMessage||'',response});});res.on('error',reject);});
16
+ req.setTimeout(c.timeout,()=>req.destroy(Error('Délai de requête dépassé.')));req.on('error',()=>reject(Error('Requête API échouée.')));if(!['GET','HEAD'].includes(c.method))req.write(body);req.end();
17
+ });
18
+ let safe=JSON.stringify(result);for(const secret of resolvedSecrets)if(secret)safe=safe.split(JSON.stringify(secret).slice(1,-1)).join('••••');return JSON.parse(safe);
19
+ }
20
+ export function responseVariables(c,result){const values={[c.name+'_status-code']:String(result.status),[c.name+'_status-text']:result.statusText,[c.name+'_response']:typeof result.response==='string'?result.response:JSON.stringify(result.response)};for(const o of c.outputs){let v=result.response;for(const key of o.path)v=v&&typeof v==='object'&&Object.hasOwn(v,key)?v[key]:undefined;values[c.name+'_'+o.name]=v===undefined?'':typeof v==='object'?JSON.stringify(v):String(v);}return values;}
@@ -0,0 +1,7 @@
1
+ import {readFile,writeFile,rename,mkdir,unlink,readdir} from 'node:fs/promises';
2
+ import {privateDecrypt,constants,createDecipheriv} from 'node:crypto';
3
+ import path from 'node:path';
4
+ export function openEnvelope(e,key){const aes=privateDecrypt({key,oaepHash:'sha256',padding:constants.RSA_PKCS1_OAEP_PADDING},Buffer.from(e.key,'base64'));const b=Buffer.from(e.ciphertext,'base64'),d=createDecipheriv('aes-256-gcm',aes,Buffer.from(e.iv,'base64'));d.setAuthTag(b.subarray(-16));return JSON.parse(Buffer.concat([d.update(b.subarray(0,-16)),d.final()]).toString('utf8'));}
5
+ const valid=name=>/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(name||'');
6
+ export async function secretOperation(home,agentId,p){const directory=path.join(home,'api-secrets');await mkdir(directory,{recursive:true,mode:0o700});if(p.op==='list')return {secrets:(await readdir(directory)).filter(n=>n.endsWith('.json')&&valid(n.slice(0,-5))).map(n=>n.slice(0,-5))};if(!valid(p.name))throw Error('Nom de secret invalide.');const file=path.join(directory,p.name+'.json');if(p.op==='delete'){await unlink(file).catch(e=>{if(e.code!=='ENOENT')throw e;});return {ok:true};}if(p.op!=='set')throw Error('Opération refusée.');const secret=openEnvelope(p.envelope,await readFile(path.join(home,'private.pem'),'utf8'));try{if(secret.agentId!==agentId||secret.name!==p.name||typeof secret.value!=='string'||!secret.value||secret.value.length>8000)throw Error('Secret invalide.');await writeFile(file+'.tmp',JSON.stringify(p.envelope),{mode:0o600});await rename(file+'.tmp',file);return {ok:true};}finally{secret.value='';}}
7
+ export async function readApiSecret(name,home=process.env.OFTONE_API_HOME){if(!valid(name)||!home)throw Error('Secret indisponible.');const e=JSON.parse(await readFile(path.join(home,'api-secrets',name+'.json'),'utf8'));const secret=openEnvelope(e,await readFile(path.join(home,'private.pem'),'utf8'));if(secret.name!==name)throw Error('Secret invalide.');return secret.value;}
@@ -1,3 +1,5 @@
1
+ import {requestApi,responseVariables} from './api-request.mjs';
2
+ import {readApiSecret} from './api-secrets.mjs';
1
3
  import { VARIABLE_ACTIONS } from './variable-actions.mjs';
2
4
  import { DISCORD_ACTIONS } from './discord-actions.mjs';
3
5
 
@@ -153,5 +155,5 @@ async function deleteMessage(context, parameters) {
153
155
 
154
156
  // Extend this registry together with compiler validation when adding a new block type.
155
157
 
156
- export const FUNCTIONS = Object.freeze(Object.assign(Object.create(null), Object.fromEntries(Object.entries({ sendMessage, wait, deleteMessage, ...DISCORD_ACTIONS, ...VARIABLE_ACTIONS }).map(([name, fn]) => [name, async (context, args) => { try { return await fn(context, args); } catch (error) { throw runtimeError(undefined, name, error); } }]))));
158
+ export const FUNCTIONS = Object.freeze(Object.assign(Object.create(null), Object.fromEntries(Object.entries({ apiRequest: async(context,args)=>{const result=await requestApi(args,{signal:context.signal,resolveSecret:readApiSecret});Object.assign(context.variableOptions.base,responseVariables(args,result));return result;}, sendMessage, wait, deleteMessage, ...DISCORD_ACTIONS, ...VARIABLE_ACTIONS }).map(([name, fn]) => [name, async (context, args) => { try { return await fn(context, args); } catch (error) { throw runtimeError(undefined, name, error); } }]))));
157
159
 
package/version.mjs CHANGED
@@ -1 +1 @@
1
- export const AGENT_VERSION = '0.5.10';
1
+ export const AGENT_VERSION = '0.5.12';