botql 1.0.2 → 1.2.2
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/Database.js +1 -146
- package/LICENSE +30 -10
- package/Parser.js +2 -759
- package/RAG.js +1 -572
- package/README.md +183 -12
- package/Terminal.js +96 -0
- package/botql.browser.js +686 -1550
- package/botql.js +4 -786
- package/createBot.js +88 -0
- package/package.json +3 -2
- package/Bootstrap.js +0 -74
package/botql.js
CHANGED
|
@@ -1,789 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
1
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* cria tabelas, guarda handlers de eventos (ON START / ON MESSAGE / ON SIGNAL)
|
|
9
|
-
* e corre-os quando uma mensagem ou sinal chega.
|
|
10
|
-
*
|
|
11
|
-
* Uso básico:
|
|
12
|
-
* const { BotQLInterpreter } = require('./botql.js');
|
|
13
|
-
* const bot = BotQLInterpreter.fromSource(sourceCode, {
|
|
14
|
-
* onReply: ({ target, text }) => console.log('REPLY', target, text),
|
|
15
|
-
* onForward: ({ target }) => console.log('FORWARD', target),
|
|
16
|
-
* onSend: ({ target, signal }) => console.log('SEND', target, signal),
|
|
17
|
-
* });
|
|
18
|
-
* await bot.start();
|
|
19
|
-
* await bot.receiveMessage('+244900000000', 'quero comprar');
|
|
2
|
+
*botql.min.js v1.1.3 © 2026/09
|
|
3
|
+
*Adilson C. Rafael
|
|
4
|
+
*adilsonrafael847@gmail.com, for more info.
|
|
20
5
|
*/
|
|
21
6
|
|
|
22
|
-
const { Parser } = require('./Parser.js');
|
|
23
|
-
const { MemoryDatabase } = require('./Database.js');
|
|
24
|
-
const { createDefaultFileSystem } = require('./FileSystem.js');
|
|
25
|
-
const { KnowledgeCache } = require('./RAG.js');
|
|
26
|
-
|
|
27
|
-
const WAITING_TEXTO_PADRAO = 'Pensando...';
|
|
28
|
-
const WAITING_SEGUNDOS_PADRAO = 3;
|
|
29
|
-
|
|
30
|
-
// A base de dados em memória vive só em Database.js. Qualquer objeto com
|
|
31
|
-
// os métodos createTable/insert/update/getRows pode ser passado como `db`
|
|
32
|
-
// nas opções do interpreter, para ligar a um banco real (ex: SQLiteDatabase).
|
|
33
|
-
|
|
34
|
-
// ===== Interpreter =====
|
|
35
|
-
|
|
36
|
-
class BotQLInterpreter {
|
|
37
|
-
constructor(options = {}) {
|
|
38
|
-
this.db = options.db || new MemoryDatabase();
|
|
39
|
-
|
|
40
|
-
// onReply/onForward/onSend ficam `null` quando não passados nas
|
|
41
|
-
// opções, para se poder distinguir "não definido" de "função vazia".
|
|
42
|
-
// Isto é o que permite ao Connectors.js (attachConnectors) saber se
|
|
43
|
-
// deve ou não injetar o connector real da plataforma — se ficasse
|
|
44
|
-
// sempre com uma função por omissão, o `||` do attachConnectors
|
|
45
|
-
// nunca via um valor falsy e nunca substituía nada.
|
|
46
|
-
this.onReply = options.onReply || null;
|
|
47
|
-
this.onForward = options.onForward || null;
|
|
48
|
-
this.onSend = options.onSend || null;
|
|
49
|
-
this.signalParser = options.signalParser || ((raw) => raw);
|
|
50
|
-
|
|
51
|
-
// THINK(ficheiro.txt): dispara onThinking (se definido) antes de
|
|
52
|
-
// procurar no KnowledgeIndex — a plataforma usa isso para mostrar
|
|
53
|
-
// algo tipo "Pensando..." enquanto a busca corre, já que não é
|
|
54
|
-
// instantânea como um REPLY comum (RAG.js pondera BM25 + fuzzy match
|
|
55
|
-
// sobre o ficheiro inteiro a cada chamada não cacheada).
|
|
56
|
-
//
|
|
57
|
-
// A decisão de responder ou não já vem pronta do KnowledgeIndex
|
|
58
|
-
// (analyze(), que cruza confidence com a margem entre o melhor e o
|
|
59
|
-
// segundo colocado — ver RAG.js) — por isso não há aqui um limiar
|
|
60
|
-
// fixo configurável como havia antes com search().
|
|
61
|
-
this.onThinking = options.onThinking || null;
|
|
62
|
-
|
|
63
|
-
this.botName = null;
|
|
64
|
-
this.platform = null;
|
|
65
|
-
this.connections = [];
|
|
66
|
-
this.handlers = { START: [], MESSAGE: [], SIGNAL: [] };
|
|
67
|
-
this.running = false;
|
|
68
|
-
|
|
69
|
-
// CONNECT RESPONSE <alias>: guarda so os aliases ligados (Set), na
|
|
70
|
-
// ordem declarada no .sql. Response() sem argumento so e permitido
|
|
71
|
-
// com exatamente uma ligacao — response() com mais de uma e ambiguo,
|
|
72
|
-
// listado no erro. A funcao que fala com a IA de verdade fica de
|
|
73
|
-
// fora do motor (this.onResponse, plugavel como onReply/onForward) —
|
|
74
|
-
// o interpreter so valida o alias e invoca o handler.
|
|
75
|
-
this.responseConnections = new Set();
|
|
76
|
-
this.onResponse = options.onResponse || null;
|
|
77
|
-
|
|
78
|
-
// DEFAULT MESSAGE: no máximo uma tabela do bot pode ter isto (a doc
|
|
79
|
-
// não define o que aconteceria com duas), por isso guardamos só a
|
|
80
|
-
// última declarada, tal como faria um CREATE TABLE duplicado.
|
|
81
|
-
// { table, senderColumn, file?, index?, value? } — ver load() e
|
|
82
|
-
// receiveMessage().
|
|
83
|
-
this.defaultMessageConfig = null;
|
|
84
|
-
|
|
85
|
-
this.nativeFuncs = new Map();
|
|
86
|
-
this.registerFunction('NOW', () => new Date().toISOString());
|
|
87
|
-
|
|
88
|
-
// Acesso a ficheiros para resolver IMPORT: por omissão usa fs/path
|
|
89
|
-
// reais do Node (createDefaultFileSystem deteta o ambiente). Em
|
|
90
|
-
// browser ou qualquer ambiente sem disco, passar options.fileSystem
|
|
91
|
-
// com um adapter próprio (ex: FileSystem.MemoryFileSystem) — ver
|
|
92
|
-
// FileSystem.js para o contrato exigido.
|
|
93
|
-
this.fileSystem = options.fileSystem || createDefaultFileSystem();
|
|
94
|
-
|
|
95
|
-
// Suporte a IMPORT: pilha de diretórios base (para resolver caminhos
|
|
96
|
-
// relativos de imports aninhados) e registo de ficheiros já
|
|
97
|
-
// importados (evita loops em imports circulares).
|
|
98
|
-
const defaultBasePath = (typeof process !== 'undefined' && process.cwd) ? process.cwd() : '';
|
|
99
|
-
this._basePathStack = [options.basePath || defaultBasePath];
|
|
100
|
-
this._importedFiles = new Set();
|
|
101
|
-
|
|
102
|
-
// Base fixa usada para resolver CONTAINS KEYWORDS("ficheiro.txt"):
|
|
103
|
-
// ao contrário do _basePathStack (que muda durante o load de
|
|
104
|
-
// IMPORTs aninhados), isto guarda sempre a pasta do ficheiro
|
|
105
|
-
// principal, porque KEYWORDS() só é lido em runtime (ao receber
|
|
106
|
-
// uma mensagem), já depois do load/_flattenImports ter terminado.
|
|
107
|
-
this._rootBasePath = options.basePath || defaultBasePath;
|
|
108
|
-
|
|
109
|
-
// Cache de ficheiros de keywords já lidos: path -> array de
|
|
110
|
-
// palavras (uma por linha). Evita reler o ficheiro a cada
|
|
111
|
-
// mensagem recebida.
|
|
112
|
-
this._keywordsFileCache = new Map();
|
|
113
|
-
|
|
114
|
-
// Cache de ficheiros de resposta (REPLY (ficheiro.txt, N)) já lidos:
|
|
115
|
-
// path -> Map(indice -> texto). Mesmo raciocínio do
|
|
116
|
-
// _keywordsFileCache acima. Reaproveitado também por
|
|
117
|
-
// IMPORT {ficheiro.txt, N} — é o mesmo formato "N- valor" por linha.
|
|
118
|
-
this._replyFileCache = new Map();
|
|
119
|
-
|
|
120
|
-
// Valores carregados via IMPORT {ficheiro.txt, N}: nome do ficheiro
|
|
121
|
-
// (sem extensão) -> valor lido. Ficam disponíveis em qualquer
|
|
122
|
-
// expressão como env.NOME (ex: IMPORT {env.txt, 1} fica acessível
|
|
123
|
-
// como env.env — ver evalExpr, ramo MemberAccess).
|
|
124
|
-
this.envValues = new Map();
|
|
125
|
-
|
|
126
|
-
// KnowledgeCache (RAG.js): um KnowledgeIndex por ficheiro de
|
|
127
|
-
// conhecimento, construído (BM25 + stemming) na primeira vez que
|
|
128
|
-
// THINK(ficheiro) é avaliado, e reaproveitado nas mensagens
|
|
129
|
-
// seguintes — o índice não muda entre mensagens, só a query muda.
|
|
130
|
-
this.knowledgeCache = new KnowledgeCache(this.fileSystem);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
static fromSource(source, options = {}) {
|
|
134
|
-
const ast = new Parser(source).parseProgram();
|
|
135
|
-
const interpreter = new BotQLInterpreter(options);
|
|
136
|
-
interpreter.load(ast);
|
|
137
|
-
return interpreter;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Lê um ficheiro .sql do disco (ou de outro fileSystem injetado) e usa
|
|
141
|
-
// a sua pasta como base para resolver IMPORTs relativos dentro dele.
|
|
142
|
-
static fromFile(filePath, options = {}) {
|
|
143
|
-
const fileSystem = options.fileSystem || createDefaultFileSystem();
|
|
144
|
-
const resolved = fileSystem.resolve('', filePath);
|
|
145
|
-
const source = fileSystem.readFile(resolved);
|
|
146
|
-
return BotQLInterpreter.fromSource(source, {
|
|
147
|
-
...options,
|
|
148
|
-
fileSystem,
|
|
149
|
-
basePath: fileSystem.dirname(resolved)
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
registerFunction(name, fn) {
|
|
154
|
-
this.nativeFuncs.set(name, fn);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ---- Carregamento da AST: statements de topo, fora de eventos ----
|
|
158
|
-
//
|
|
159
|
-
// IMPORT é sempre resolvido antes de qualquer outro comando, independente
|
|
160
|
-
// de onde aparece no ficheiro (tal como o SOURCE do MySQL ou o \i do
|
|
161
|
-
// psql) — por isso o corpo é primeiro "achatado": todo o conteúdo vindo
|
|
162
|
-
// de ficheiros importados entra ANTES dos statements do próprio ficheiro,
|
|
163
|
-
// recursivamente, mesmo que o IMPORT apareça no meio ou no fim do texto.
|
|
164
|
-
|
|
165
|
-
load(ast, basePath = this._basePathStack[this._basePathStack.length - 1]) {
|
|
166
|
-
const flatBody = this._flattenImports(ast.body, basePath);
|
|
167
|
-
|
|
168
|
-
for (const node of flatBody) {
|
|
169
|
-
switch (node.type) {
|
|
170
|
-
case 'CreateBot':
|
|
171
|
-
this.botName = node.name;
|
|
172
|
-
break;
|
|
173
|
-
case 'Platform':
|
|
174
|
-
this.platform = node.value;
|
|
175
|
-
break;
|
|
176
|
-
case 'Connect':
|
|
177
|
-
this.connections.push({
|
|
178
|
-
service: node.service,
|
|
179
|
-
kind: node.kind,
|
|
180
|
-
credential: node.credential
|
|
181
|
-
});
|
|
182
|
-
break;
|
|
183
|
-
case 'ConnectResponse':
|
|
184
|
-
if (!this.envValues.has(node.alias)) {
|
|
185
|
-
throw new Error(`runtime error: CONNECT RESPONSE failed, alias "${node.alias}" was not imported (use IMPORT {..., N} AS ${node.alias})`);
|
|
186
|
-
}
|
|
187
|
-
this.responseConnections.add(node.alias);
|
|
188
|
-
break;
|
|
189
|
-
case 'CreateTable':
|
|
190
|
-
this.db.createTable(node.name, node.columns, node.preventDefault);
|
|
191
|
-
if (node.defaultMessage) {
|
|
192
|
-
const senderColumn = node.name === 'Context'
|
|
193
|
-
? 'client'
|
|
194
|
-
: (node.columns.find((c) => c.name === 'client' || c.name === 'sender') || {}).name;
|
|
195
|
-
if (!senderColumn) {
|
|
196
|
-
throw new Error(`runtime error: DEFAULT MESSAGE on table "${node.name}" requires a "client" or "sender" column`);
|
|
197
|
-
}
|
|
198
|
-
this.defaultMessageConfig = { table: node.name, senderColumn, ...node.defaultMessage };
|
|
199
|
-
}
|
|
200
|
-
break;
|
|
201
|
-
case 'On':
|
|
202
|
-
this.handlers[node.event].push(node);
|
|
203
|
-
break;
|
|
204
|
-
case 'RunBot':
|
|
205
|
-
this.running = true;
|
|
206
|
-
break;
|
|
207
|
-
default:
|
|
208
|
-
throw new Error(`runtime error: unsupported top-level statement: ${node.type}`);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Devolve um array de statements sem nenhum nó 'Import': o conteúdo de
|
|
214
|
-
// cada ficheiro importado é lido, parseado e achatado recursivamente
|
|
215
|
-
// primeiro (imports dentro de imports também respeitam a regra), e só
|
|
216
|
-
// depois vêm os statements que pertencem a este próprio ficheiro.
|
|
217
|
-
//
|
|
218
|
-
// IMPORT {ficheiro.sql} (sem índice) — comportamento original: lê o
|
|
219
|
-
// ficheiro inteiro como código BotQL e junta ao programa.
|
|
220
|
-
//
|
|
221
|
-
// IMPORT {ficheiro.txt, N} (com índice) — não é código: lê só a
|
|
222
|
-
// entrada N do ficheiro (mesmo formato "N- valor" do REPLY indexado)
|
|
223
|
-
// e guarda em this.envValues. Com AS alias, a chave é o alias (fica
|
|
224
|
-
// acessível diretamente pelo nome, ex: K, CONNECT RESPONSE K); sem
|
|
225
|
-
// AS, mantém-se o comportamento original — chave é o nome do
|
|
226
|
-
// ficheiro sem extensão, só acessível via env.NOME_FICHEIRO. Um
|
|
227
|
-
// ficheiro sem AS não pode ser usado por CONNECT RESPONSE (exige
|
|
228
|
-
// alias explícito, para não colidir com outros IMPORTs do mesmo
|
|
229
|
-
// ficheiro em índices diferentes).
|
|
230
|
-
// Não produz nenhum statement — é resolvido e removido do AST aqui
|
|
231
|
-
// mesmo, antes de correr qualquer evento.
|
|
232
|
-
_flattenImports(body, basePath) {
|
|
233
|
-
const fromImports = [];
|
|
234
|
-
const ownStatements = [];
|
|
235
|
-
|
|
236
|
-
for (const node of body) {
|
|
237
|
-
if (node.type !== 'Import') {
|
|
238
|
-
ownStatements.push(node);
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (node.index !== null && node.index !== undefined) {
|
|
243
|
-
const index = this.evalExpr(node.index, this.createContext());
|
|
244
|
-
const entries = this._loadIndexedFile(node.path, basePath);
|
|
245
|
-
const value = entries.get(Number(index));
|
|
246
|
-
if (value === undefined) {
|
|
247
|
-
throw new Error(`runtime error: IMPORT failed, entry ${index} not found in "${node.path}"`);
|
|
248
|
-
}
|
|
249
|
-
const key = node.alias || node.path.replace(/\.[^.]+$/, '');
|
|
250
|
-
this.envValues.set(key, value);
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const fullPath = this.fileSystem.resolve(basePath, node.path);
|
|
255
|
-
if (this._importedFiles.has(fullPath)) continue; // já importado, ignora (evita loops)
|
|
256
|
-
if (!this.fileSystem.exists(fullPath)) {
|
|
257
|
-
throw new Error(`runtime error: IMPORT failed, file not found: "${fullPath}"`);
|
|
258
|
-
}
|
|
259
|
-
this._importedFiles.add(fullPath);
|
|
260
|
-
|
|
261
|
-
const source = this.fileSystem.readFile(fullPath);
|
|
262
|
-
const importedAst = new Parser(source).parseProgram();
|
|
263
|
-
const nestedFlat = this._flattenImports(importedAst.body, this.fileSystem.dirname(fullPath));
|
|
264
|
-
fromImports.push(...nestedFlat);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
return [...fromImports, ...ownStatements];
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ---- Contexto de execução de um evento ----
|
|
271
|
-
|
|
272
|
-
createContext(base = {}) {
|
|
273
|
-
return {
|
|
274
|
-
vars: {
|
|
275
|
-
client: base.client || null,
|
|
276
|
-
message: base.message || null,
|
|
277
|
-
lastMsg: null,
|
|
278
|
-
SIGNAL: null
|
|
279
|
-
},
|
|
280
|
-
rawSignal: base.rawSignal || null,
|
|
281
|
-
lastInsertId: null
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ---- Ciclo de vida do bot ----
|
|
286
|
-
|
|
287
|
-
async start() {
|
|
288
|
-
for (const handler of this.handlers.START) {
|
|
289
|
-
const ctx = this.createContext();
|
|
290
|
-
await this.run(handler.body, ctx);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async receiveMessage(client, message) {
|
|
295
|
-
const ctx = this.createContext({ client, message });
|
|
296
|
-
|
|
297
|
-
// DEFAULT MESSAGE: se a tabela declarada ainda não tem nenhuma linha
|
|
298
|
-
// para este client/sender, é o primeiro contacto — responde só com a
|
|
299
|
-
// saudação e não corre o resto do ON MESSAGE desta vez (nem o INSERT
|
|
300
|
-
// que normalmente regista a mensagem). Em produção real (WhatsApp,
|
|
301
|
-
// Telegram) isto só pode ser detetado reactivamente, na primeira
|
|
302
|
-
// mensagem recebida — a plataforma não deixa o bot escrever primeiro
|
|
303
|
-
// sem o utilizador ter escrito antes. Ver greetIfNew() para o caso do
|
|
304
|
-
// preview do editor, que não tem essa restrição.
|
|
305
|
-
const cumprimentou = await this.greetIfNew(client);
|
|
306
|
-
if (cumprimentou) return ctx;
|
|
307
|
-
|
|
308
|
-
for (const handler of this.handlers.MESSAGE) {
|
|
309
|
-
await this.run(handler.body, ctx);
|
|
310
|
-
}
|
|
311
|
-
return ctx;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Mostra a DEFAULT MESSAGE para este client, se ainda não for
|
|
315
|
-
// conhecido — sem precisar de nenhuma mensagem recebida. Usado por
|
|
316
|
-
// receiveMessage() (caso real, reativo) e também pode ser chamado
|
|
317
|
-
// diretamente pelo editor/preview ao abrir o chat, já que aí não há a
|
|
318
|
-
// restrição das plataformas reais de só poder responder depois do
|
|
319
|
-
// utilizador escrever primeiro. Devolve true se cumprimentou (e por
|
|
320
|
-
// isso nada mais deve correr nesse turno), false caso contrário —
|
|
321
|
-
// incluindo quando não há DEFAULT MESSAGE nenhuma configurada.
|
|
322
|
-
async greetIfNew(client) {
|
|
323
|
-
if (!this.defaultMessageConfig) return false;
|
|
324
|
-
|
|
325
|
-
const { table, senderColumn } = this.defaultMessageConfig;
|
|
326
|
-
const jaConhecido = this.db.getRows(table).some((row) => row[senderColumn] === client);
|
|
327
|
-
if (jaConhecido) return false;
|
|
328
|
-
|
|
329
|
-
const ctx = this.createContext({ client });
|
|
330
|
-
const text = this.resolveDefaultMessageText(this.defaultMessageConfig, ctx);
|
|
331
|
-
ctx.vars.lastMsg = text;
|
|
332
|
-
// Regista este client agora, senão a próxima chamada também
|
|
333
|
-
// encontraria "nenhuma linha" e a DEFAULT MESSAGE nunca pararia de
|
|
334
|
-
// disparar.
|
|
335
|
-
this.db.insert(table, [senderColumn], [client]);
|
|
336
|
-
if (this.onReply) {
|
|
337
|
-
await this.onReply({ target: null, text, client });
|
|
338
|
-
}
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Resolve o texto do DEFAULT MESSAGE: mesma dualidade texto-direto vs
|
|
343
|
-
// ficheiro indexado que o REPLY já tem (ver resolveReplyFromFile).
|
|
344
|
-
resolveDefaultMessageText(config, ctx) {
|
|
345
|
-
if (config.file) {
|
|
346
|
-
return this.resolveReplyFromFile(config.file, config.index, ctx);
|
|
347
|
-
}
|
|
348
|
-
return String(this.evalExpr(config.value, ctx));
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
async receiveSignal(source, rawSignal) {
|
|
352
|
-
const ctx = this.createContext({ rawSignal });
|
|
353
|
-
for (const handler of this.handlers.SIGNAL) {
|
|
354
|
-
if (handler.source !== source) continue;
|
|
355
|
-
await this.run(handler.body, ctx);
|
|
356
|
-
}
|
|
357
|
-
return ctx;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// ---- Execução de uma lista de statements (corpo de um bloco) ----
|
|
361
|
-
|
|
362
|
-
async run(statements, ctx) {
|
|
363
|
-
let groupMatched = false;
|
|
364
|
-
|
|
365
|
-
for (let i = 0; i < statements.length; i++) {
|
|
366
|
-
const stmt = statements[i];
|
|
367
|
-
|
|
368
|
-
if (stmt.type === 'When') {
|
|
369
|
-
if (i === 0 || statements[i - 1].type !== 'When') groupMatched = false;
|
|
370
|
-
const isMatch = stmt.conditions.some((cond) => this.evalCondition(cond, ctx));
|
|
371
|
-
if (isMatch) {
|
|
372
|
-
await this.run(stmt.body, ctx);
|
|
373
|
-
groupMatched = true;
|
|
374
|
-
}
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (stmt.type === 'Otherwise') {
|
|
379
|
-
if (!groupMatched) await this.run(stmt.body, ctx);
|
|
380
|
-
groupMatched = false;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
await this.execStatement(stmt, ctx);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
evalCondition(cond, ctx) {
|
|
389
|
-
if (typeof ctx.vars.message !== 'string') return false;
|
|
390
|
-
const message = ctx.vars.message.toLowerCase();
|
|
391
|
-
|
|
392
|
-
if (cond.op === 'CONTAINS') {
|
|
393
|
-
// Case-insensitive: "OLA", "Ola" e "ola" devem bater com CONTAINS "ola".
|
|
394
|
-
return message.includes(cond.value.toLowerCase());
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (cond.op === 'CONTAINS_ANY') {
|
|
398
|
-
// CONTAINS ("oi", "ola", ...) — basta uma das palavras bater.
|
|
399
|
-
return cond.values.some((v) => message.includes(v.toLowerCase()));
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (cond.op === 'CONTAINS_KEYWORDS_FILE') {
|
|
403
|
-
// CONTAINS KEYWORDS("ficheiro.txt") — mesma lógica do CONTAINS_ANY,
|
|
404
|
-
// mas a lista vem de um ficheiro (lido uma vez e mantido em cache).
|
|
405
|
-
const words = this._loadKeywordsFile(cond.path);
|
|
406
|
-
return words.some((v) => message.includes(v.toLowerCase()));
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
throw new Error(`runtime error: unsupported condition: ${cond.op}`);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Lê um ficheiro de keywords: uma palavra/frase por linha, nada mais.
|
|
413
|
-
// Sem comentários nem qualquer outra sintaxe misturada de propósito —
|
|
414
|
-
// um ficheiro só serve para um fim, evita ambiguidade sobre o que é
|
|
415
|
-
// keyword e o que não é. Resultado fica em cache — chamado a cada
|
|
416
|
-
// mensagem recebida, não pode reler disco/rede sempre.
|
|
417
|
-
_loadKeywordsFile(path) {
|
|
418
|
-
if (this._keywordsFileCache.has(path)) {
|
|
419
|
-
return this._keywordsFileCache.get(path);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const fullPath = this.fileSystem.resolve(this._rootBasePath, path);
|
|
423
|
-
if (!this.fileSystem.exists(fullPath)) {
|
|
424
|
-
throw new Error(`runtime error: KEYWORDS failed, file not found: "${fullPath}"`);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const raw = this.fileSystem.readFile(fullPath);
|
|
428
|
-
const words = raw
|
|
429
|
-
.split('\n')
|
|
430
|
-
.map((linha) => linha.trim())
|
|
431
|
-
.filter((linha) => linha.length > 0);
|
|
432
|
-
|
|
433
|
-
this._keywordsFileCache.set(path, words);
|
|
434
|
-
return words;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Resolve o texto e o tempo mínimo do WAITING(...) de um THINK. Sem
|
|
438
|
-
// WAITING nenhum no .sql (stmt.waiting === null), ou com WAITING()
|
|
439
|
-
// vazio, usa os valores por omissão. O ficheiro do WAITING é lido tal
|
|
440
|
-
// e qual (texto/HTML livre) — ao contrário do REPLY/DEFAULT MESSAGE
|
|
441
|
-
// indexados, não é o formato "N- valor".
|
|
442
|
-
resolveWaitingConfig(waiting, ctx) {
|
|
443
|
-
if (!waiting) return { text: WAITING_TEXTO_PADRAO, seconds: WAITING_SEGUNDOS_PADRAO };
|
|
444
|
-
|
|
445
|
-
let text = WAITING_TEXTO_PADRAO;
|
|
446
|
-
if (waiting.file) {
|
|
447
|
-
const fullPath = this.fileSystem.resolve(this._rootBasePath, waiting.file);
|
|
448
|
-
if (!this.fileSystem.exists(fullPath)) {
|
|
449
|
-
throw new Error(`runtime error: WAITING failed, file not found: "${fullPath}"`);
|
|
450
|
-
}
|
|
451
|
-
text = this.fileSystem.readFile(fullPath).trim();
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
let seconds = WAITING_SEGUNDOS_PADRAO;
|
|
455
|
-
if (waiting.seconds) {
|
|
456
|
-
seconds = Number(this.evalExpr(waiting.seconds, ctx));
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
return { text, seconds };
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// Resolve REPLY (ficheiro.txt, indice): lê o índice (número ou
|
|
463
|
-
// expressão que resolve a número) e devolve o texto da entrada
|
|
464
|
-
// correspondente do ficheiro de respostas.
|
|
465
|
-
resolveReplyFromFile(file, indexNode, ctx) {const index = this.evalExpr(indexNode, ctx);
|
|
466
|
-
const entries = this._loadIndexedFile(file, this._rootBasePath);
|
|
467
|
-
const text = entries.get(Number(index));
|
|
468
|
-
if (text === undefined) {
|
|
469
|
-
throw new Error(`runtime error: REPLY failed, entry ${index} not found in "${file}"`);
|
|
470
|
-
}
|
|
471
|
-
return text;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Lê um ficheiro de entradas numeradas. Duas formas, na mesma
|
|
475
|
-
// entrada N, nunca misturadas:
|
|
476
|
-
//
|
|
477
|
-
// N- texto — uma linha só (forma original, continua igual)
|
|
478
|
-
// N-{ ... } — bloco delimitado por chaves, pode ter
|
|
479
|
-
// varias linhas e HTML/Markdown/CSS por
|
|
480
|
-
// dentro. So fecha na "}" que corresponde a
|
|
481
|
-
// "{" que abriu — chaves internas (ex: um
|
|
482
|
-
// style="{color:red}" dentro do HTML) contam
|
|
483
|
-
// para o aninhamento e NAO fecham a entrada
|
|
484
|
-
// cedo. Rigoroso: uma "{" sem a "}"
|
|
485
|
-
// correspondente antes do fim do ficheiro e'
|
|
486
|
-
// erro, nunca silenciosamente ignorado.
|
|
487
|
-
//
|
|
488
|
-
// Resultado e' um Map indice -> valor, em cache — usado tanto por
|
|
489
|
-
// REPLY (ficheiro, N) como por IMPORT {ficheiro, N}.
|
|
490
|
-
_loadIndexedFile(path, basePath) {
|
|
491
|
-
if (this._replyFileCache.has(path)) {
|
|
492
|
-
return this._replyFileCache.get(path);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
const fullPath = this.fileSystem.resolve(basePath, path);
|
|
496
|
-
if (!this.fileSystem.exists(fullPath)) {
|
|
497
|
-
throw new Error(`runtime error: file not found: "${fullPath}"`);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const raw = this.fileSystem.readFile(fullPath);
|
|
501
|
-
const entries = this._parseIndexedEntries(raw, path);
|
|
502
|
-
this._replyFileCache.set(path, entries);
|
|
503
|
-
return entries;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Parser char-by-char do formato de entradas numeradas. Percorre o
|
|
507
|
-
// ficheiro procurando "N-" no inicio de uma linha (ignorando espacos);
|
|
508
|
-
// o que vem a seguir decide a forma:
|
|
509
|
-
// "{" logo a seguir -> bloco: conta chaves ate a correspondente
|
|
510
|
-
// fechar, valor e' o conteudo entre elas (sem as chaves),
|
|
511
|
-
// aparado nas pontas.
|
|
512
|
-
// qualquer outra coisa -> forma de uma linha: valor e' o resto da
|
|
513
|
-
// linha, aparado.
|
|
514
|
-
_parseIndexedEntries(raw, path) {
|
|
515
|
-
const entries = new Map();
|
|
516
|
-
const len = raw.length;
|
|
517
|
-
let i = 0;
|
|
518
|
-
|
|
519
|
-
while (i < len) {
|
|
520
|
-
// Posiciona no inicio de uma linha antes de tentar casar "N-".
|
|
521
|
-
const lineStart = i;
|
|
522
|
-
let j = lineStart;
|
|
523
|
-
while (j < len && (raw[j] === ' ' || raw[j] === '\t')) j++;
|
|
524
|
-
|
|
525
|
-
const numMatch = /^\d+/.exec(raw.slice(j));
|
|
526
|
-
if (!numMatch || raw[j + numMatch[0].length] !== '-') {
|
|
527
|
-
// Nao e' inicio de entrada nesta linha — avanca para a linha seguinte.
|
|
528
|
-
const nl = raw.indexOf('\n', lineStart);
|
|
529
|
-
i = nl === -1 ? len : nl + 1;
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
const index = Number(numMatch[0]);
|
|
534
|
-
let k = j + numMatch[0].length + 1; // posicao logo depois do "-"
|
|
535
|
-
|
|
536
|
-
if (raw[k] === '{') {
|
|
537
|
-
// Forma de bloco: conta chaves ate encontrar a correspondente.
|
|
538
|
-
let depth = 1;
|
|
539
|
-
const contentStart = k + 1;
|
|
540
|
-
let p = contentStart;
|
|
541
|
-
while (p < len && depth > 0) {
|
|
542
|
-
if (raw[p] === '{') depth++;
|
|
543
|
-
else if (raw[p] === '}') depth--;
|
|
544
|
-
p++;
|
|
545
|
-
}
|
|
546
|
-
if (depth !== 0) {
|
|
547
|
-
throw new Error(`runtime error: unclosed "{" for entry ${index} in "${path}"`);
|
|
548
|
-
}
|
|
549
|
-
const value = raw.slice(contentStart, p - 1).trim();
|
|
550
|
-
entries.set(index, value);
|
|
551
|
-
i = p; // continua logo depois do "}" de fecho
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// Forma de uma linha: valor e' o resto da linha, aparado.
|
|
556
|
-
const nl = raw.indexOf('\n', k);
|
|
557
|
-
const lineEnd = nl === -1 ? len : nl;
|
|
558
|
-
const value = raw.slice(k, lineEnd).trim();
|
|
559
|
-
entries.set(index, value);
|
|
560
|
-
i = nl === -1 ? len : nl + 1;
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
return entries;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
// ---- Execução de um statement de ação ----
|
|
567
|
-
|
|
568
|
-
async execStatement(stmt, ctx) {
|
|
569
|
-
switch (stmt.type) {
|
|
570
|
-
case 'Reply': {
|
|
571
|
-
const text = stmt.file
|
|
572
|
-
? this.resolveReplyFromFile(stmt.file, stmt.index, ctx)
|
|
573
|
-
: String(await this.evalReplyValue(stmt.value, ctx));
|
|
574
|
-
ctx.vars.lastMsg = text;
|
|
575
|
-
if (this.onReply) {
|
|
576
|
-
await this.onReply({ target: stmt.target, text, client: ctx.vars.client });
|
|
577
|
-
}
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
case 'Think': {
|
|
582
|
-
const waiting = this.resolveWaitingConfig(stmt.waiting, ctx);
|
|
583
|
-
const inicio = Date.now();
|
|
584
|
-
|
|
585
|
-
if (this.onThinking) {
|
|
586
|
-
await this.onThinking({ client: ctx.vars.client, text: waiting.text });
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
const fullPath = this.fileSystem.resolve(this._rootBasePath, stmt.file);
|
|
590
|
-
const index = this.knowledgeCache.get(fullPath);
|
|
591
|
-
const resultado = index.analyze(ctx.vars.message);
|
|
592
|
-
|
|
593
|
-
// Tempo mínimo do WAITING: mesmo que a busca termine mais depressa
|
|
594
|
-
// que isso, só responde depois de decorrido esse mínimo — sem isto
|
|
595
|
-
// a bolha "a pensar" apareceria e desapareceria rápido demais para
|
|
596
|
-
// parecer natural (ver doc, secção WAITING).
|
|
597
|
-
const decorrido = Date.now() - inicio;
|
|
598
|
-
const faltam = waiting.seconds * 1000 - decorrido;
|
|
599
|
-
if (faltam > 0) {
|
|
600
|
-
await new Promise((resolve) => setTimeout(resolve, faltam));
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (resultado.decision === 'RESPONDER') {
|
|
604
|
-
const text = resultado.texto;
|
|
605
|
-
ctx.vars.lastMsg = text;
|
|
606
|
-
if (this.onReply) {
|
|
607
|
-
await this.onReply({ target: null, text, client: ctx.vars.client });
|
|
608
|
-
}
|
|
609
|
-
} else if (stmt.fallback) {
|
|
610
|
-
// REANALISAR (blocos concorrentes sem vencedor claro, mesmo após
|
|
611
|
-
// a retentativa focada e a tentativa de unir/deduplicar dentro do
|
|
612
|
-
// analyze) ou UNKNOWN (nada bateu com confiança suficiente): em
|
|
613
|
-
// ambos os casos corre o REPLY fallback declarado a seguir ao OR,
|
|
614
|
-
// tal como qualquer outro statement.
|
|
615
|
-
await this.execStatement(stmt.fallback, ctx);
|
|
616
|
-
}
|
|
617
|
-
// Sem fallback e sem decisão de RESPONDER: THINK não responde nada
|
|
618
|
-
// (silencioso) — quem escreve o .sql decide se isso é aceitável ou
|
|
619
|
-
// se devia sempre ter um OR REPLY (fallback.txt, N) a acompanhar.
|
|
620
|
-
break;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
case 'ForwardTo': {
|
|
624
|
-
const target = String(this.evalExpr(stmt.target, ctx));
|
|
625
|
-
if (this.onForward) {
|
|
626
|
-
await this.onForward({ target, client: ctx.vars.client });
|
|
627
|
-
}
|
|
628
|
-
break;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
case 'ParseSignal': {
|
|
632
|
-
ctx.vars.SIGNAL = await this.signalParser(ctx.rawSignal);
|
|
633
|
-
break;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
case 'SendTo': {
|
|
637
|
-
if (this.onSend) {
|
|
638
|
-
await this.onSend({ target: stmt.target, signal: ctx.vars.SIGNAL });
|
|
639
|
-
}
|
|
640
|
-
break;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
case 'Insert': {
|
|
644
|
-
this.execInsert(stmt, ctx);
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
case 'Update': {
|
|
649
|
-
this.execUpdate(stmt, ctx);
|
|
650
|
-
break;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
default:
|
|
654
|
-
throw new Error(`runtime error: unsupported statement inside event: ${stmt.type}`);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
execInsert(stmt, ctx) {
|
|
659
|
-
let columnNames;
|
|
660
|
-
let values;
|
|
661
|
-
|
|
662
|
-
// INSERT INTO Context() automático: sem parâmetros, captura implícita.
|
|
663
|
-
if (stmt.table === 'Context' && (!stmt.columns || stmt.columns.length === 0) && !stmt.values) {
|
|
664
|
-
columnNames = ['client', 'message', 'created_at'];
|
|
665
|
-
values = [ctx.vars.client, ctx.vars.message, new Date().toISOString()];
|
|
666
|
-
} else {
|
|
667
|
-
columnNames = (stmt.columns || []).map((c) => c.name);
|
|
668
|
-
values = (stmt.values || []).map((v) => this.evalExpr(v, ctx));
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
ctx.lastInsertId = this.db.insert(stmt.table, columnNames, values);
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
execUpdate(stmt, ctx) {
|
|
675
|
-
const value = this.evalExpr(stmt.set.value, ctx);
|
|
676
|
-
|
|
677
|
-
let whereColumn = null;
|
|
678
|
-
let whereValue = null;
|
|
679
|
-
if (stmt.where) {
|
|
680
|
-
whereColumn = stmt.where.left.name;
|
|
681
|
-
whereValue = this.evalExpr(stmt.where.right, ctx);
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
this.db.update(stmt.table, stmt.set.column, value, whereColumn, whereValue);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
// ---- Avaliação de expressões ----
|
|
688
|
-
|
|
689
|
-
// Ponto de entrada usado só por REPLY <expr>: trata Response()/
|
|
690
|
-
// Response(alias) como caso especial assíncrono (chama this.onResponse,
|
|
691
|
-
// que fala de verdade com a IA ligada) e delega qualquer outra
|
|
692
|
-
// expressão ao evalExpr síncrono normal. Response() não pode ser
|
|
693
|
-
// composto dentro de + (ex: REPLY "x" + Response() não é suportado) —
|
|
694
|
-
// só faz sentido como o valor direto do REPLY, conforme a doc.
|
|
695
|
-
async evalReplyValue(node, ctx) {
|
|
696
|
-
if (node.type === 'Call' && node.callee === 'Response') {
|
|
697
|
-
return this.execResponse(node, ctx);
|
|
698
|
-
}
|
|
699
|
-
return this.evalExpr(node, ctx);
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
// Response() / Response(alias): dispara o ciclo com a IA ligada via
|
|
703
|
-
// CONNECT RESPONSE — envia a mensagem atual, espera, devolve o texto.
|
|
704
|
-
// Sem argumento só é válido com exatamente uma ligação (ambíguo com
|
|
705
|
-
// mais que uma); com argumento, o alias tem de corresponder a um
|
|
706
|
-
// CONNECT RESPONSE já feito.
|
|
707
|
-
async execResponse(node, ctx) {
|
|
708
|
-
if (!this.onResponse) {
|
|
709
|
-
throw new Error('runtime error: Response() failed, no AI connected (missing onResponse handler)');
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
let alias;
|
|
713
|
-
if (node.args.length === 0) {
|
|
714
|
-
if (this.responseConnections.size === 0) {
|
|
715
|
-
throw new Error('runtime error: Response() failed, no CONNECT RESPONSE found');
|
|
716
|
-
}
|
|
717
|
-
if (this.responseConnections.size > 1) {
|
|
718
|
-
throw new Error(`runtime error: Response() is ambiguous with multiple CONNECT RESPONSE (${[...this.responseConnections].join(', ')}); use Response(alias)`);
|
|
719
|
-
}
|
|
720
|
-
alias = [...this.responseConnections][0];
|
|
721
|
-
} else {
|
|
722
|
-
const argNode = node.args[0];
|
|
723
|
-
if (argNode.type !== 'Identifier') {
|
|
724
|
-
throw new Error('runtime error: Response(alias) expects an alias name, not a string or expression');
|
|
725
|
-
}
|
|
726
|
-
alias = argNode.name;
|
|
727
|
-
if (!this.responseConnections.has(alias)) {
|
|
728
|
-
throw new Error(`runtime error: Response(${alias}) failed, no CONNECT RESPONSE ${alias} found`);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
const token = this.envValues.get(alias);
|
|
733
|
-
const text = await this.onResponse({ alias, token, message: ctx.vars.message, client: ctx.vars.client });
|
|
734
|
-
return text;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
evalExpr(node, ctx) {
|
|
738
|
-
switch (node.type) {
|
|
739
|
-
case 'Literal':
|
|
740
|
-
return node.value;
|
|
741
|
-
|
|
742
|
-
case 'Identifier': {
|
|
743
|
-
if (node.name === 'env') {
|
|
744
|
-
return Object.fromEntries(this.envValues);
|
|
745
|
-
}
|
|
746
|
-
if (Object.prototype.hasOwnProperty.call(ctx.vars, node.name)) {
|
|
747
|
-
return ctx.vars[node.name];
|
|
748
|
-
}
|
|
749
|
-
// Alias de um IMPORT {..., N} AS alias: acessivel diretamente pelo
|
|
750
|
-
// nome do alias, sem passar por env.alias (so quem nao usou AS cai
|
|
751
|
-
// em env.NOME_FICHEIRO, tratado no ramo acima).
|
|
752
|
-
if (this.envValues.has(node.name)) {
|
|
753
|
-
return this.envValues.get(node.name);
|
|
754
|
-
}
|
|
755
|
-
throw new Error(`runtime error: unknown identifier: "${node.name}"`);
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
case 'Member': {
|
|
759
|
-
const obj = this.evalExpr(node.object, ctx);
|
|
760
|
-
if (obj === null || obj === undefined) return undefined;
|
|
761
|
-
return obj[node.property];
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
case 'Call': {
|
|
765
|
-
if (node.callee === 'LAST_INSERT_ID') return ctx.lastInsertId;
|
|
766
|
-
const fn = this.nativeFuncs.get(node.callee);
|
|
767
|
-
if (!fn) throw new Error(`runtime error: unknown function: "${node.callee}"`);
|
|
768
|
-
const args = node.args.map((a) => this.evalExpr(a, ctx));
|
|
769
|
-
return fn(...args);
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
case 'Binary': {
|
|
773
|
-
const left = this.evalExpr(node.left, ctx);
|
|
774
|
-
const right = this.evalExpr(node.right, ctx);
|
|
775
|
-
if (node.op === '+') {
|
|
776
|
-
if (typeof left === 'number' && typeof right === 'number') return left + right;
|
|
777
|
-
return String(left) + String(right);
|
|
778
|
-
}
|
|
779
|
-
if (node.op === '=') return left === right;
|
|
780
|
-
throw new Error(`runtime error: unsupported operator: "${node.op}"`);
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
default:
|
|
784
|
-
throw new Error(`runtime error: unsupported expression: ${node.type}`);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
module.exports = { BotQLInterpreter, MemoryDatabase };
|
|
7
|
+
"use strict";const{Parser:e}=require("./Parser.js"),{MemoryDatabase:t}=require("./Database.js"),{createDefaultFileSystem:s}=require("./FileSystem.js"),{KnowledgeCache:i}=require("./RAG.js"),WAITING_TEXTO_PADRAO=null,WAITING_SEGUNDOS_PADRAO=3;class BotQLInterpreter{constructor(e={}){this.db=e.db||new t,this.onReply=e.onReply||null,this.onForward=e.onForward||null,this.onSend=e.onSend||null,this.signalParser=e.signalParser||(e=>e),this.onThinking=e.onThinking||null,this.botName=null,this.platform=null,this.connections=[],this.handlers={START:[],MESSAGE:[],SIGNAL:[]},this.running=!1,this.responseConnections=new Set,this._clientesOcupados=new Set,this.onBusy=e.onBusy||null,this.onResponse=e.onResponse||null,this.defaultMessageConfig=null,this.nativeFuncs=new Map,this.registerFunction("NOW",()=>new Date().toISOString()),this.fileSystem=e.fileSystem||s();let n="undefined"!=typeof process&&process.cwd?process.cwd():"";this._basePathStack=[e.basePath||n],this._importedFiles=new Set,this._rootBasePath=e.basePath||n,this._keywordsFileCache=new Map,this._replyFileCache=new Map,this.envValues=new Map,this.knowledgeCache=new i(this.fileSystem)}static fromSource(t,s={}){let i=new e(t).parseProgram(),n=new BotQLInterpreter(s);return n.load(i),n}static fromFile(e,t={}){let i=t.fileSystem||s(),n=i.resolve("",e),r=i.readFile(n);return BotQLInterpreter.fromSource(r,{...t,fileSystem:i,basePath:i.dirname(n)})}registerFunction(e,t){this.nativeFuncs.set(e,t)}load(e,t=this._basePathStack[this._basePathStack.length-1]){let s=this._flattenImports(e.body,t);for(let i of s)switch(i.type){case"CreateBot":this.botName=i.name;break;case"Platform":this.platform=i.value;break;case"Connect":this.connections.push({service:i.service,kind:i.kind,credential:i.credential});break;case"ConnectResponse":if(!this.envValues.has(i.alias))throw Error(`runtime error: CONNECT RESPONSE failed, alias "${i.alias}" was not imported (use IMPORT {..., N} AS ${i.alias})`);this.responseConnections.add(i.alias);break;case"CreateTable":if(this.db.createTable(i.name,i.columns,i.preventDefault),i.defaultMessage){let n="Context"===i.name?"client":(i.columns.find(e=>"client"===e.name||"sender"===e.name)||{}).name;if(!n)throw Error(`runtime error: DEFAULT MESSAGE on table "${i.name}" requires a "client" or "sender" column`);this.defaultMessageConfig={table:i.name,senderColumn:n,...i.defaultMessage}}break;case"On":this.handlers[i.event].push(i);break;case"RunBot":this.running=!0;break;default:throw Error(`runtime error: unsupported top-level statement: ${i.type}`)}}_flattenImports(t,s){let i=[],n=[];for(let r of t){if("Import"!==r.type){n.push(r);continue}if(null!==r.index&&void 0!==r.index){let a=this.evalExpr(r.index,this.createContext()),l=this._loadIndexedFile(r.path,s),o=l.get(Number(a));if(void 0===o)throw Error(`runtime error: IMPORT failed, entry ${a} not found in "${r.path}"`);let h=r.alias||r.path.replace(/\.[^.]+$/,"");this.envValues.set(h,o);continue}let u=this.fileSystem.resolve(s,r.path);if(this._importedFiles.has(u))continue;if(!this.fileSystem.exists(u))throw Error(`runtime error: IMPORT failed, file not found: "${u}"`);this._importedFiles.add(u);let c=this.fileSystem.readFile(u),d=new e(c).parseProgram(),f=this._flattenImports(d.body,this.fileSystem.dirname(u));i.push(...f)}return[...i,...n]}createContext(e={}){return{vars:{client:e.client||null,message:e.message||null,lastMsg:null,SIGNAL:null},rawSignal:e.rawSignal||null,lastInsertId:null}}async start(){for(let e of this.handlers.START){let t=this.createContext();await this.run(e.body,t)}}async receiveMessage(e,t){if(this._clientesOcupados.has(e))return this.onBusy&&await this.onBusy({client:e,message:t}),null;this._clientesOcupados.add(e);try{let s=this.createContext({client:e,message:t}),i=await this.greetIfNew(e);if(i)return s;for(let n of this.handlers.MESSAGE)await this.run(n.body,s);return s}finally{this._clientesOcupados.delete(e)}}async greetIfNew(e){if(!this.defaultMessageConfig)return!1;let{table:t,senderColumn:s}=this.defaultMessageConfig,i=this.db.getRows(t).some(t=>t[s]===e);if(i)return!1;let n=this.createContext({client:e}),r=this.resolveDefaultMessageText(this.defaultMessageConfig,n);return n.vars.lastMsg=r,this.db.insert(t,[s],[e]),this.onReply&&await this.onReply({target:null,text:r,client:e}),!0}resolveDefaultMessageText(e,t){return e.file?this.resolveReplyFromFile(e.file,e.index,t):String(this.evalExpr(e.value,t))}async receiveSignal(e,t){let s=this.createContext({rawSignal:t});for(let i of this.handlers.SIGNAL)i.source===e&&await this.run(i.body,s);return s}async run(e,t){let s=!1;for(let i=0;i<e.length;i++){let n=e[i];if("When"===n.type){(0===i||"When"!==e[i-1].type)&&(s=!1);let r=n.conditions.some(e=>this.evalCondition(e,t));r&&(await this.run(n.body,t),s=!0);continue}if("Otherwise"===n.type){s||await this.run(n.body,t),s=!1;continue}await this.execStatement(n,t)}}evalCondition(e,t){if("string"!=typeof t.vars.message)return!1;let s=t.vars.message.toLowerCase();if("CONTAINS"===e.op)return s.includes(e.value.toLowerCase());if("CONTAINS_ANY"===e.op)return e.values.some(e=>s.includes(e.toLowerCase()));if("CONTAINS_KEYWORDS_FILE"===e.op){let i=this._loadKeywordsFile(e.path);return i.some(e=>s.includes(e.toLowerCase()))}throw Error(`runtime error: unsupported condition: ${e.op}`)}_loadKeywordsFile(e){if(this._keywordsFileCache.has(e))return this._keywordsFileCache.get(e);let t=this.fileSystem.resolve(this._rootBasePath,e);if(!this.fileSystem.exists(t))throw Error(`runtime error: KEYWORDS failed, file not found: "${t}"`);let s=this.fileSystem.readFile(t),i=s.split("\n").map(e=>e.trim()).filter(e=>e.length>0);return this._keywordsFileCache.set(e,i),i}resolveWaitingConfig(e,t){if(!e)return{text:null,seconds:3};let s=null;if(null!==e.text&&void 0!==e.text)s=e.text;else if(e.file){let i=this.fileSystem.resolve(this._rootBasePath,e.file);if(!this.fileSystem.exists(i))throw Error(`runtime error: WAITING failed, file not found: "${i}"`);s=this.fileSystem.readFile(i).trim()}let n=3;return e.seconds&&(n=Number(this.evalExpr(e.seconds,t))),{text:s,seconds:n}}resolveReplyFromFile(e,t,s){let i=this.evalExpr(t,s),n=this._loadIndexedFile(e,this._rootBasePath),r=n.get(Number(i));if(void 0===r)throw Error(`runtime error: REPLY failed, entry ${i} not found in "${e}"`);return r}_loadIndexedFile(e,t){if(this._replyFileCache.has(e))return this._replyFileCache.get(e);let s=this.fileSystem.resolve(t,e);if(!this.fileSystem.exists(s))throw Error(`runtime error: file not found: "${s}"`);let i=this.fileSystem.readFile(s),n=this._parseIndexedEntries(i,e);return this._replyFileCache.set(e,n),n}_parseIndexedEntries(e,t){let s=new Map,i=e.length,n=0;for(;n<i;){let r=n,a=r;for(;a<i&&(" "===e[a]||" "===e[a]);)a++;let l=/^\d+/.exec(e.slice(a));if(!l||"-"!==e[a+l[0].length]){let o=e.indexOf("\n",r);n=-1===o?i:o+1;continue}let h=Number(l[0]),u=a+l[0].length+1;if("{"===e[u]){let c=1,d=u+1,f=d;for(;f<i&&c>0;)"{"===e[f]?c++:"}"===e[f]&&c--,f++;if(0!==c)throw Error(`runtime error: unclosed "{" for entry ${h} in "${t}"`);let p=e.slice(d,f-1).trim();s.set(h,p),n=f;continue}let m=e.indexOf("\n",u),w=-1===m?i:m,g=e.slice(u,w).trim();s.set(h,g),n=-1===m?i:m+1}return s}async execStatement(e,t){switch(e.type){case"Reply":{let s=e.file?this.resolveReplyFromFile(e.file,e.index,t):String(await this.evalReplyValue(e.value,t));t.vars.lastMsg=s,this.onReply&&await this.onReply({target:e.target,text:s,client:t.vars.client});break}case"Waiting":{let i=this.resolveWaitingConfig(e,t);this.onThinking&&await this.onThinking({client:t.vars.client,text:i.text}),await new Promise(e=>setTimeout(e,1e3*i.seconds));break}case"Think":{let n=this.resolveWaitingConfig(e.waiting,t),r=Date.now();this.onThinking&&await this.onThinking({client:t.vars.client,text:n.text});let a=this.fileSystem.resolve(this._rootBasePath,e.file),l=this.knowledgeCache.get(a),o=l.analyze(t.vars.message),h=Date.now()-r,u=1e3*n.seconds-h;if(u>0&&await new Promise(e=>setTimeout(e,u)),"RESPONDER"===o.decision){let c=o.texto;t.vars.lastMsg=c,this.onReply&&await this.onReply({target:null,text:c,client:t.vars.client})}else e.fallback&&await this.execStatement(e.fallback,t);break}case"ForwardTo":{let d=String(this.evalExpr(e.target,t));this.onForward&&await this.onForward({target:d,client:t.vars.client});break}case"ParseSignal":t.vars.SIGNAL=await this.signalParser(t.rawSignal);break;case"SendTo":this.onSend&&await this.onSend({target:e.target,signal:t.vars.SIGNAL});break;case"Insert":this.execInsert(e,t);break;case"Update":this.execUpdate(e,t);break;default:throw Error(`runtime error: unsupported statement inside event: ${e.type}`)}}execInsert(e,t){let s,i;"Context"!==e.table||e.columns&&0!==e.columns.length||e.values?(s=(e.columns||[]).map(e=>e.name),i=(e.values||[]).map(e=>this.evalExpr(e,t))):(s=["client","message","created_at"],i=[t.vars.client,t.vars.message,new Date().toISOString()]),t.lastInsertId=this.db.insert(e.table,s,i)}execUpdate(e,t){let s=this.evalExpr(e.set.value,t),i=null,n=null;e.where&&(i=e.where.left.name,n=this.evalExpr(e.where.right,t)),this.db.update(e.table,e.set.column,s,i,n)}async evalReplyValue(e,t){return"Call"===e.type&&"Response"===e.callee?this.execResponse(e,t):this.evalExpr(e,t)}async execResponse(e,t){if(!this.onResponse)throw Error("runtime error: Response() failed, no AI connected (missing onResponse handler)");let s;if(0===e.args.length){if(0===this.responseConnections.size)throw Error("runtime error: Response() failed, no CONNECT RESPONSE found");if(this.responseConnections.size>1)throw Error(`runtime error: Response() is ambiguous with multiple CONNECT RESPONSE (${[...this.responseConnections].join(", ")}); use Response(alias)`);s=[...this.responseConnections][0]}else{let i=e.args[0];if("Identifier"!==i.type)throw Error("runtime error: Response(alias) expects an alias name, not a string or expression");if(s=i.name,!this.responseConnections.has(s))throw Error(`runtime error: Response(${s}) failed, no CONNECT RESPONSE ${s} found`)}let n=this.envValues.get(s),r=await this.onResponse({alias:s,token:n,message:t.vars.message,client:t.vars.client});return r}evalExpr(e,t){switch(e.type){case"Literal":return e.value;case"Identifier":if("env"===e.name)return Object.fromEntries(this.envValues);if(Object.prototype.hasOwnProperty.call(t.vars,e.name))return t.vars[e.name];if(this.envValues.has(e.name))return this.envValues.get(e.name);throw Error(`runtime error: unknown identifier: "${e.name}"`);case"Member":{let s=this.evalExpr(e.object,t);if(null==s)return;return s[e.property]}case"Call":{if("LAST_INSERT_ID"===e.callee)return t.lastInsertId;let i=this.nativeFuncs.get(e.callee);if(!i)throw Error(`runtime error: unknown function: "${e.callee}"`);let n=e.args.map(e=>this.evalExpr(e,t));return i(...n)}case"Binary":{let r=this.evalExpr(e.left,t),a=this.evalExpr(e.right,t);if("+"===e.op){if("number"==typeof r&&"number"==typeof a)return r+a;return String(r)+String(a)}if("="===e.op)return r===a;throw Error(`runtime error: unsupported operator: "${e.op}"`)}default:throw Error(`runtime error: unsupported expression: ${e.type}`)}}}module.exports={BotQLInterpreter,MemoryDatabase:t};
|