create-panal-agent 0.1.2 → 0.1.3
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/package.json +1 -1
- package/template/src/server.ts +117 -6
package/package.json
CHANGED
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.
|