oft-agt 0.5.0 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/cli.mjs +12 -13
- package/compile-plan.mjs +177 -7
- package/compiler.mjs +55 -55
- package/instance.mjs +13 -1
- package/npm-shrinkwrap.json +3 -3
- package/package.json +2 -2
- package/runtime/button-router.mjs +42 -0
- package/runtime/conditions.mjs +14 -1
- package/runtime/context-variables.mjs +14 -0
- package/runtime/custom-variables.mjs +184 -0
- package/runtime/discord-actions.mjs +64 -0
- package/runtime/discord-blocks.mjs +53 -0
- package/runtime/errors.mjs +85 -85
- package/runtime/execute.mjs +143 -20
- package/runtime/functions.mjs +92 -4
- package/runtime/lifecycle.mjs +1 -1
- package/runtime/message-buttons.mjs +17 -0
- package/runtime/message-config.mjs +80 -3
- package/runtime/scheduler.mjs +91 -0
- package/runtime/variable-actions.mjs +42 -0
- package/runtime/variable-store-worker.mjs +69 -0
- package/runtime/variable-store.mjs +26 -0
- package/templates/bot.mjs +17 -6
- package/update.mjs +24 -2
- package/usage.mjs +42 -39
- package/version.mjs +1 -1
|
@@ -1,73 +1,150 @@
|
|
|
1
1
|
export function normalizeMessageConfig(config = {}) {
|
|
2
|
+
|
|
2
3
|
const legacyChannel = !config.sendMode && config.replyMode === 'manual';
|
|
4
|
+
|
|
3
5
|
return {
|
|
6
|
+
|
|
4
7
|
...config,
|
|
8
|
+
|
|
5
9
|
text: config.text || '',
|
|
10
|
+
|
|
6
11
|
embeds: Array.isArray(config.embeds) ? config.embeds : [],
|
|
12
|
+
|
|
7
13
|
sendMode: config.sendMode || (legacyChannel ? 'channel' : 'reply'),
|
|
14
|
+
|
|
8
15
|
replyMode: ['block', 'message'].includes(config.replyMode) ? config.replyMode : 'command',
|
|
16
|
+
|
|
9
17
|
targetBlockId: config.targetBlockId || '',
|
|
18
|
+
|
|
10
19
|
messageId: config.messageId || '',
|
|
20
|
+
|
|
11
21
|
channelMode: config.channelMode || (legacyChannel ? 'id' : 'current'),
|
|
22
|
+
|
|
23
|
+
createdChannelBlockId: config.createdChannelBlockId || '',
|
|
24
|
+
|
|
12
25
|
channelId: config.channelId || (legacyChannel ? config.replyTarget || '' : ''),
|
|
26
|
+
|
|
13
27
|
dmMode: config.dmMode || 'author',
|
|
28
|
+
|
|
14
29
|
userId: config.userId || '',
|
|
30
|
+
|
|
15
31
|
ephemeral: Boolean(config.ephemeral),
|
|
32
|
+
|
|
16
33
|
};
|
|
34
|
+
|
|
17
35
|
}
|
|
18
36
|
|
|
37
|
+
|
|
38
|
+
|
|
19
39
|
export function safeMessageUrl(value) {
|
|
40
|
+
|
|
20
41
|
if (!value) return '';
|
|
42
|
+
|
|
21
43
|
try {
|
|
44
|
+
|
|
22
45
|
const url = new URL(value);
|
|
46
|
+
|
|
23
47
|
return ['https:', 'http:'].includes(url.protocol) ? url.href : '';
|
|
48
|
+
|
|
24
49
|
} catch { return ''; }
|
|
50
|
+
|
|
25
51
|
}
|
|
26
52
|
|
|
53
|
+
|
|
54
|
+
|
|
27
55
|
export function messageConfigError(raw, nodes, allowVariables = true) {
|
|
56
|
+
|
|
28
57
|
const config = normalizeMessageConfig(raw);
|
|
29
|
-
|
|
58
|
+
|
|
59
|
+
if (!config.text.trim() && !config.embeds.length && !config.buttons?.length) return 'Ajoutez du texte ou un embed au message.';
|
|
60
|
+
|
|
30
61
|
if (config.text.length > 2000) return 'Le message est limité à 2 000 caractères.';
|
|
62
|
+
|
|
31
63
|
if (config.embeds.length > 5) return 'Vous pouvez ajouter au maximum 5 embeds.';
|
|
64
|
+
|
|
32
65
|
let total = 0;
|
|
66
|
+
|
|
33
67
|
for (const [index, embed] of config.embeds.entries()) {
|
|
68
|
+
|
|
34
69
|
const fields = embed.fields || [];
|
|
70
|
+
|
|
35
71
|
const texts = [embed.title || '', embed.description || '', embed.author?.name || '', embed.footer?.text || ''];
|
|
72
|
+
|
|
36
73
|
if (!texts.some(Boolean) && !embed.image?.url && !embed.thumbnail?.url && !fields.length) return `Complétez l’embed ${index + 1} ou supprimez-le.`;
|
|
74
|
+
|
|
37
75
|
if (texts[0].length > 256 || texts[1].length > 4096 || texts[2].length > 256 || texts[3].length > 2048) return `Un texte de l’embed ${index + 1} dépasse la limite autorisée.`;
|
|
76
|
+
|
|
38
77
|
if (fields.length > 25 || fields.some(field => !field.name?.trim() || !field.value?.trim() || field.name.length > 256 || field.value.length > 1024)) return `Vérifiez les champs de l’embed ${index + 1} (nom et valeur requis).`;
|
|
78
|
+
|
|
39
79
|
if (embed.color && !/^#[a-f\d]{6}$/i.test(embed.color) && !(Number.isInteger(embed.color) && embed.color >= 0 && embed.color <= 0xffffff)) return `Vérifiez la couleur de l’embed ${index + 1}.`;
|
|
80
|
+
|
|
40
81
|
for (const url of [embed.url, embed.author?.url, embed.author?.icon_url, embed.footer?.icon_url, embed.image?.url, embed.thumbnail?.url]) {
|
|
41
|
-
|
|
82
|
+
|
|
83
|
+
const previewUrl = allowVariables && typeof url === 'string' ? url.replace(/\{[a-zA-Z0-9_-]+\}/g, 'https://example.com') : url;
|
|
84
|
+
|
|
42
85
|
if (url && !safeMessageUrl(previewUrl)) return `Utilisez une URL http ou https valide dans l’embed ${index + 1}.`;
|
|
86
|
+
|
|
43
87
|
}
|
|
88
|
+
|
|
44
89
|
if ((embed.author?.url || embed.author?.icon_url) && !embed.author?.name) return `Renseignez le nom de l’auteur dans l’embed ${index + 1}.`;
|
|
90
|
+
|
|
45
91
|
if (embed.footer?.icon_url && !embed.footer?.text) return `Renseignez le pied de page dans l’embed ${index + 1}.`;
|
|
92
|
+
|
|
46
93
|
total += texts.reduce((sum, text) => sum + text.length, 0) + fields.reduce((sum, field) => sum + field.name.length + field.value.length, 0);
|
|
94
|
+
|
|
47
95
|
}
|
|
96
|
+
|
|
48
97
|
if (total > 6000) return 'L’ensemble des embeds est limité à 6 000 caractères.';
|
|
98
|
+
|
|
49
99
|
if (config.sendMode === 'reply' && config.replyMode === 'block' && (!config.targetBlockId || (nodes && !nodes.some(node => (node.id === config.targetBlockId && (node.type === 'sendMessage' || node.data?.kind === 'message')))))) return 'Choisissez un bloc de message existant.';
|
|
100
|
+
|
|
101
|
+
if (config.sendMode === 'channel' && config.channelMode === 'created' && (!config.createdChannelBlockId || (nodes && !nodes.some(n=>n.id===config.createdChannelBlockId && (n.type==='createChannel'||n.data?.kind==='createChannel'))))) return 'Choisissez un bloc de création de salon.';
|
|
102
|
+
|
|
50
103
|
const needsId = config.sendMode === 'reply' && config.replyMode === 'message' ? config.messageId : config.sendMode === 'channel' && config.channelMode === 'id' ? config.channelId : config.sendMode === 'dm' && config.dmMode === 'id' ? config.userId : null;
|
|
51
|
-
|
|
104
|
+
|
|
105
|
+
if (needsId !== null && !/^\d{17,20}$/.test(needsId) && !(allowVariables && /^\{[a-zA-Z0-9_-]+\}$/.test(needsId))) return 'Renseignez un ID Discord valide (17 à 20 chiffres).';
|
|
106
|
+
|
|
52
107
|
return '';
|
|
108
|
+
|
|
53
109
|
}
|
|
54
110
|
|
|
111
|
+
|
|
112
|
+
|
|
55
113
|
export function discordMessagePayload(raw) {
|
|
114
|
+
|
|
56
115
|
const config = normalizeMessageConfig(raw);
|
|
116
|
+
|
|
57
117
|
return {
|
|
118
|
+
|
|
58
119
|
...(config.text ? { content: config.text } : {}),
|
|
120
|
+
|
|
59
121
|
allowedMentions: { parse: [] },
|
|
122
|
+
|
|
60
123
|
embeds: config.embeds.map(embed => ({
|
|
124
|
+
|
|
61
125
|
...(embed.title ? { title: embed.title } : {}),
|
|
126
|
+
|
|
62
127
|
...(embed.description ? { description: embed.description } : {}),
|
|
128
|
+
|
|
63
129
|
...(safeMessageUrl(embed.url) ? { url: safeMessageUrl(embed.url) } : {}),
|
|
130
|
+
|
|
64
131
|
...(embed.color !== undefined && embed.color !== '' ? { color: typeof embed.color === 'number' ? embed.color : parseInt(embed.color.slice(1), 16) } : {}),
|
|
132
|
+
|
|
65
133
|
...(embed.author?.name ? { author: { name: embed.author.name, ...(safeMessageUrl(embed.author.url) ? { url: safeMessageUrl(embed.author.url) } : {}), ...(safeMessageUrl(embed.author.icon_url) ? { icon_url: safeMessageUrl(embed.author.icon_url) } : {}) } } : {}),
|
|
134
|
+
|
|
66
135
|
...(embed.footer?.text ? { footer: { text: embed.footer.text, ...(safeMessageUrl(embed.footer.icon_url) ? { icon_url: safeMessageUrl(embed.footer.icon_url) } : {}) } } : {}),
|
|
136
|
+
|
|
67
137
|
...(safeMessageUrl(embed.image?.url) ? { image: { url: safeMessageUrl(embed.image.url) } } : {}),
|
|
138
|
+
|
|
68
139
|
...(safeMessageUrl(embed.thumbnail?.url) ? { thumbnail: { url: safeMessageUrl(embed.thumbnail.url) } } : {}),
|
|
140
|
+
|
|
69
141
|
...(embed.fields?.length ? { fields: embed.fields } : {}),
|
|
142
|
+
|
|
70
143
|
...(embed.timestamp ? { timestamp: new Date().toISOString() } : {}),
|
|
144
|
+
|
|
71
145
|
})),
|
|
146
|
+
|
|
72
147
|
};
|
|
148
|
+
|
|
73
149
|
}
|
|
150
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import v8 from 'node:v8';
|
|
3
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
4
|
+
|
|
5
|
+
const MB = 1024 * 1024;
|
|
6
|
+
export function busyError() { return Object.assign(new Error('Le bot est occupé. Réessayez dans quelques instants.'), { code: 'BOT_BUSY' }); }
|
|
7
|
+
|
|
8
|
+
/** Global CPU includes other applications; available memory and event-loop lag
|
|
9
|
+
* constrain concurrency. A separate bound includes queued and sleeping commands. */
|
|
10
|
+
export function createResourceSampler() {
|
|
11
|
+
const totals = () => os.cpus().reduce((sum, cpu) => {
|
|
12
|
+
sum.idle += cpu.times.idle;
|
|
13
|
+
sum.total += Object.values(cpu.times).reduce((a, b) => a + b, 0);
|
|
14
|
+
return sum;
|
|
15
|
+
}, { idle: 0, total: 0 });
|
|
16
|
+
let previous = totals();
|
|
17
|
+
const lag = monitorEventLoopDelay({ resolution: 20 }); lag.enable();
|
|
18
|
+
const sample = () => {
|
|
19
|
+
const current = totals(), elapsed = current.total - previous.total;
|
|
20
|
+
const cpu = elapsed > 0 ? 1 - (current.idle - previous.idle) / elapsed : 0;
|
|
21
|
+
previous = current;
|
|
22
|
+
const free = Math.min(os.freemem(), process.availableMemory?.() ?? Infinity);
|
|
23
|
+
const memory = process.memoryUsage(), heap = v8.getHeapStatistics();
|
|
24
|
+
const result = { cpu, lagMs: Number(lag.percentile(95)) / 1e6, free,
|
|
25
|
+
heapRatio: memory.heapUsed / heap.heap_size_limit, cores: os.availableParallelism() };
|
|
26
|
+
lag.reset(); return result;
|
|
27
|
+
};
|
|
28
|
+
sample.close = () => lag.disable();
|
|
29
|
+
return sample;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AdaptiveScheduler {
|
|
33
|
+
constructor({ sample = createResourceSampler(), intervalMs = 1000, queueTimeoutMs = 30000 } = {}) {
|
|
34
|
+
this.sample = sample; this.queueTimeoutMs = queueTimeoutMs;
|
|
35
|
+
this.limit = 4; this.active = 0; this.retained = 0; this.capacity = 32;
|
|
36
|
+
this.queue = []; this.healthy = 0; this.pressure = false; this.closed = false;
|
|
37
|
+
this.refresh();
|
|
38
|
+
if (intervalMs) { this.timer = setInterval(() => this.refresh(), intervalMs); this.timer.unref(); }
|
|
39
|
+
}
|
|
40
|
+
refresh() {
|
|
41
|
+
const s = this.sample();
|
|
42
|
+
const headroom = Math.max(0, s.free - 256 * MB);
|
|
43
|
+
const ceiling = Math.max(1, Math.min(256, s.cores * 16, Math.floor(headroom / (16 * MB))));
|
|
44
|
+
this.capacity = Math.max(8, Math.min(1024, Math.floor(headroom / (2 * MB))));
|
|
45
|
+
this.pressure = s.free < 128 * MB || s.heapRatio > 0.85;
|
|
46
|
+
if (this.pressure || s.cpu > 0.9 || s.lagMs > 100) {
|
|
47
|
+
this.limit = Math.max(1, Math.floor(this.limit / 2)); this.healthy = 0;
|
|
48
|
+
} else if (s.cpu < 0.75 && s.lagMs < 40 && s.heapRatio < 0.7) {
|
|
49
|
+
if (++this.healthy >= 3) { this.limit++; this.healthy = 0; }
|
|
50
|
+
} else this.healthy = 0;
|
|
51
|
+
this.limit = Math.min(this.limit, ceiling);
|
|
52
|
+
this.drain();
|
|
53
|
+
}
|
|
54
|
+
admit() {
|
|
55
|
+
if (this.closed || this.pressure || this.retained >= this.capacity) throw busyError();
|
|
56
|
+
this.retained++;
|
|
57
|
+
let released = false;
|
|
58
|
+
return () => { if (!released) { released = true; this.retained--; } };
|
|
59
|
+
}
|
|
60
|
+
run(task, { signal } = {}) {
|
|
61
|
+
if (this.closed || signal?.aborted) return Promise.reject(signal?.reason || busyError());
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const entry = { task, resolve, reject, signal };
|
|
64
|
+
const cancel = reason => {
|
|
65
|
+
const index = this.queue.indexOf(entry);
|
|
66
|
+
if (index < 0) return;
|
|
67
|
+
this.queue.splice(index, 1); entry.cleanup(); reject(reason);
|
|
68
|
+
};
|
|
69
|
+
entry.abort = () => cancel(signal.reason);
|
|
70
|
+
entry.timer = setTimeout(() => cancel(busyError()), this.queueTimeoutMs);
|
|
71
|
+
entry.cleanup = () => { clearTimeout(entry.timer); signal?.removeEventListener('abort', entry.abort); };
|
|
72
|
+
signal?.addEventListener('abort', entry.abort, { once: true });
|
|
73
|
+
this.queue.push(entry); this.drain();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
drain() {
|
|
77
|
+
while (!this.closed && !this.pressure && this.active < this.limit && this.queue.length) {
|
|
78
|
+
const entry = this.queue.shift(); entry.cleanup();
|
|
79
|
+
if (entry.signal?.aborted) { entry.reject(entry.signal.reason); continue; }
|
|
80
|
+
this.active++;
|
|
81
|
+
Promise.resolve().then(() => { entry.signal?.throwIfAborted(); return entry.task(); }).then(entry.resolve, entry.reject).finally(() => { this.active--; this.drain(); });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
close() {
|
|
85
|
+
this.closed = true; clearInterval(this.timer); this.sample.close?.();
|
|
86
|
+
for (const entry of this.queue.splice(0)) { entry.cleanup(); entry.reject(busyError()); }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let scheduler;
|
|
91
|
+
export function getScheduler() { return scheduler ||= new AdaptiveScheduler(); }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { interpolateVariables, validateVariableBlock, parseVariableLines } from './custom-variables.mjs';
|
|
2
|
+
|
|
3
|
+
export function variableRuntimeOptions(context, base, definitions, store, warn = () => {}) {
|
|
4
|
+
const target = async (definition, params = {}) => {
|
|
5
|
+
const current = { guildId: context.interaction.guildId, channelId: context.interaction.channelId, userId: context.interaction.user?.id };
|
|
6
|
+
const next = { guildId: params.server || current.guildId, channelId: params.channel || current.channelId, userId: params.user || current.userId };
|
|
7
|
+
if (definition.scope === 'guild' && next.guildId !== current.guildId) {
|
|
8
|
+
const guild = await context.client.guilds.fetch(next.guildId);
|
|
9
|
+
const member = await guild.members.fetch({ user: current.userId, force: true });
|
|
10
|
+
if (!member.permissions.has('ManageGuild')) throw Error('Contexte de variable inaccessible.');
|
|
11
|
+
}
|
|
12
|
+
if (['channel', 'channelUser'].includes(definition.level) && (next.channelId !== current.channelId || next.guildId !== current.guildId)) {
|
|
13
|
+
const channel = await context.client.channels.fetch(next.channelId);
|
|
14
|
+
if (!channel || channel.guildId !== next.guildId) throw Error('Contexte de variable inaccessible.');
|
|
15
|
+
const member = await channel.guild.members.fetch({ user: current.userId, force: true });
|
|
16
|
+
if (!channel.permissionsFor(member)?.has('ViewChannel')) throw Error('Contexte de variable inaccessible.');
|
|
17
|
+
}
|
|
18
|
+
return next;
|
|
19
|
+
};
|
|
20
|
+
const aliases = {...base};
|
|
21
|
+
for(const [name,value] of Object.entries(base)) if(name.endsWith('_option_id')) aliases['option_'+name.slice(0,-10)]=value;
|
|
22
|
+
return {
|
|
23
|
+
base: aliases, definitions, warn, target,
|
|
24
|
+
read: async (definition, params) => {
|
|
25
|
+
if (!store) throw Error('Stockage local indisponible.');
|
|
26
|
+
return store.read(definition.id, await target(definition, params));
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function runVariableAction(context, args, kind) {
|
|
31
|
+
const options = context.variableOptions;
|
|
32
|
+
const config = validateVariableBlock(kind, args, options?.definitions || []);
|
|
33
|
+
const definition = options.definitions.find(item => item.id === config.variableId);
|
|
34
|
+
if (!context.variableStore) throw Error('Stockage local des variables indisponible.');
|
|
35
|
+
const target = await options.target(definition, { user: config.userId, channel: config.channelId, server: config.guildId });
|
|
36
|
+
const result = await context.variableStore.mutate(definition.id, target, config, kind);
|
|
37
|
+
if (result?.outside) options.warn('VARIABLE_LINE_OUTSIDE');
|
|
38
|
+
}
|
|
39
|
+
export const VARIABLE_ACTIONS = {
|
|
40
|
+
setVariable: (context, args) => runVariableAction(context, args, 'setVariable'),
|
|
41
|
+
deleteVariable: (context, args) => runVariableAction(context, args, 'deleteVariable'),
|
|
42
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { mkdirSync, chmodSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { VARIABLE_LIMITS, validateVariableDefinitions, validateVariableBlock, validateVariableValue, emptyVariableValue, variableStorageKey, mutateVariableValue, parseVariableLines } from './custom-variables.mjs';
|
|
6
|
+
|
|
7
|
+
// This worker never talks to the agent relay. All messages stay inside the bot.
|
|
8
|
+
mkdirSync(path.dirname(workerData.file), { recursive: true, mode: 0o700 });
|
|
9
|
+
const database = new DatabaseSync(workerData.file);
|
|
10
|
+
chmodSync(workerData.file, 0o600);
|
|
11
|
+
database.exec(`PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA max_page_count=${Math.floor(VARIABLE_LIMITS.databaseBytes / 4096)};
|
|
12
|
+
CREATE TABLE IF NOT EXISTS variable_definitions(id TEXT NOT NULL, schema TEXT NOT NULL, revision INTEGER NOT NULL, default_value TEXT NOT NULL, PRIMARY KEY(id,revision));
|
|
13
|
+
CREATE TABLE IF NOT EXISTS variable_values(storage_key TEXT PRIMARY KEY, variable_id TEXT NOT NULL, value TEXT NOT NULL);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS variable_values_definition ON variable_values(variable_id);`);
|
|
15
|
+
let definitions = [];
|
|
16
|
+
function transaction(action) {
|
|
17
|
+
database.exec('BEGIN IMMEDIATE');
|
|
18
|
+
try { const result = action(); database.exec('COMMIT'); return result; }
|
|
19
|
+
catch (error) { database.exec('ROLLBACK'); throw error; }
|
|
20
|
+
}
|
|
21
|
+
function synchronize(raw, deletions) {
|
|
22
|
+
const next = validateVariableDefinitions(raw);
|
|
23
|
+
transaction(() => {
|
|
24
|
+
for (const item of next) {
|
|
25
|
+
const schema = JSON.stringify([item.type, item.level, item.scope]);
|
|
26
|
+
const old = database.prepare('SELECT schema FROM variable_definitions WHERE id=? AND revision=?').get(item.id, item.revision);
|
|
27
|
+
if (old && old.schema !== schema) throw Error('Révision de variable incohérente.');
|
|
28
|
+
if (!old) {
|
|
29
|
+
database.prepare('INSERT OR REPLACE INTO variable_definitions VALUES(?,?,?,?)').run(item.id, schema, item.revision, JSON.stringify(emptyVariableValue(item.type)));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const item of deletions || []) if (item?.purge === true && typeof item.id === 'string' && !next.some(definition => definition.id === item.id)) {
|
|
33
|
+
database.prepare('DELETE FROM variable_values WHERE variable_id=?').run(item.id);
|
|
34
|
+
database.prepare('DELETE FROM variable_definitions WHERE id=?').run(item.id);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
definitions = next;
|
|
38
|
+
}
|
|
39
|
+
function definition(id) { const result = definitions.find(item => item.id === id); if (!result) throw Error('Variable introuvable.'); return result; }
|
|
40
|
+
function read(item, context) {
|
|
41
|
+
const row = database.prepare('SELECT value FROM variable_values WHERE storage_key=?').get(variableStorageKey(item, context));
|
|
42
|
+
const fallback = database.prepare('SELECT default_value FROM variable_definitions WHERE id=? AND revision=?').get(item.id, item.revision);
|
|
43
|
+
return validateVariableValue(item.type, JSON.parse(row?.value ?? fallback.default_value));
|
|
44
|
+
}
|
|
45
|
+
function mutate(id, context, raw, kind) {
|
|
46
|
+
const item = definition(id), action = validateVariableBlock(kind, raw, definitions);
|
|
47
|
+
return transaction(() => {
|
|
48
|
+
const key = variableStorageKey(item, context);
|
|
49
|
+
if (kind === 'deleteVariable' && action.operation === 'reset') { database.prepare('DELETE FROM variable_values WHERE storage_key=?').run(key); return { outside: false }; }
|
|
50
|
+
const current = read(item, context);
|
|
51
|
+
const outside = kind === 'deleteVariable' && item.type === 'collection' && action.operation === 'lines' && parseVariableLines(action.lines, current.length).outside;
|
|
52
|
+
const value = validateVariableValue(item.type, mutateVariableValue(item, current, { ...action, kind }));
|
|
53
|
+
if (!database.prepare('SELECT 1 FROM variable_values WHERE storage_key=?').get(key) && database.prepare('SELECT count(*) AS count FROM variable_values').get().count >= VARIABLE_LIMITS.records) throw Error('Limite de stockage des variables atteinte.');
|
|
54
|
+
database.prepare('INSERT OR REPLACE INTO variable_values VALUES(?,?,?)').run(key, id, JSON.stringify(value));
|
|
55
|
+
return { outside: Boolean(outside) }; // No new-value output is exposed to the dashboard.
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
parentPort.on('message', message => {
|
|
59
|
+
try {
|
|
60
|
+
let result;
|
|
61
|
+
if (message.action === 'sync') result = synchronize(message.definitions, message.deletions);
|
|
62
|
+
else if (message.action === 'read') result = read(definition(message.variableId), message.context);
|
|
63
|
+
else if (message.action === 'mutate') result = mutate(message.variableId, message.context, message.config, message.kind);
|
|
64
|
+
else if (message.action === 'close') { database.close(); parentPort.postMessage({ id: message.id, result: true }); parentPort.close(); return; }
|
|
65
|
+
else throw Error('Opération de variable invalide.');
|
|
66
|
+
parentPort.postMessage({ id: message.id, result });
|
|
67
|
+
} catch { parentPort.postMessage({ id: message.id, error: 'Opération de variable refusée. Vérifiez la définition, le type et les limites locales.' }); }
|
|
68
|
+
});
|
|
69
|
+
parentPort.postMessage({ ready: true });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class VariableStore {
|
|
5
|
+
constructor({ file, definitions = [], deletions = [] }) {
|
|
6
|
+
if (!path.isAbsolute(file)) throw Error('Le stockage des variables doit utiliser un chemin local absolu.');
|
|
7
|
+
this.pending = new Map(); this.sequence = 0;
|
|
8
|
+
// Node 22.5–22.12 requires the flag. It stays confined to this local worker.
|
|
9
|
+
const execArgv = process.allowedNodeEnvironmentFlags.has('--experimental-sqlite') ? ['--experimental-sqlite'] : [];
|
|
10
|
+
this.worker = new Worker(new URL('./variable-store-worker.mjs', import.meta.url), { workerData: { file }, execArgv });
|
|
11
|
+
const initialized = new Promise((resolve, reject) => {
|
|
12
|
+
this.worker.on('message', message => {
|
|
13
|
+
if (message.ready) { resolve(); return; }
|
|
14
|
+
const job = this.pending.get(message.id); if (!job) return;
|
|
15
|
+
this.pending.delete(message.id); message.error ? job.reject(Error(message.error)) : job.resolve(message.result);
|
|
16
|
+
});
|
|
17
|
+
const fail = () => { reject(Error('Le stockage local des variables est indisponible.')); for (const job of this.pending.values()) job.reject(Error('Le stockage local des variables est indisponible.')); this.pending.clear(); };
|
|
18
|
+
this.worker.on('error', fail); this.worker.on('exit', code => { if (code !== 0) fail(); });
|
|
19
|
+
});
|
|
20
|
+
this.ready = initialized.then(() => this.request({ action: 'sync', definitions, deletions }));
|
|
21
|
+
}
|
|
22
|
+
request(message) { return new Promise((resolve, reject) => { const id = ++this.sequence; this.pending.set(id, { resolve, reject }); this.worker.postMessage({ ...message, id }); }); }
|
|
23
|
+
async read(variableId, context) { await this.ready; return this.request({ action: 'read', variableId, context }); }
|
|
24
|
+
async mutate(variableId, context, config, kind) { await this.ready; return this.request({ action: 'mutate', variableId, context, config, kind }); }
|
|
25
|
+
async close() { try { await this.ready; await this.request({ action: 'close' }); } finally { await this.worker.terminate(); } }
|
|
26
|
+
}
|
package/templates/bot.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { VariableStore } from './runtime/variable-store.mjs';
|
|
1
3
|
import { readFile } from 'node:fs/promises';
|
|
2
4
|
import { Client, GatewayIntentBits, REST, Routes } from 'discord.js';
|
|
3
|
-
import { executeCommand } from './runtime/execute.mjs';
|
|
5
|
+
import { executeCommand, executeButtonInteraction } from './runtime/execute.mjs';
|
|
4
6
|
|
|
5
7
|
const plan = JSON.parse(await readFile(new URL('./plan.json', import.meta.url), 'utf8'));
|
|
6
8
|
if (plan.schemaVersion !== 1) throw new Error('Version de plan non prise en charge.');
|
|
@@ -17,30 +19,39 @@ if (process.argv.includes('--deploy')) {
|
|
|
17
19
|
await rest.put(route, { body: plan.commands.map(command => ({ name: command.name, description: command.description, type: 1, options: (command.options || []).map(({ type, name, description, required }) => ({ type, name, description, required, ...(type === 3 ? { max_length: 6000 } : {}) })) })) });
|
|
18
20
|
console.log(`${plan.commands.length} commande(s) enregistrée(s).`);
|
|
19
21
|
} else {
|
|
22
|
+
const variableStore = plan.variableDefinitions?.length || plan.variableDeletions?.length ? new VariableStore({ file: process.env.OFTONE_VARIABLES_PATH || fileURLToPath(new URL('./data/variables.sqlite', import.meta.url)), definitions: plan.variableDefinitions || [], deletions: plan.variableDeletions || [] }) : null;
|
|
23
|
+
if (variableStore) await variableStore.ready;
|
|
24
|
+
const variableOptions = { variableStore, variableDefinitions: plan.variableDefinitions || [] };
|
|
20
25
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
|
21
26
|
const commands = new Map(plan.commands.map(command => [command.name, command]));
|
|
22
27
|
client.once('ready', () => console.log(`Bot connecté : ${client.user.tag}`));
|
|
23
28
|
client.on('interactionCreate', async interaction => {
|
|
29
|
+
if(interaction.isButton?.()) {
|
|
30
|
+
try{await executeButtonInteraction(interaction,client,{...variableOptions,onExecution: sample => { if(process.connected&&process.send)try{process.send({type:'usage',hour:Math.floor(Date.now()/3600000)*3600000,...sample},()=>{});}catch{} }});}catch(error){
|
|
31
|
+
const payload={content:error.code==='BOT_BUSY'?'Le bot est occupé. Réessayez.':'Le bouton n’a pas pu être exécuté. Vérifiez les permissions et la configuration.',embeds:[]};
|
|
32
|
+
try{if(interaction.deferred&&!interaction.replied)await interaction.editReply(payload);else if(interaction.replied)await interaction.followUp({...payload,flags:64});else await interaction.reply({...payload,flags:64});}catch{console.error('Impossible de notifier le clic.');}
|
|
33
|
+
}return;
|
|
34
|
+
}
|
|
24
35
|
if (!interaction.isChatInputCommand()) return;
|
|
25
36
|
const command = commands.get(interaction.commandName);
|
|
26
37
|
if (!command) { await interaction.reply({ content: 'Commande introuvable.', flags: 64 }); return; }
|
|
27
|
-
const started = performance.now(), hour = Math.floor(Date.now() / 3600000) * 3600000; let success = true;
|
|
38
|
+
const started = performance.now(), hour = Math.floor(Date.now() / 3600000) * 3600000; let success = true; const warningCodes = new Set();
|
|
28
39
|
try {
|
|
29
|
-
await executeCommand(command, interaction, client, { onError: () => { success = false; } });
|
|
40
|
+
await executeCommand(command, interaction, client, { ...variableOptions, onWarning: code => warningCodes.add(code), onError: () => { success = false; } });
|
|
30
41
|
} catch (error) {
|
|
31
42
|
success = false;
|
|
32
43
|
console.error(`Échec de /${command.name} (${error.name}).`);
|
|
33
|
-
const content = 'La commande n’a pas pu être exécutée. Vérifiez sa configuration et les permissions du bot.';
|
|
44
|
+
const content = error.code === 'BOT_BUSY' ? 'Le bot est occupé. Réessayez dans quelques instants.' : 'La commande n’a pas pu être exécutée. Vérifiez sa configuration et les permissions du bot.';
|
|
34
45
|
try {
|
|
35
46
|
if (interaction.deferred && !interaction.replied) await interaction.editReply({ content, embeds: [] });
|
|
36
47
|
else if (interaction.replied) await interaction.followUp({ content, flags: 64 });
|
|
37
48
|
else await interaction.reply({ content, flags: 64 });
|
|
38
49
|
} catch { console.error('Impossible de notifier l’utilisateur.'); }
|
|
39
50
|
} finally {
|
|
40
|
-
if (process.connected && process.send) try { process.send({ type: 'usage', hour, command: command.name, success, durationMs: Math.min(900000, Math.round(performance.now() - started)) }, () => {}); } catch {}
|
|
51
|
+
if (process.connected && process.send) try { process.send({ type: 'usage', hour, command: command.name, success, warningCodes: [...warningCodes], durationMs: Math.min(900000, Math.round(performance.now() - started)) }, () => {}); } catch {}
|
|
41
52
|
}
|
|
42
53
|
});
|
|
43
54
|
client.on('error', error => console.error(`Erreur du client Discord (${error.name}).`));
|
|
44
|
-
for (const event of ['SIGTERM', 'SIGINT']) process.on(event, () => { client.destroy(); process.exit(0); });
|
|
55
|
+
for (const event of ['SIGTERM', 'SIGINT']) process.on(event, async () => { client.destroy(); if (variableStore) await variableStore.close().catch(() => {}); process.exit(0); });
|
|
45
56
|
await client.login(token);
|
|
46
57
|
}
|
package/update.mjs
CHANGED
|
@@ -16,10 +16,32 @@ export function validateRelease(release, current, publicKey = RELEASE_PUBLIC_KEY
|
|
|
16
16
|
const payload = { version: release.version, sha256: release.sha256, bytes: release.bytes };
|
|
17
17
|
if (typeof release.signature !== 'string' || !verify(null, Buffer.from(JSON.stringify(payload)), publicKey, Buffer.from(release.signature, 'base64'))) throw new Error('UPDATE_SIGNATURE');
|
|
18
18
|
const url = new URL(release.url);
|
|
19
|
-
|
|
20
|
-
if (!(local || (url.origin === 'https://builder.oftone.fr')) || url.username || url.password || url.search || url.hash || url.pathname !== `/downloads/agent-${release.sha256}.tgz`) throw new Error('UPDATE_ORIGIN');
|
|
19
|
+
if (url.origin !== 'https://builder.oftone.fr' || url.username || url.password || url.search || url.hash || url.pathname !== `/downloads/agent-${release.sha256}.tgz`) throw new Error('UPDATE_ORIGIN');
|
|
21
20
|
return url;
|
|
22
21
|
}
|
|
22
|
+
export const OFFICIAL_RELEASE_MANIFEST = 'https://builder.oftone.fr/downloads/agent-release.json';
|
|
23
|
+
export async function fetchOfficialRelease(version, currentVersion, { fetchRelease = fetch, publicKey = RELEASE_PUBLIC_KEY } = {}) {
|
|
24
|
+
if (!newer(version, currentVersion)) throw new Error('UPDATE_INVALID');
|
|
25
|
+
const response = await fetchRelease(OFFICIAL_RELEASE_MANIFEST, { redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(15000) });
|
|
26
|
+
if (!response.ok || Number(response.headers.get('content-length')) > 16384 || !response.body) throw new Error('UPDATE_MANIFEST');
|
|
27
|
+
const reader = response.body.getReader(), chunks = []; let size = 0;
|
|
28
|
+
try {
|
|
29
|
+
while (true) {
|
|
30
|
+
const { value, done } = await reader.read(); if (done) break;
|
|
31
|
+
size += value.length; if (size > 16384) throw new Error('UPDATE_MANIFEST');
|
|
32
|
+
chunks.push(Buffer.from(value));
|
|
33
|
+
}
|
|
34
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
35
|
+
const manifest = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
36
|
+
if (manifest.version !== version || manifest.path !== `/downloads/agent-${manifest.sha256}.tgz`) throw new Error('UPDATE_MANIFEST');
|
|
37
|
+
const release = { version: manifest.version, sha256: manifest.sha256, bytes: manifest.bytes, signature: manifest.signature, url: 'https://builder.oftone.fr' + manifest.path };
|
|
38
|
+
validateRelease(release, currentVersion, publicKey);
|
|
39
|
+
return release;
|
|
40
|
+
}
|
|
41
|
+
export async function installOfficialUpdate(version, options = {}) {
|
|
42
|
+
const release = await fetchOfficialRelease(version, options.currentVersion, options);
|
|
43
|
+
return installUpdate(release, options);
|
|
44
|
+
}
|
|
23
45
|
const files = new Set(['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']);
|
|
24
46
|
export function readPackage(archive) {
|
|
25
47
|
const tar = gunzipSync(archive, { maxOutputLength: 8_000_000 });
|
package/usage.mjs
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Object.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
1
|
+
import { VARIABLE_WARNING_CODES } from './runtime/custom-variables.mjs';
|
|
2
|
+
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const HOUR = 3600000;
|
|
6
|
+
export async function createUsageCollector(home, now = () => Date.now()) {
|
|
7
|
+
const file = path.join(home, 'usage.json');
|
|
8
|
+
let state;
|
|
9
|
+
try { state = JSON.parse(await readFile(file, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
10
|
+
state ||= { source: randomUUID(), buckets: {}, acknowledged: {} };
|
|
11
|
+
if (!/^[a-f0-9-]{36}$/.test(state.source) || !state.buckets || !state.acknowledged) throw new Error('Invalid local usage store');
|
|
12
|
+
let writing = Promise.resolve();
|
|
13
|
+
return {
|
|
14
|
+
record(event) {
|
|
15
|
+
if (event?.type !== 'usage' || !/^[a-z0-9_-]{1,32}$/.test(event.command || '') || typeof event.success !== 'boolean' || !Number.isFinite(event.durationMs) || event.durationMs < 0 || event.durationMs > 900000) return;
|
|
16
|
+
const current = Math.floor(now() / HOUR) * HOUR;
|
|
17
|
+
const hour = event.hour ?? current;
|
|
18
|
+
if (!Number.isSafeInteger(hour) || hour % HOUR || hour > current || hour < current - 719 * HOUR) return;
|
|
19
|
+
const bucket = state.buckets[hour] ||= { hour, revision: 0, commands: {} };
|
|
20
|
+
if (!Object.hasOwn(bucket.commands, event.command) && Object.keys(bucket.commands).length >= 200) return;
|
|
21
|
+
const old = Object.hasOwn(bucket.commands, event.command) ? bucket.commands[event.command] : { count: 0, failed: 0, durationMs: 0 };
|
|
22
|
+
const warnings = Object.fromEntries(VARIABLE_WARNING_CODES.filter(code=>Number.isSafeInteger(old.warnings?.[code])&&old.warnings[code]>0).map(code=>[code,old.warnings[code]]));
|
|
23
|
+
for (const code of new Set(Array.isArray(event.warningCodes) ? event.warningCodes.slice(0,50) : [])) if (VARIABLE_WARNING_CODES.includes(code)) warnings[code] = (warnings[code] || 0) + 1;
|
|
24
|
+
Object.defineProperty(bucket.commands, event.command, { value: { count: old.count + 1, failed: old.failed + Number(!event.success), durationMs: old.durationMs + Math.round(event.durationMs), ...(Object.keys(warnings).length ? { warnings } : {}) }, enumerable: true, writable: true, configurable: true });
|
|
25
|
+
bucket.revision++;
|
|
26
|
+
},
|
|
27
|
+
acknowledge(hour, revision) {
|
|
28
|
+
if (state.buckets[hour] && Number.isSafeInteger(revision) && revision <= state.buckets[hour].revision) state.acknowledged[hour] = Math.max(state.acknowledged[hour] || 0, revision);
|
|
29
|
+
},
|
|
30
|
+
flush(send = () => {}) {
|
|
31
|
+
writing = writing.catch(() => {}).then(async () => {
|
|
32
|
+
const cutoff = Math.floor(now() / HOUR) * HOUR - 719 * HOUR;
|
|
33
|
+
for (const hour of Object.keys(state.buckets)) if (Number(hour) < cutoff) { delete state.buckets[hour]; delete state.acknowledged[hour]; }
|
|
34
|
+
const snapshot = JSON.stringify(state);
|
|
35
|
+
await writeFile(file + '.tmp', snapshot, { mode: 0o600 }); await rename(file + '.tmp', file);
|
|
36
|
+
const saved = JSON.parse(snapshot);
|
|
37
|
+
for (const bucket of Object.values(saved.buckets).sort((a, b) => b.hour - a.hour).filter(bucket => bucket.revision > (saved.acknowledged[bucket.hour] || 0)).slice(0, 24)) send({ type: 'usage', source: saved.source, ...bucket });
|
|
38
|
+
});
|
|
39
|
+
return writing;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/version.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const AGENT_VERSION = '0.5.
|
|
1
|
+
export const AGENT_VERSION = '0.5.7';
|