create-panal-agent 0.1.2 → 0.2.0
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/dist/i18n.js +949 -0
- package/dist/index.js +151 -42
- package/package.json +2 -2
- package/template/src/agent.ts +4 -0
- package/template/src/server.ts +117 -6
package/dist/index.js
CHANGED
|
@@ -2,20 +2,26 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* create-panal-agent — genera un agente de Panal listo para funcionar.
|
|
4
4
|
*
|
|
5
|
-
* npx create-panal-agent mi-agente
|
|
5
|
+
* npx create-panal-agent mi-agente [--lang es] [--no-input]
|
|
6
6
|
*
|
|
7
|
-
* Copia la plantilla, le pone el nombre,
|
|
8
|
-
*
|
|
9
|
-
* registrarse.
|
|
7
|
+
* Copia la plantilla, le pone el nombre, genera una wallet dedicada nueva y
|
|
8
|
+
* escribe la documentación del proyecto en el idioma de quien lo ejecuta.
|
|
9
|
+
* Después son tres pasos: instalar, publicar el endpoint y registrarse.
|
|
10
10
|
*
|
|
11
11
|
* Por qué existe: antes, poner un agente en Panal significaba leer una guía de
|
|
12
12
|
* 529 líneas, montar un VPS y mantenerlo encendido. Eso no es un alta, es un
|
|
13
13
|
* trabajo — y explica por qué el marketplace tenía cinco agentes.
|
|
14
|
+
*
|
|
15
|
+
* Por qué en diez idiomas: el alta es lo primero que ve un desarrollador, y en
|
|
16
|
+
* ella van las tres cosas que salen caras si se malinterpretan —la clave
|
|
17
|
+
* privada, el endpoint público y el gas—. Un aviso que no se entiende es un
|
|
18
|
+
* aviso que no existe.
|
|
14
19
|
*/
|
|
15
20
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
16
21
|
import { dirname, join, resolve } from 'node:path';
|
|
17
22
|
import { fileURLToPath } from 'node:url';
|
|
18
23
|
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
|
|
24
|
+
import { CATALOG, LANGS, fill, isLang, resolveLang } from './i18n.js';
|
|
19
25
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
26
|
/** La plantilla se empaqueta junto al binario (ver `files` del package.json). */
|
|
21
27
|
const TEMPLATE = resolve(HERE, '..', 'template');
|
|
@@ -34,33 +40,81 @@ const RENAMES = {
|
|
|
34
40
|
_gitignore: '.gitignore',
|
|
35
41
|
};
|
|
36
42
|
const c = {
|
|
37
|
-
bold: (s) =>
|
|
38
|
-
dim: (s) =>
|
|
39
|
-
green: (s) =>
|
|
40
|
-
yellow: (s) =>
|
|
43
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
44
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
45
|
+
green: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
46
|
+
yellow: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
41
47
|
};
|
|
42
48
|
function fail(msg) {
|
|
43
49
|
console.error(`\n${msg}\n`);
|
|
44
50
|
process.exit(1);
|
|
45
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Se admite `--lang es` y `--lang=es`. No es capricho: la primera forma es la
|
|
54
|
+
* que teclea una persona y la segunda la que escriben los scripts.
|
|
55
|
+
*/
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const args = { name: null, lang: null, noInput: false, help: false, version: false };
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === '--help' || a === '-h')
|
|
61
|
+
args.help = true;
|
|
62
|
+
else if (a === '--version' || a === '-v')
|
|
63
|
+
args.version = true;
|
|
64
|
+
else if (a === '--no-input' || a === '--yes' || a === '-y')
|
|
65
|
+
args.noInput = true;
|
|
66
|
+
else if (a === '--lang')
|
|
67
|
+
args.lang = argv[++i] ?? '';
|
|
68
|
+
else if (a.startsWith('--lang='))
|
|
69
|
+
args.lang = a.slice('--lang='.length);
|
|
70
|
+
else if (!a.startsWith('-') && args.name === null)
|
|
71
|
+
args.name = a;
|
|
72
|
+
}
|
|
73
|
+
return args;
|
|
74
|
+
}
|
|
46
75
|
/** Nombre válido de carpeta y de paquete npm. */
|
|
47
|
-
function validateName(raw) {
|
|
48
|
-
const name = raw.trim().replace(/^\.\//, '');
|
|
76
|
+
function validateName(raw, t) {
|
|
77
|
+
const name = (raw ?? '').trim().replace(/^\.\//, '');
|
|
49
78
|
if (!name)
|
|
50
|
-
fail(
|
|
51
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
52
|
-
fail(
|
|
53
|
-
'Usa minúsculas, números y guiones: mi-agente, traductor-tecnico, resumidor.');
|
|
54
|
-
}
|
|
79
|
+
fail(t.errNoName);
|
|
80
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name))
|
|
81
|
+
fail(fill(t.errBadName, { name }));
|
|
55
82
|
return name;
|
|
56
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Selector interactivo. Solo aparece cuando hay una persona delante: sin TTY
|
|
86
|
+
* —en CI, en un Dockerfile, tras una tubería— preguntar cuelga el proceso, así
|
|
87
|
+
* que en ese caso se cae al inglés sin ruido.
|
|
88
|
+
*/
|
|
89
|
+
async function askLang() {
|
|
90
|
+
const { createInterface } = await import('node:readline/promises');
|
|
91
|
+
console.log('');
|
|
92
|
+
LANGS.forEach((l, i) => {
|
|
93
|
+
console.log(` ${c.bold(String(i + 1).padStart(2))} ${l.label}${i === 0 ? c.dim(' (default)') : ''}`);
|
|
94
|
+
});
|
|
95
|
+
console.log('');
|
|
96
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
97
|
+
try {
|
|
98
|
+
// La pregunta va en inglés porque todavía no sabemos en qué idioma leer.
|
|
99
|
+
const answer = (await rl.question(`${CATALOG.en.pickLang} [1-${LANGS.length}] `)).trim();
|
|
100
|
+
const n = Number(answer);
|
|
101
|
+
if (Number.isInteger(n) && n >= 1 && n <= LANGS.length)
|
|
102
|
+
return LANGS[n - 1].code;
|
|
103
|
+
if (isLang(answer.toLowerCase()))
|
|
104
|
+
return answer.toLowerCase();
|
|
105
|
+
return 'en';
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
rl.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
57
111
|
/** Copia la plantilla sustituyendo los marcadores. */
|
|
58
|
-
function copyTemplate(dest, name) {
|
|
112
|
+
function copyTemplate(dest, name, t) {
|
|
59
113
|
cpSync(TEMPLATE, dest, { recursive: true });
|
|
60
114
|
for (const [from, to] of Object.entries(RENAMES)) {
|
|
61
115
|
const src = join(dest, from);
|
|
62
116
|
if (!existsSync(src))
|
|
63
|
-
fail(
|
|
117
|
+
fail(fill(t.errTemplateMissing, { name: from }));
|
|
64
118
|
renameSync(src, join(dest, to));
|
|
65
119
|
}
|
|
66
120
|
const walk = (dir) => {
|
|
@@ -78,38 +132,93 @@ function copyTemplate(dest, name) {
|
|
|
78
132
|
};
|
|
79
133
|
walk(dest);
|
|
80
134
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
135
|
+
/**
|
|
136
|
+
* El `.env.example` se escribe aquí, no se copia: sus comentarios explican qué
|
|
137
|
+
* es cada clave y son justo lo que hay que entender antes de pegar una clave
|
|
138
|
+
* privada en un servidor.
|
|
139
|
+
*/
|
|
140
|
+
function writeEnvExample(dest, t) {
|
|
141
|
+
const bloque = (texto) => texto
|
|
142
|
+
.split('\n')
|
|
143
|
+
.map((l) => `# ${l}`)
|
|
144
|
+
.join('\n');
|
|
145
|
+
const contenido = [
|
|
146
|
+
bloque(t.env.key),
|
|
147
|
+
'AGENT_PRIVATE_KEY=',
|
|
148
|
+
'',
|
|
149
|
+
bloque(t.env.port),
|
|
150
|
+
'PORT=8787',
|
|
151
|
+
'',
|
|
152
|
+
bloque(t.env.model),
|
|
153
|
+
'# OpenAI https://api.openai.com/v1 gpt-4o-mini',
|
|
154
|
+
'# DeepSeek https://api.deepseek.com/v1 deepseek-chat',
|
|
155
|
+
'# Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile',
|
|
156
|
+
'LLM_BASE_URL=https://api.deepseek.com/v1',
|
|
157
|
+
'LLM_API_KEY=',
|
|
158
|
+
'LLM_MODEL=deepseek-chat',
|
|
159
|
+
'',
|
|
160
|
+
bloque(t.env.rpc),
|
|
161
|
+
'RPC_URL=',
|
|
162
|
+
'',
|
|
163
|
+
bloque(t.env.data),
|
|
164
|
+
'DATA_DIR=./data',
|
|
165
|
+
'',
|
|
166
|
+
].join('\n');
|
|
167
|
+
writeFileSync(join(dest, '.env.example'), contenido, 'utf8');
|
|
168
|
+
return contenido;
|
|
169
|
+
}
|
|
170
|
+
async function main() {
|
|
171
|
+
const args = parseArgs(process.argv.slice(2));
|
|
172
|
+
// --help y --version salen antes que nada: son las dos cosas que se piden
|
|
173
|
+
// cuando aún no se sabe qué hace el comando.
|
|
174
|
+
const pedido = resolveLang(args.lang, process.env);
|
|
175
|
+
if (args.lang && !pedido)
|
|
176
|
+
fail(fill(CATALOG.en.errBadLang, { name: args.lang }));
|
|
177
|
+
if (args.version) {
|
|
178
|
+
const pkg = JSON.parse(readFileSync(resolve(HERE, '..', 'package.json'), 'utf8'));
|
|
179
|
+
console.log(pkg.version);
|
|
180
|
+
return;
|
|
86
181
|
}
|
|
182
|
+
if (args.help) {
|
|
183
|
+
console.log(`\n${(pedido ? CATALOG[pedido] : CATALOG.en).usage}\n`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Sin idioma decidido: se pregunta si hay alguien delante, y si no, inglés.
|
|
187
|
+
const interactivo = !args.noInput && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
188
|
+
const lang = pedido ?? (interactivo ? await askLang() : 'en');
|
|
189
|
+
const t = CATALOG[lang];
|
|
190
|
+
const name = validateName(args.name, t);
|
|
191
|
+
const dest = resolve(process.cwd(), name);
|
|
192
|
+
if (existsSync(dest) && readdirSync(dest).length > 0)
|
|
193
|
+
fail(fill(t.errDirExists, { name }));
|
|
87
194
|
if (!existsSync(TEMPLATE))
|
|
88
|
-
fail(
|
|
195
|
+
fail(fill(t.errTemplateMissing, { name: TEMPLATE }));
|
|
89
196
|
mkdirSync(dest, { recursive: true });
|
|
90
|
-
copyTemplate(dest, name);
|
|
197
|
+
copyTemplate(dest, name, t);
|
|
198
|
+
const envExample = writeEnvExample(dest, t);
|
|
91
199
|
// Wallet dedicada, generada aquí. La alternativa —"crea una wallet y pega la
|
|
92
200
|
// clave"— es donde la gente acaba pegando la de su MetaMask personal, que es
|
|
93
201
|
// exactamente lo que no debe vivir en un servidor.
|
|
94
202
|
const privateKey = generatePrivateKey();
|
|
95
203
|
const address = privateKeyToAccount(privateKey).address;
|
|
96
|
-
const envExample = readFileSync(join(dest, '.env.example'), 'utf8');
|
|
97
204
|
writeFileSync(join(dest, '.env'), envExample.replace('AGENT_PRIVATE_KEY=', `AGENT_PRIVATE_KEY=${privateKey}`), 'utf8');
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
console.log(c.
|
|
102
|
-
console.log(` ${c.bold(
|
|
103
|
-
console.log(c.dim(`
|
|
104
|
-
console.log(c.
|
|
105
|
-
console.log(` ${c.bold('
|
|
106
|
-
console.log(c.dim(`
|
|
107
|
-
console.log(c.dim(`
|
|
108
|
-
console.log(` ${c.bold('
|
|
109
|
-
console.log(c.dim(`
|
|
110
|
-
console.log(c.dim(`
|
|
111
|
-
console.log(
|
|
112
|
-
console.log(
|
|
113
|
-
console.log(c.dim(
|
|
205
|
+
// El README del proyecto, en su idioma. Es donde vive lo que no cabe en la
|
|
206
|
+
// pantalla de alta: cómo se cobra, qué hace cada archivo y qué rompe agentes.
|
|
207
|
+
writeFileSync(join(dest, 'README.md'), fill(t.readme, { name, address }), 'utf8');
|
|
208
|
+
console.log(`\n${c.green('✓')} ${c.bold(fill(t.created, { name }))}\n`);
|
|
209
|
+
console.log(` ${t.walletLabel} ${c.bold(address)}`);
|
|
210
|
+
console.log(c.dim(` ${fill(t.walletNote, { name })}\n`));
|
|
211
|
+
console.log(c.bold(`${t.stepsTitle}\n`));
|
|
212
|
+
console.log(` ${c.bold('1.')} ${t.s1Title}`);
|
|
213
|
+
console.log(c.dim(` ${fill(t.s1Install, { name })}`));
|
|
214
|
+
console.log(c.dim(` ${fill(t.s1Fund, { address })}\n`));
|
|
215
|
+
console.log(` ${c.bold('2.')} ${t.s2Title}`);
|
|
216
|
+
console.log(c.dim(` ${t.s2Edit}`));
|
|
217
|
+
console.log(c.dim(` ${t.s2Key}\n`));
|
|
218
|
+
console.log(` ${c.bold('3.')} ${t.s3Title}`);
|
|
219
|
+
console.log(c.dim(` ${t.s3Start}`));
|
|
220
|
+
console.log(c.dim(` ${t.s3Register}\n`));
|
|
221
|
+
console.log(`${c.yellow(t.warnLabel)} ${t.warnBody}\n`);
|
|
222
|
+
console.log(c.dim(`${t.docs}\n`));
|
|
114
223
|
}
|
|
115
|
-
main();
|
|
224
|
+
void main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-panal-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Crea un agente de IA para Panal, funcionando y cobrando on-chain, en cinco minutos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,6 +40,6 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
-
"test": "tsx test/scaffold.test.ts"
|
|
43
|
+
"test": "tsx test/i18n.test.ts && tsx test/scaffold.test.ts"
|
|
44
44
|
}
|
|
45
45
|
}
|
package/template/src/agent.ts
CHANGED
|
@@ -52,6 +52,10 @@ export async function handleTask(brief: string, ctx: TaskContext): Promise<strin
|
|
|
52
52
|
const res = await fetch(`${process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'}/chat/completions`, {
|
|
53
53
|
method: 'POST',
|
|
54
54
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` },
|
|
55
|
+
// Sin este tope, un modelo que se cuelga deja la tarea colgada para
|
|
56
|
+
// siempre: el cliente ni cobra el resultado ni recupera su dinero hasta
|
|
57
|
+
// que vence el plazo. Pasó de verdad, en mainnet.
|
|
58
|
+
signal: AbortSignal.timeout(120_000),
|
|
55
59
|
body: JSON.stringify({
|
|
56
60
|
model: process.env.LLM_MODEL ?? 'gpt-4o-mini',
|
|
57
61
|
messages: [
|
package/template/src/server.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { createPanalClient, TaskStatus } from '@panal/sdk';
|
|
28
28
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
29
|
-
import { verifyMessage } from 'viem';
|
|
29
|
+
import { keccak256, toBytes, verifyMessage } from 'viem';
|
|
30
30
|
import type { Address } from 'viem';
|
|
31
31
|
import { handleTask } from './agent.js';
|
|
32
32
|
|
|
@@ -116,6 +116,82 @@ async function work(taskId: bigint, brief: string): Promise<void> {
|
|
|
116
116
|
// HTTP
|
|
117
117
|
// ---------------------------------------------------------------------------
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Página de reenvío manual (GET /reenviar?task=<id>).
|
|
121
|
+
*
|
|
122
|
+
* Todo va incrustado: ni CDN ni fuentes ni librerías. Dentro del navegador de
|
|
123
|
+
* una wallet, cada recurso externo es una cosa más que puede no cargar, y esta
|
|
124
|
+
* página existe precisamente para cuando algo ya ha fallado.
|
|
125
|
+
*/
|
|
126
|
+
const PAGINA_REENVIO = `<!doctype html>
|
|
127
|
+
<html lang="es"><head>
|
|
128
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
129
|
+
<title>Reenviar brief · Panal</title>
|
|
130
|
+
<style>
|
|
131
|
+
:root{color-scheme:dark light}
|
|
132
|
+
body{margin:0;padding:24px 18px;font:16px/1.5 system-ui,-apple-system,sans-serif;background:#0f0f11;color:#e8e8ea;max-width:34rem;margin-inline:auto}
|
|
133
|
+
h1{font-size:1.25rem;margin:0 0 .25rem}
|
|
134
|
+
p.sub{margin:0 0 1.5rem;color:#9a9aa2;font-size:.9rem}
|
|
135
|
+
label{display:block;margin:1rem 0 .35rem;font-size:.85rem;color:#b8b8c0}
|
|
136
|
+
input,textarea{width:100%;box-sizing:border-box;padding:.7rem .8rem;border-radius:10px;border:1px solid #33333a;background:#17171b;color:inherit;font:inherit}
|
|
137
|
+
textarea{min-height:9rem;resize:vertical}
|
|
138
|
+
button{width:100%;margin-top:1rem;padding:.85rem;border:0;border-radius:10px;background:#f5c518;color:#1a1a1a;font:600 1rem system-ui;cursor:pointer}
|
|
139
|
+
button.sec{background:#26262c;color:#e8e8ea}
|
|
140
|
+
#estado{margin-top:1.1rem;padding:.8rem;border-radius:10px;font-size:.9rem;white-space:pre-wrap;word-break:break-word}
|
|
141
|
+
#estado.bien{background:#12301c;color:#7ee2a8}
|
|
142
|
+
#estado.mal{background:#33161a;color:#ff9d9d}
|
|
143
|
+
#estado:empty{display:none}
|
|
144
|
+
</style></head><body>
|
|
145
|
+
<h1>Reenviar el brief</h1>
|
|
146
|
+
<p class="sub">Para cuando el envío automático no llegó. Copia el texto exacto del pedido desde panal.lat (botón "Copiar brief del pedido") y pégalo aquí.</p>
|
|
147
|
+
<label for="id">Número de tarea</label>
|
|
148
|
+
<input id="id" inputmode="numeric" placeholder="24">
|
|
149
|
+
<label for="brief">Texto del pedido</label>
|
|
150
|
+
<textarea id="brief" placeholder="Pega aquí el brief, tal cual"></textarea>
|
|
151
|
+
<button id="conectar" class="sec">Conectar wallet</button>
|
|
152
|
+
<button id="enviar">Firmar y enviar</button>
|
|
153
|
+
<div id="estado"></div>
|
|
154
|
+
<script>
|
|
155
|
+
var q = new URLSearchParams(location.search);
|
|
156
|
+
function $(s){ return document.querySelector(s); }
|
|
157
|
+
// Solo dígitos, siempre. Un teclado de móvil cuela un punto sin que lo veas y
|
|
158
|
+
// la petición se va a /brief/25. → 404, con el usuario mirando un número que
|
|
159
|
+
// parece correcto.
|
|
160
|
+
function soloDigitos(v){ return String(v || '').replace(/[^0-9]/g, ''); }
|
|
161
|
+
$('#id').value = soloDigitos(q.get('task'));
|
|
162
|
+
$('#id').addEventListener('input', function(){ this.value = soloDigitos(this.value); });
|
|
163
|
+
var cuenta = null;
|
|
164
|
+
function estado(msg, mal){ var e = $('#estado'); e.textContent = msg; e.className = mal ? 'mal' : 'bien'; }
|
|
165
|
+
$('#conectar').onclick = async function(){
|
|
166
|
+
if (!window.ethereum) { estado('Aquí no hay wallet. Abre esta página desde el navegador de MetaMask, no desde Chrome.', true); return; }
|
|
167
|
+
try {
|
|
168
|
+
var r = await ethereum.request({ method: 'eth_requestAccounts' });
|
|
169
|
+
cuenta = r[0];
|
|
170
|
+
$('#conectar').textContent = cuenta.slice(0,6) + '…' + cuenta.slice(-4);
|
|
171
|
+
estado('Wallet conectada.');
|
|
172
|
+
} catch (e) { estado('Conexión rechazada.', true); }
|
|
173
|
+
};
|
|
174
|
+
$('#enviar').onclick = async function(){
|
|
175
|
+
var id = soloDigitos($('#id').value);
|
|
176
|
+
var brief = $('#brief').value;
|
|
177
|
+
if (!id || !brief.trim()) { estado('Falta el número de tarea o el texto.', true); return; }
|
|
178
|
+
if (!cuenta) { estado('Conecta la wallet primero: hay que firmar con la misma que pagó.', true); return; }
|
|
179
|
+
try {
|
|
180
|
+
estado('Firma el mensaje en tu wallet. No cuesta gas.');
|
|
181
|
+
var firma = await ethereum.request({ method: 'personal_sign', params: ['Panal brief #' + id, cuenta] });
|
|
182
|
+
estado('Enviando…');
|
|
183
|
+
var res = await fetch('/brief/' + id, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ brief: brief, address: cuenta, signature: firma })
|
|
187
|
+
});
|
|
188
|
+
var txt = await res.text();
|
|
189
|
+
if (res.ok) estado('Aceptado. El agente ya está trabajando en tu pedido.');
|
|
190
|
+
else estado('Rechazado (' + res.status + '):\\n' + txt, true);
|
|
191
|
+
} catch (e) { estado('Falló: ' + (e && e.message ? e.message : e), true); }
|
|
192
|
+
};
|
|
193
|
+
</script></body></html>`;
|
|
194
|
+
|
|
119
195
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
120
196
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
121
197
|
res.end(JSON.stringify(body));
|
|
@@ -153,26 +229,50 @@ const server = createServer((req, res) => {
|
|
|
153
229
|
return;
|
|
154
230
|
}
|
|
155
231
|
|
|
232
|
+
// Reenvío manual del brief, para cuando el envío automático del dashboard
|
|
233
|
+
// no llega: móvil, wallet que se traga la firma, pestaña cerrada a medias.
|
|
234
|
+
// Se sirve desde el propio agente a propósito: mismo origen, sin CORS de
|
|
235
|
+
// por medio, y funciona dentro del navegador de una wallet.
|
|
236
|
+
if (url.pathname === '/reenviar' && req.method === 'GET') {
|
|
237
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
238
|
+
res.end(PAGINA_REENVIO);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
156
242
|
// ---- El cliente te manda el encargo -------------------------------------
|
|
157
|
-
|
|
243
|
+
// La ruta canónica es POST /brief/<taskId>: es la que llama el dashboard de
|
|
244
|
+
// panal.lat y la que documenta el bot de referencia. Se admite también
|
|
245
|
+
// POST /brief con el taskId dentro del cuerpo, porque hay clientes que ya
|
|
246
|
+
// hablaban así y romperlos no arregla nada.
|
|
247
|
+
const rutaBrief = /^\/brief(?:\/(\d+))?$/.exec(url.pathname);
|
|
248
|
+
if (rutaBrief && req.method === 'POST') {
|
|
158
249
|
const body = JSON.parse(await readBody(req)) as {
|
|
159
250
|
taskId?: string | number;
|
|
160
251
|
brief?: string;
|
|
252
|
+
address?: string;
|
|
161
253
|
signature?: string;
|
|
162
254
|
};
|
|
163
|
-
|
|
255
|
+
const idCrudo = rutaBrief[1] ?? body.taskId;
|
|
256
|
+
if (idCrudo === undefined || !body.brief || !body.signature) {
|
|
164
257
|
json(res, 400, { error: 'faltan taskId, brief o signature' });
|
|
165
258
|
return;
|
|
166
259
|
}
|
|
167
|
-
const taskId = BigInt(
|
|
260
|
+
const taskId = BigInt(idCrudo);
|
|
168
261
|
const task = await panal.getTask(taskId);
|
|
169
262
|
|
|
170
|
-
//
|
|
171
|
-
// siga abierta,
|
|
263
|
+
// Cuatro comprobaciones, y las cuatro importan: que la tarea sea tuya,
|
|
264
|
+
// que siga abierta, que quien dice firmar sea el cliente que pagó, y que
|
|
265
|
+
// la firma lo demuestre.
|
|
172
266
|
if (task.worker.toLowerCase() !== account.address.toLowerCase()) {
|
|
173
267
|
json(res, 403, { error: 'esa tarea no es de este agente' });
|
|
174
268
|
return;
|
|
175
269
|
}
|
|
270
|
+
// El dashboard manda además quién firma; si no cuadra con el cliente de
|
|
271
|
+
// la tarea, se corta antes de gastar una verificación de firma.
|
|
272
|
+
if (body.address && body.address.toLowerCase() !== task.client.toLowerCase()) {
|
|
273
|
+
json(res, 403, { error: 'esa dirección no es el cliente de la tarea' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
176
276
|
if (task.status !== TaskStatus.Open) {
|
|
177
277
|
json(res, 409, { error: `la tarea está ${TaskStatus[task.status]}` });
|
|
178
278
|
return;
|
|
@@ -181,6 +281,17 @@ const server = createServer((req, res) => {
|
|
|
181
281
|
json(res, 401, { error: 'la firma no es del cliente de esta tarea' });
|
|
182
282
|
return;
|
|
183
283
|
}
|
|
284
|
+
// Y que el texto sea EL que se encargó. Para esto existe el taskHash: sin
|
|
285
|
+
// esta comprobación, un cliente podría pagar por una cosa on-chain y
|
|
286
|
+
// pedirte otra por HTTP, y en una disputa el árbitro no tendría con qué
|
|
287
|
+
// decidir. Un carácter de más y esto salta, que es justo lo que se busca.
|
|
288
|
+
if (keccak256(toBytes(body.brief)) !== task.taskHash) {
|
|
289
|
+
json(res, 409, {
|
|
290
|
+
error: 'ese texto no es el que se registró en la cadena para esta tarea',
|
|
291
|
+
taskHash: task.taskHash,
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
184
295
|
|
|
185
296
|
json(res, 202, { ok: true });
|
|
186
297
|
// Sin await: el cliente no debería esperar a que termines de trabajar.
|