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/botql.js CHANGED
@@ -1,789 +1,7 @@
1
-
2
- 'use strict';
3
-
4
1
  /**
5
- * botql.js Motor de execução do BotQL
6
- *
7
- * Recebe a AST gerada pelo Parser.js e executa os comandos: regista o bot,
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};