@danieltmn/tamnora 0.6.0 → 0.6.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/CHANGELOG.md +13 -0
- package/README.md +30 -0
- package/harness/packages/server/public/app.js +97 -2
- package/harness/packages/server/public/index.html +40 -1
- package/harness/packages/server/public/styles.css +2 -0
- package/harness/packages/server/src/lib/channels/index.js +88 -35
- package/harness/packages/server/src/lib/channels/telegram.js +166 -20
- package/harness/packages/server/src/routes/channels.js +48 -0
- package/harness/packages/server/src/routes/chat.js +6 -1
- package/harness/packages/server/src/server.js +8 -5
- package/harness/packages/server/test/server.test.js +85 -0
- package/harness/packages/server/test/telegram.test.js +94 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.6.2] - 2026-10-05
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- fix(telegram): proveedor de transcripcion dedicado y error claro en la voz
|
|
13
|
+
|
|
14
|
+
## [0.6.1] - 2026-10-05
|
|
15
|
+
|
|
16
|
+
### Cambios
|
|
17
|
+
|
|
18
|
+
- feat(consola): configura Telegram desde la consola y cierra el turno con resumen
|
|
19
|
+
- docs(website): actividad colapsable, runs durables, subagentes y Telegram
|
|
20
|
+
|
|
8
21
|
## [0.6.0] - 2026-10-04
|
|
9
22
|
|
|
10
23
|
### Cambios
|
package/README.md
CHANGED
|
@@ -81,6 +81,12 @@ menu "mas" (los tres puntitos). En el chat:
|
|
|
81
81
|
- **`/new`** abre otra sesion en la **misma carpeta** de la sesion activa; el boton
|
|
82
82
|
"+ nueva sesion" (y `/new` sin sesion) abre el selector de carpetas. La
|
|
83
83
|
**carpeta predeterminada** se elige en Ajustes.
|
|
84
|
+
- **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
|
|
85
|
+
`fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
|
|
86
|
+
(args + resultado); el volcado completo se pide bajo demanda.
|
|
87
|
+
- **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
|
|
88
|
+
volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
|
|
89
|
+
el boton de detener (o `POST /api/sessions/:id/stop`).
|
|
84
90
|
|
|
85
91
|
### Preview
|
|
86
92
|
|
|
@@ -120,6 +126,22 @@ URL publica **y un QR** para abrirlo desde el celular. Trabaja protegido por el
|
|
|
120
126
|
login del server: configura un usuario antes de exponerlo (ver
|
|
121
127
|
[`SECURITY.md`](SECURITY.md)).
|
|
122
128
|
|
|
129
|
+
### Telegram (canales de mensajeria)
|
|
130
|
+
|
|
131
|
+
Con un token de bot (`telegram.token` en `tamnora.json`, `app.json` o
|
|
132
|
+
`TAMNORA_TELEGRAM_TOKEN`) podes manejar el harness desde el chat. Cada chat mapea
|
|
133
|
+
a una sesion y el bot soporta:
|
|
134
|
+
|
|
135
|
+
- texto, **imagenes** y **notas de voz** (transcripcion con `telegram.transcribeModel`,
|
|
136
|
+
default `whisper-1`, via `POST {baseUrl}/audio/transcriptions`);
|
|
137
|
+
- aprobaciones `ask` con **botones inline** (permitir/denegar);
|
|
138
|
+
- comandos `/new`, `/status`, `/model`, `/provider`, `/mode`, `/stop`, `/help`;
|
|
139
|
+
- **notificaciones**: si un run de la consola pertenece a una sesion vinculada a un
|
|
140
|
+
chat, el canal avisa el fin, los errores, los subagentes y los permisos.
|
|
141
|
+
|
|
142
|
+
`server/src/lib/channels/` define la interfaz (`stop`, `notify`); sumar otro
|
|
143
|
+
proveedor es implementarla.
|
|
144
|
+
|
|
123
145
|
## MCP
|
|
124
146
|
|
|
125
147
|
Los servidores MCP se configuran en `tamnora.json` (`mcp[]`) y soportan HTTP y
|
|
@@ -136,6 +158,14 @@ Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
|
|
|
136
158
|
esta detras de Cloudflare; para esos casos, registra un MCP de navegador (por
|
|
137
159
|
ejemplo Playwright).
|
|
138
160
|
|
|
161
|
+
## Subagentes
|
|
162
|
+
|
|
163
|
+
El agente puede delegar una subtarea con la tool `task`: lanza un **subagente**
|
|
164
|
+
con contexto y toolset propios (solo lectura por defecto, modo `plan`) y devuelve
|
|
165
|
+
un resumen. En la consola la actividad del subagente se anida en una tarjeta
|
|
166
|
+
plegable; el limite de rondas se ajusta con `agents.maxRounds`. La tool `task`
|
|
167
|
+
tambien esta disponible en modo `plan` (exploracion delegada).
|
|
168
|
+
|
|
139
169
|
## Documentacion
|
|
140
170
|
|
|
141
171
|
- [`CHANGELOG.md`](CHANGELOG.md) — historial de versiones.
|
|
@@ -178,7 +178,7 @@ function activateTab(name) {
|
|
|
178
178
|
hideMoreMenu();
|
|
179
179
|
if (name === 'runs') refreshRuns();
|
|
180
180
|
if (name === 'providers') refreshProviders();
|
|
181
|
-
if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
|
|
181
|
+
if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
document.querySelectorAll('.tab[data-tab]').forEach((t) =>
|
|
@@ -1531,6 +1531,25 @@ function handleAgentEvent(event, data, turn) {
|
|
|
1531
1531
|
return true;
|
|
1532
1532
|
}
|
|
1533
1533
|
|
|
1534
|
+
function formatDuration(ms) {
|
|
1535
|
+
const value = Number(ms) || 0;
|
|
1536
|
+
if (value <= 0) return '';
|
|
1537
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** Cierre del turno: siempre deja un resumen visible (rondas, tools, tiempo). */
|
|
1541
|
+
function renderCompletion(message, data) {
|
|
1542
|
+
const tools = Number(data.toolCalls) || 0;
|
|
1543
|
+
const parts = [`${data.rounds ?? '?'} ronda${data.rounds === 1 ? '' : 's'}`];
|
|
1544
|
+
if (tools) parts.push(`${tools} herramienta${tools === 1 ? '' : 's'}`);
|
|
1545
|
+
const duration = formatDuration(data.durationMs);
|
|
1546
|
+
if (duration) parts.push(duration);
|
|
1547
|
+
if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
|
|
1548
|
+
const maxed = data.stopReason === 'max_rounds';
|
|
1549
|
+
message.tools.appendChild(el('div', maxed ? 'chip warn' : 'chip ok', `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`));
|
|
1550
|
+
scrollMessages(paneOf(message));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1534
1553
|
function handleChatEvent(event, data, turn) {
|
|
1535
1554
|
if (handleAgentEvent(event, data, turn)) return;
|
|
1536
1555
|
if (event === 'agent_start') {
|
|
@@ -1603,7 +1622,23 @@ function handleChatEvent(event, data, turn) {
|
|
|
1603
1622
|
turn.assistant().tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
|
|
1604
1623
|
} else if (event === 'done') {
|
|
1605
1624
|
setActivity(turn.active, null);
|
|
1606
|
-
|
|
1625
|
+
const finalText = (turn.active?.textEl?.textContent ?? '').trim();
|
|
1626
|
+
const tools = Number(data.toolCalls) || 0;
|
|
1627
|
+
if (!finalText) {
|
|
1628
|
+
if (data.stopReason === 'max_rounds') {
|
|
1629
|
+
setText(
|
|
1630
|
+
turn.assistant(),
|
|
1631
|
+
`⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
|
|
1632
|
+
tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
|
|
1633
|
+
} Pedile que continúe o subí \`maxRounds\`.`
|
|
1634
|
+
);
|
|
1635
|
+
} else if (tools) {
|
|
1636
|
+
setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
|
|
1637
|
+
} else {
|
|
1638
|
+
setText(turn.assistant(), '(sin texto)');
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
renderCompletion(turn.active, data);
|
|
1607
1642
|
updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
|
|
1608
1643
|
addTokens(data.tokens);
|
|
1609
1644
|
} else if (event === 'aborted') {
|
|
@@ -2361,6 +2396,11 @@ const mcpForm = $('#mcp-form');
|
|
|
2361
2396
|
const skillListEl = $('#skill-list');
|
|
2362
2397
|
const skillModal = $('#skill-modal');
|
|
2363
2398
|
const skillForm = $('#skill-form');
|
|
2399
|
+
const tgTokenEl = $('#tg-token');
|
|
2400
|
+
const tgChatsEl = $('#tg-chats');
|
|
2401
|
+
const tgTranscribeEl = $('#tg-transcribe');
|
|
2402
|
+
const tgTranscribeProviderEl = $('#tg-transcribe-provider');
|
|
2403
|
+
const tgStatusEl = $('#tg-status');
|
|
2364
2404
|
|
|
2365
2405
|
function setStatus(node, text, cls = '') {
|
|
2366
2406
|
const elNode = $(node);
|
|
@@ -2575,6 +2615,59 @@ async function submitSkill(event) {
|
|
|
2575
2615
|
refreshSkills();
|
|
2576
2616
|
}
|
|
2577
2617
|
|
|
2618
|
+
async function refreshChannels() {
|
|
2619
|
+
if (!tgStatusEl) return;
|
|
2620
|
+
const data = await api('/api/channels').catch(() => null);
|
|
2621
|
+
if (!data?.telegram) return;
|
|
2622
|
+
const tg = data.telegram;
|
|
2623
|
+
tgStatusEl.className = `summary${tg.running ? ' ok' : ''}`;
|
|
2624
|
+
tgStatusEl.textContent = tg.running
|
|
2625
|
+
? `activo${tg.tokenHint ? ` · ${tg.tokenHint}` : ''}`
|
|
2626
|
+
: tg.tokenSet
|
|
2627
|
+
? 'token guardado (bot detenido)'
|
|
2628
|
+
: 'sin configurar';
|
|
2629
|
+
if (tgChatsEl && document.activeElement !== tgChatsEl) {
|
|
2630
|
+
tgChatsEl.value = (tg.allowedChatIds ?? []).join(', ');
|
|
2631
|
+
}
|
|
2632
|
+
if (tgTranscribeEl && document.activeElement !== tgTranscribeEl) {
|
|
2633
|
+
tgTranscribeEl.value = tg.transcribeModel ?? 'whisper-1';
|
|
2634
|
+
}
|
|
2635
|
+
if (tgTranscribeProviderEl && document.activeElement !== tgTranscribeProviderEl) {
|
|
2636
|
+
tgTranscribeProviderEl.value = tg.transcribeProvider ?? '';
|
|
2637
|
+
}
|
|
2638
|
+
if (tgTokenEl && document.activeElement !== tgTokenEl) {
|
|
2639
|
+
tgTokenEl.value = '';
|
|
2640
|
+
tgTokenEl.placeholder = tg.tokenHint ? `${tg.tokenHint} (dejar vacío para no cambiar)` : '123456:ABC…';
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
async function submitChannel() {
|
|
2645
|
+
if (!tgTokenEl) return;
|
|
2646
|
+
setStatus('#tg-msg', 'guardando…');
|
|
2647
|
+
const body = {
|
|
2648
|
+
allowedChatIds: tgChatsEl?.value ?? '',
|
|
2649
|
+
transcribeModel: tgTranscribeEl?.value.trim() || 'whisper-1',
|
|
2650
|
+
transcribeProvider: tgTranscribeProviderEl?.value.trim() || '',
|
|
2651
|
+
};
|
|
2652
|
+
const token = tgTokenEl.value.trim();
|
|
2653
|
+
if (token) body.token = token;
|
|
2654
|
+
const data = await api('/api/channels/telegram', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
|
|
2655
|
+
if (data?.error) return setStatus('#tg-msg', data.error, 'err');
|
|
2656
|
+
setStatus('#tg-msg', data.telegram?.running ? 'guardado · bot activo' : 'guardado', 'ok');
|
|
2657
|
+
refreshChannels();
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2660
|
+
async function testChannel() {
|
|
2661
|
+
if (!tgTokenEl) return;
|
|
2662
|
+
setStatus('#tg-msg', 'probando…');
|
|
2663
|
+
const data = await api('/api/channels/telegram/test', {
|
|
2664
|
+
method: 'POST',
|
|
2665
|
+
body: { token: tgTokenEl.value.trim() },
|
|
2666
|
+
}).catch((e) => ({ ok: false, error: e.message }));
|
|
2667
|
+
if (data?.ok) setStatus('#tg-msg', `token OK${data.username ? ` · @${data.username}` : ''}`, 'ok');
|
|
2668
|
+
else setStatus('#tg-msg', data?.error || 'token inválido', 'err');
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2578
2671
|
async function importMcp() {
|
|
2579
2672
|
const input = $('#mcp-import');
|
|
2580
2673
|
const value = input.value.trim();
|
|
@@ -2622,6 +2715,8 @@ if (mcpListEl) {
|
|
|
2622
2715
|
}
|
|
2623
2716
|
});
|
|
2624
2717
|
$('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
|
|
2718
|
+
$('#tg-save')?.addEventListener('click', submitChannel);
|
|
2719
|
+
$('#tg-test')?.addEventListener('click', testChannel);
|
|
2625
2720
|
$('#mcp-close').addEventListener('click', closeMcpForm);
|
|
2626
2721
|
$('#mf-cancel').addEventListener('click', closeMcpForm);
|
|
2627
2722
|
$('#mf-transport').addEventListener('change', syncMcpFormFields);
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
</head>
|
|
34
34
|
<body>
|
|
35
35
|
<header class="topbar">
|
|
36
|
-
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.
|
|
36
|
+
<div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.2</span></div>
|
|
37
37
|
<nav class="tabs">
|
|
38
38
|
<button class="tab active" data-tab="chat">chat</button>
|
|
39
39
|
<button class="tab" data-tab="providers">proveedores</button>
|
|
@@ -377,6 +377,45 @@
|
|
|
377
377
|
<div id="skill-import-status" class="summary"></div>
|
|
378
378
|
<ul id="skill-list" class="providers"></ul>
|
|
379
379
|
</div>
|
|
380
|
+
|
|
381
|
+
<div class="panel">
|
|
382
|
+
<div class="row between">
|
|
383
|
+
<h2>Telegram</h2>
|
|
384
|
+
<span id="tg-status" class="summary">cargando…</span>
|
|
385
|
+
</div>
|
|
386
|
+
<p class="muted verify-line">
|
|
387
|
+
Bot por long polling (no necesita URL pública). El token vive en <code>app.json</code>
|
|
388
|
+
(la casa) y nunca se muestra completo.
|
|
389
|
+
</p>
|
|
390
|
+
<label class="field"><span>token del bot</span>
|
|
391
|
+
<input id="tg-token" type="password" autocomplete="off" spellcheck="false"
|
|
392
|
+
placeholder="123456:ABC… (dejar vacío para no cambiar)" />
|
|
393
|
+
</label>
|
|
394
|
+
<label class="field"><span>chats autorizados (ids separados por coma)</span>
|
|
395
|
+
<input id="tg-chats" spellcheck="false" placeholder="123456789, -1001234567890" />
|
|
396
|
+
</label>
|
|
397
|
+
<label class="field"><span>proveedor de transcripción (voz, opcional)</span>
|
|
398
|
+
<input id="tg-transcribe-provider" spellcheck="false"
|
|
399
|
+
placeholder="openai · groq — vacío = proveedor activo" />
|
|
400
|
+
</label>
|
|
401
|
+
<label class="field"><span>modelo de transcripción (voz)</span>
|
|
402
|
+
<input id="tg-transcribe" spellcheck="false" placeholder="whisper-1" />
|
|
403
|
+
</label>
|
|
404
|
+
<details class="tg-help">
|
|
405
|
+
<summary>¿Cómo habilito mi chat?</summary>
|
|
406
|
+
<ol>
|
|
407
|
+
<li>Creá el bot con <b>@BotFather</b> (<code>/newbot</code>) y pegá el token acá.</li>
|
|
408
|
+
<li>Guardá y mandale cualquier mensaje al bot: si tu chat no está autorizado, te
|
|
409
|
+
responde tu <b>chat id</b>.</li>
|
|
410
|
+
<li>Agregá ese id a <strong>chats autorizados</strong> y volvé a guardar.</li>
|
|
411
|
+
</ol>
|
|
412
|
+
</details>
|
|
413
|
+
<div class="row">
|
|
414
|
+
<button id="tg-save" class="btn primary" type="button">guardar</button>
|
|
415
|
+
<button id="tg-test" class="btn" type="button">probar token</button>
|
|
416
|
+
<span id="tg-msg" class="summary"></span>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
380
419
|
</section>
|
|
381
420
|
|
|
382
421
|
<dialog id="folder-modal" class="modal folder-modal">
|
|
@@ -613,6 +613,8 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
613
613
|
word-break: break-word;
|
|
614
614
|
}
|
|
615
615
|
.chip.err { border-left-color: var(--danger); color: var(--danger); }
|
|
616
|
+
.chip.ok { border-left-color: var(--ok); color: var(--ok); }
|
|
617
|
+
.chip.warn { border-left-color: var(--warn); color: var(--warn); }
|
|
616
618
|
details.reasoning { margin-top: 6px; }
|
|
617
619
|
details.reasoning > summary { color: var(--muted); cursor: pointer; font-size: 12.5px; }
|
|
618
620
|
details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top: 4px; border-left: 1px solid var(--border); padding-left: 10px; }
|
|
@@ -1,43 +1,96 @@
|
|
|
1
1
|
// Capa de canales de mensajeria. Hoy implementa Telegram; la interfaz comun
|
|
2
|
-
// (`stop`, `notify`) permite sumar otros proveedores mas adelante.
|
|
2
|
+
// (`stop`, `notify`) permite sumar otros proveedores mas adelante. El manager
|
|
3
|
+
// permite recargar los canales en caliente tras cambiar la configuracion.
|
|
3
4
|
|
|
4
5
|
import { startTelegramChannel } from './telegram.js';
|
|
5
6
|
|
|
7
|
+
function tokenHint(token) {
|
|
8
|
+
const value = String(token ?? '');
|
|
9
|
+
if (!value) return '';
|
|
10
|
+
if (value.includes('•')) return value;
|
|
11
|
+
return value.length <= 6 ? '••••' : `••••${value.slice(-4)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseAllowed(raw) {
|
|
15
|
+
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
16
|
+
return String(raw ?? '')
|
|
17
|
+
.split(/[,\s]+/)
|
|
18
|
+
.map((s) => s.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
23
|
+
* Crea el manager de canales. `load()` arranca (o reinicia) los canales segun
|
|
24
|
+
* la config efectiva (app.json + tamnora.json + entorno), `status()` la resume
|
|
25
|
+
* y `stop()` los baja.
|
|
9
26
|
*/
|
|
10
|
-
export function
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
|
|
26
|
-
for (const channel of channels) {
|
|
27
|
-
try {
|
|
28
|
-
channel.notify?.(sessionId, event);
|
|
29
|
-
} catch {
|
|
30
|
-
// un canal caido no debe frenar el run
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
return {
|
|
36
|
-
channels,
|
|
37
|
-
enabled: channels.length > 0,
|
|
38
|
-
stop() {
|
|
39
|
-
unsubscribe?.();
|
|
40
|
-
for (const channel of channels) channel.stop?.();
|
|
41
|
-
},
|
|
27
|
+
export function createChannelsManager(ctx, { log = () => {}, enabled = true } = {}) {
|
|
28
|
+
let active = null;
|
|
29
|
+
|
|
30
|
+
// app.json (global) es la base; tamnora.json solo pisa con valores definidos
|
|
31
|
+
// (evita que sus defaults vacios borren lo guardado desde la consola).
|
|
32
|
+
const readConfig = () => {
|
|
33
|
+
const app = ctx.app?.telegram ?? {};
|
|
34
|
+
const cfg = ctx.config?.telegram ?? {};
|
|
35
|
+
const tg = { ...app };
|
|
36
|
+
if (cfg.token) tg.token = cfg.token;
|
|
37
|
+
if (Array.isArray(cfg.allowedChatIds) && cfg.allowedChatIds.length) tg.allowedChatIds = cfg.allowedChatIds;
|
|
38
|
+
if (cfg.transcribeModel) tg.transcribeModel = cfg.transcribeModel;
|
|
39
|
+
if (cfg.transcribeProvider) tg.transcribeProvider = cfg.transcribeProvider;
|
|
40
|
+
return tg;
|
|
42
41
|
};
|
|
42
|
+
|
|
43
|
+
function effective() {
|
|
44
|
+
const tg = readConfig();
|
|
45
|
+
const token = tg.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
|
|
46
|
+
const allowedChatIds = parseAllowed(tg.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? []);
|
|
47
|
+
return {
|
|
48
|
+
token,
|
|
49
|
+
allowedChatIds,
|
|
50
|
+
transcribeModel: tg.transcribeModel,
|
|
51
|
+
transcribeProvider: tg.transcribeProvider,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stop() {
|
|
56
|
+
active?.unsubscribe?.();
|
|
57
|
+
for (const channel of active?.channels ?? []) channel.stop?.();
|
|
58
|
+
active = null;
|
|
59
|
+
ctx.telegram = false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function load() {
|
|
63
|
+
stop();
|
|
64
|
+
if (!enabled) return { enabled: false, channels: [] };
|
|
65
|
+
const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
|
|
66
|
+
const channels = [];
|
|
67
|
+
const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, transcribeProvider, log });
|
|
68
|
+
if (telegram) channels.push(telegram);
|
|
69
|
+
const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
|
|
70
|
+
for (const channel of channels) {
|
|
71
|
+
try {
|
|
72
|
+
channel.notify?.(sessionId, event);
|
|
73
|
+
} catch {
|
|
74
|
+
// un canal caido no debe frenar el run
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
active = { channels, unsubscribe };
|
|
79
|
+
ctx.telegram = channels.length > 0;
|
|
80
|
+
return { enabled: ctx.telegram, channels };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function status() {
|
|
84
|
+
const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
|
|
85
|
+
return {
|
|
86
|
+
tokenSet: Boolean(token),
|
|
87
|
+
tokenHint: tokenHint(token),
|
|
88
|
+
allowedChatIds,
|
|
89
|
+
transcribeModel: transcribeModel || 'whisper-1',
|
|
90
|
+
transcribeProvider: transcribeProvider || '',
|
|
91
|
+
running: Boolean(active && active.channels.length),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { load, stop, status, readConfig };
|
|
43
96
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// inline. Cada chat mapea a una sesion del harness.
|
|
4
4
|
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { runAgent, createTools, resolveMode, summarizeToolCall } from '../core.js';
|
|
6
|
+
import { runAgent, createTools, resolveMode, summarizeToolCall, listProviderModels, PROVIDER_META } from '../core.js';
|
|
7
7
|
import { resolveExistingDir } from '../workspace.js';
|
|
8
8
|
|
|
9
9
|
const API = 'https://api.telegram.org';
|
|
@@ -11,11 +11,12 @@ const API = 'https://api.telegram.org';
|
|
|
11
11
|
const HELP = [
|
|
12
12
|
'Tamnora Harness — bot',
|
|
13
13
|
'',
|
|
14
|
+
'/menu — panel con botones (modelo, modo, proveedor)',
|
|
14
15
|
'/new — nueva sesión',
|
|
15
16
|
'/status — proveedor, modelo, modo y sesión',
|
|
16
|
-
'/model
|
|
17
|
-
'/provider
|
|
18
|
-
'/mode plan|build — cambia el modo',
|
|
17
|
+
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
18
|
+
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
19
|
+
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
19
20
|
'/stop — cancela la respuesta en curso',
|
|
20
21
|
'/help — esta ayuda',
|
|
21
22
|
'',
|
|
@@ -42,6 +43,17 @@ async function tg(token, method, payload) {
|
|
|
42
43
|
return data.result;
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** Valida un token consultando `getMe`. Devuelve `{ ok, username, name }` o `{ ok:false, error }`. */
|
|
47
|
+
export async function getTelegramBotInfo(token) {
|
|
48
|
+
if (!token) return { ok: false, error: 'Falta el token.' };
|
|
49
|
+
try {
|
|
50
|
+
const result = await tg(token, 'getMe', {});
|
|
51
|
+
return { ok: true, username: result?.username ?? null, name: result?.first_name ?? null };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
async function downloadFile(token, fileId) {
|
|
46
58
|
const file = await tg(token, 'getFile', { file_id: fileId });
|
|
47
59
|
const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
|
|
@@ -58,14 +70,27 @@ function splitMessage(text, max = 3900) {
|
|
|
58
70
|
|
|
59
71
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
72
|
|
|
73
|
+
/** Linea de cierre del turno (rondas, herramientas, duración). */
|
|
74
|
+
export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
|
|
75
|
+
const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
|
|
76
|
+
if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
|
|
77
|
+
if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
|
|
78
|
+
const maxed = stopReason === 'max_rounds';
|
|
79
|
+
return `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
/**
|
|
62
83
|
* Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
|
|
63
84
|
*/
|
|
64
|
-
export function startTelegramChannel(
|
|
85
|
+
export function startTelegramChannel(
|
|
86
|
+
ctx,
|
|
87
|
+
{ token, allowedChatIds = [], transcribeModel = 'whisper-1', transcribeProvider = '', log = () => {} } = {}
|
|
88
|
+
) {
|
|
65
89
|
if (!token) return null;
|
|
66
90
|
const allowed = new Set(allowedChatIds.map(String));
|
|
67
91
|
const controllers = new Map();
|
|
68
92
|
const ownSessions = new Set();
|
|
93
|
+
const modelChoices = new Map();
|
|
69
94
|
let running = true;
|
|
70
95
|
let offset = 0;
|
|
71
96
|
|
|
@@ -108,10 +133,11 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
108
133
|
}
|
|
109
134
|
|
|
110
135
|
async function transcribe(buffer, filename) {
|
|
111
|
-
const providerId = ctx.effectiveProviderId();
|
|
112
|
-
const
|
|
136
|
+
const providerId = transcribeProvider || ctx.effectiveProviderId();
|
|
137
|
+
const settings = ctx.providerSettings(providerId) ?? {};
|
|
138
|
+
const baseUrl = (settings.baseUrl || '').replace(/\/+$/, '');
|
|
113
139
|
const apiKey = ctx.apiKeyFor(providerId);
|
|
114
|
-
if (!baseUrl) throw new Error(
|
|
140
|
+
if (!baseUrl) throw new Error(`el proveedor "${providerId}" no tiene baseUrl para transcribir`);
|
|
115
141
|
const form = new FormData();
|
|
116
142
|
form.append('file', new Blob([buffer]), filename);
|
|
117
143
|
form.append('model', transcribeModel);
|
|
@@ -120,7 +146,15 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
120
146
|
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
121
147
|
body: form,
|
|
122
148
|
});
|
|
123
|
-
if (!res.ok)
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const hint =
|
|
151
|
+
res.status === 404
|
|
152
|
+
? ` — "${providerId}" no expone /audio/transcriptions`
|
|
153
|
+
: '';
|
|
154
|
+
throw new Error(
|
|
155
|
+
`transcripción falló (HTTP ${res.status})${hint}. Elegí un proveedor con voz (OpenAI, Groq…) en Ajustes → Telegram.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
124
158
|
const data = await res.json();
|
|
125
159
|
return String(data.text ?? '').trim();
|
|
126
160
|
}
|
|
@@ -193,6 +227,7 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
193
227
|
const controller = new AbortController();
|
|
194
228
|
controllers.set(String(chatId), controller);
|
|
195
229
|
ownSessions.add(sessionId);
|
|
230
|
+
const startedAt = Date.now();
|
|
196
231
|
const message = await send(chatId, '…');
|
|
197
232
|
let full = '';
|
|
198
233
|
let activity = '';
|
|
@@ -232,8 +267,24 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
232
267
|
}
|
|
233
268
|
},
|
|
234
269
|
});
|
|
235
|
-
|
|
236
|
-
|
|
270
|
+
const newMessages = result.messages.slice(messages.length);
|
|
271
|
+
const toolCalls = newMessages.filter((m) => m.role === 'tool').length;
|
|
272
|
+
const durationMs = Date.now() - startedAt;
|
|
273
|
+
let body = result.content?.trim() || full.trim();
|
|
274
|
+
if (!body) {
|
|
275
|
+
if (result.stopReason === 'max_rounds') {
|
|
276
|
+
body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
|
|
277
|
+
toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
|
|
278
|
+
} Pedile que continúe o subí maxRounds.`;
|
|
279
|
+
} else if (toolCalls) {
|
|
280
|
+
body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
|
|
281
|
+
} else {
|
|
282
|
+
body = '(sin texto)';
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
|
|
286
|
+
await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
|
|
287
|
+
ctx.store.addMessages(sessionId, newMessages);
|
|
237
288
|
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
238
289
|
} catch (error) {
|
|
239
290
|
await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
|
|
@@ -244,12 +295,71 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
244
295
|
}
|
|
245
296
|
}
|
|
246
297
|
|
|
298
|
+
async function sendMenu(chatId) {
|
|
299
|
+
const sessionId = ensureSession(chatId);
|
|
300
|
+
const session = ctx.store.getSession(sessionId);
|
|
301
|
+
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
302
|
+
const model = session?.model || ctx.effectiveModel() || '—';
|
|
303
|
+
const mode = resolveMode(ctx.effectiveMode());
|
|
304
|
+
const text = ['⚙ Panel', `proveedor: ${providerId}`, `modelo: ${model}`, `modo: ${mode.label}`].join('\n');
|
|
305
|
+
await send(chatId, text, {
|
|
306
|
+
reply_markup: {
|
|
307
|
+
inline_keyboard: [
|
|
308
|
+
[
|
|
309
|
+
{ text: mode.id === 'plan' ? '● Plan' : 'Plan', callback_data: 'mode:plan' },
|
|
310
|
+
{ text: mode.id === 'agent' ? '● Build' : 'Build', callback_data: 'mode:agent' },
|
|
311
|
+
],
|
|
312
|
+
[
|
|
313
|
+
{ text: 'modelos', callback_data: 'menu:models' },
|
|
314
|
+
{ text: 'proveedores', callback_data: 'menu:providers' },
|
|
315
|
+
],
|
|
316
|
+
[
|
|
317
|
+
{ text: '🆕 nueva', callback_data: 'menu:new' },
|
|
318
|
+
{ text: '⏹ detener', callback_data: 'menu:stop' },
|
|
319
|
+
],
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function sendModels(chatId) {
|
|
326
|
+
const sessionId = ensureSession(chatId);
|
|
327
|
+
const providerId = ctx.store.getSession(sessionId)?.provider || ctx.effectiveProviderId();
|
|
328
|
+
const models = await listProviderModels({
|
|
329
|
+
...ctx.providerSettings(providerId),
|
|
330
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
331
|
+
}).catch(() => []);
|
|
332
|
+
|
|
333
|
+
const current = ctx.store.getSession(sessionId)?.model || ctx.effectiveModel();
|
|
334
|
+
if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
|
|
335
|
+
const capped = models.slice(0, 12);
|
|
336
|
+
modelChoices.set(String(chatId), capped.map((m) => m.id));
|
|
337
|
+
const rows = capped.map((m, i) => [
|
|
338
|
+
{ text: `${m.id === current ? '● ' : ''}${m.label || m.id}`, callback_data: `model:${i}` },
|
|
339
|
+
]);
|
|
340
|
+
rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
|
|
341
|
+
await send(chatId, `Modelos de ${providerId} (elegí uno):`, { reply_markup: { inline_keyboard: rows } });
|
|
342
|
+
if (models.length > capped.length) {
|
|
343
|
+
send(chatId, `Hay ${models.length} modelos; mostré 12. Para otro, usá /model <id>.`).catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function sendProviders(chatId) {
|
|
348
|
+
const custom = ctx.customProviderDefs?.() ?? [];
|
|
349
|
+
const ids = [...new Set([...PROVIDER_META.map((p) => p.id), ...custom.map((p) => p.id)])];
|
|
350
|
+
const current = ctx.effectiveProviderId();
|
|
351
|
+
const rows = ids.map((id) => [{ text: `${id === current ? '● ' : ''}${id}`, callback_data: `provider:${id}` }]);
|
|
352
|
+
rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
|
|
353
|
+
await send(chatId, 'Proveedores (elegí uno):', { reply_markup: { inline_keyboard: rows } });
|
|
354
|
+
}
|
|
355
|
+
|
|
247
356
|
async function handleCommand(chatId, text) {
|
|
248
357
|
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
249
358
|
const cmd = raw.toLowerCase();
|
|
250
359
|
const arg = rest.join(' ').trim();
|
|
251
360
|
const sessionId = ctx.store.getSetting(sessionKey(chatId));
|
|
252
361
|
if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
362
|
+
if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
|
|
253
363
|
if (cmd === 'new' || cmd === 'nueva') {
|
|
254
364
|
ctx.store.setSetting(sessionKey(chatId), null);
|
|
255
365
|
return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
|
|
@@ -261,20 +371,20 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
261
371
|
`proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
262
372
|
);
|
|
263
373
|
}
|
|
264
|
-
if (cmd === 'model') {
|
|
265
|
-
if (!arg) return
|
|
374
|
+
if (cmd === 'model' || cmd === 'modelo') {
|
|
375
|
+
if (!arg) return sendModels(chatId);
|
|
266
376
|
ctx.store.setSetting('activeModel', arg);
|
|
267
377
|
if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
|
|
268
378
|
return send(chatId, `modelo: ${arg}`);
|
|
269
379
|
}
|
|
270
|
-
if (cmd === 'provider') {
|
|
271
|
-
if (!arg) return
|
|
380
|
+
if (cmd === 'provider' || cmd === 'proveedor') {
|
|
381
|
+
if (!arg) return sendProviders(chatId);
|
|
272
382
|
ctx.store.setSetting('activeProvider', arg);
|
|
273
383
|
if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
|
|
274
384
|
return send(chatId, `proveedor: ${arg}`);
|
|
275
385
|
}
|
|
276
386
|
if (cmd === 'mode' || cmd === 'modo') {
|
|
277
|
-
if (!arg) return
|
|
387
|
+
if (!arg) return sendMenu(chatId);
|
|
278
388
|
const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
|
|
279
389
|
ctx.store.setSetting('activeMode', resolveMode(id).id);
|
|
280
390
|
return send(chatId, `modo: ${resolveMode(id).label}`);
|
|
@@ -329,10 +439,46 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
|
|
|
329
439
|
}
|
|
330
440
|
|
|
331
441
|
async function handleCallback(query) {
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
442
|
+
const chatId = String(query.message?.chat.id ?? '');
|
|
443
|
+
const [kind, a, b] = String(query.data ?? '').split(':');
|
|
444
|
+
let toast = '';
|
|
445
|
+
|
|
446
|
+
if (kind === 'perm') {
|
|
447
|
+
ctx.approvals.decide(a, b === 'allow');
|
|
448
|
+
toast = b === 'allow' ? 'Permitido' : 'Denegado';
|
|
449
|
+
} else if (kind === 'mode') {
|
|
450
|
+
ctx.store.setSetting('activeMode', resolveMode(a).id);
|
|
451
|
+
toast = `Modo ${resolveMode(a).label}`;
|
|
452
|
+
} else if (kind === 'provider') {
|
|
453
|
+
ctx.store.setSetting('activeProvider', a);
|
|
454
|
+
const sid = ctx.store.getSetting(sessionKey(chatId));
|
|
455
|
+
if (sid) ctx.store.touchSession(sid, { provider: a });
|
|
456
|
+
toast = `Proveedor ${a}`;
|
|
457
|
+
} else if (kind === 'model') {
|
|
458
|
+
const id = (modelChoices.get(chatId) ?? [])[Number(a)];
|
|
459
|
+
if (id) {
|
|
460
|
+
ctx.store.setSetting('activeModel', id);
|
|
461
|
+
const sid = ctx.store.getSetting(sessionKey(chatId));
|
|
462
|
+
if (sid) ctx.store.touchSession(sid, { model: id });
|
|
463
|
+
toast = `Modelo ${id}`;
|
|
464
|
+
}
|
|
465
|
+
} else if (kind === 'menu') {
|
|
466
|
+
if (a === 'models') await sendModels(chatId);
|
|
467
|
+
else if (a === 'providers') await sendProviders(chatId);
|
|
468
|
+
else if (a === 'home') await sendMenu(chatId);
|
|
469
|
+
else if (a === 'new') {
|
|
470
|
+
ctx.store.setSetting(sessionKey(chatId), null);
|
|
471
|
+
await send(chatId, 'Nueva sesión. Mandá tu mensaje.');
|
|
472
|
+
} else if (a === 'stop') {
|
|
473
|
+
controllers.get(chatId)?.abort();
|
|
474
|
+
await send(chatId, 'Cancelando…');
|
|
475
|
+
}
|
|
476
|
+
toast = 'ok';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
await tg(token, 'answerCallbackQuery', { callback_query_id: query.id, text: toast }).catch(() => null);
|
|
480
|
+
// Solo los permisos cierran su tarjeta; el panel conserva los botones.
|
|
481
|
+
if (kind === 'perm' && query.message) {
|
|
336
482
|
await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
|
|
337
483
|
}
|
|
338
484
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Configuracion de los canales de mensajeria desde la consola. La config vive
|
|
2
|
+
// en app.json (casa); al guardar se recargan los canales en caliente.
|
|
3
|
+
|
|
4
|
+
import { readApp, writeApp } from '../lib/core.js';
|
|
5
|
+
import { getTelegramBotInfo } from '../lib/channels/telegram.js';
|
|
6
|
+
import { readJson, sendJson } from '../lib/http.js';
|
|
7
|
+
|
|
8
|
+
function sanitizeAllowed(value) {
|
|
9
|
+
if (Array.isArray(value)) return value.map((v) => String(v).trim()).filter(Boolean);
|
|
10
|
+
return String(value ?? '')
|
|
11
|
+
.split(/[,\s]+/)
|
|
12
|
+
.map((s) => s.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerChannels(route, ctx) {
|
|
17
|
+
route('GET', '/api/channels', ({ res }) => {
|
|
18
|
+
sendJson(res, 200, { telegram: ctx.channels.status() });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
route('PUT', '/api/channels/telegram', async ({ req, res }) => {
|
|
22
|
+
const body = await readJson(req);
|
|
23
|
+
const app = readApp();
|
|
24
|
+
const next = { ...(app.telegram ?? {}) };
|
|
25
|
+
|
|
26
|
+
if ('token' in body) {
|
|
27
|
+
const token = String(body.token ?? '').trim();
|
|
28
|
+
// El cliente puede mandar la mascara (`••••1234`): no pisar el token real.
|
|
29
|
+
if (!token.includes('•')) next.token = token;
|
|
30
|
+
}
|
|
31
|
+
if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
|
|
32
|
+
if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
|
|
33
|
+
if ('transcribeProvider' in body) next.transcribeProvider = String(body.transcribeProvider ?? '').trim();
|
|
34
|
+
|
|
35
|
+
app.telegram = next;
|
|
36
|
+
writeApp(app);
|
|
37
|
+
ctx.app = app;
|
|
38
|
+
ctx.channels.load();
|
|
39
|
+
sendJson(res, 200, { telegram: ctx.channels.status() });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
route('POST', '/api/channels/telegram/test', async ({ req, res }) => {
|
|
43
|
+
const body = await readJson(req).catch(() => ({}));
|
|
44
|
+
let token = String(body.token ?? '').trim();
|
|
45
|
+
if (!token || token.includes('•')) token = ctx.channels.readConfig().token ?? '';
|
|
46
|
+
sendJson(res, 200, await getTelegramBotInfo(token));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -239,6 +239,7 @@ export function registerChat(route, ctx) {
|
|
|
239
239
|
const prefixLength = messages.length;
|
|
240
240
|
|
|
241
241
|
(async () => {
|
|
242
|
+
const startedAt = Date.now();
|
|
242
243
|
const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
|
|
243
244
|
try {
|
|
244
245
|
const result = await runAgent({
|
|
@@ -293,7 +294,9 @@ export function registerChat(route, ctx) {
|
|
|
293
294
|
const title =
|
|
294
295
|
current && (!current.title || current.title === 'Nueva sesión') ? input.slice(0, 60) : undefined;
|
|
295
296
|
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
296
|
-
|
|
297
|
+
const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
|
|
298
|
+
const durationMs = Date.now() - startedAt;
|
|
299
|
+
if (result.stopReason === 'aborted') ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls });
|
|
297
300
|
else
|
|
298
301
|
ctx.runs.emit(sessionId, 'done', {
|
|
299
302
|
sessionId,
|
|
@@ -301,6 +304,8 @@ export function registerChat(route, ctx) {
|
|
|
301
304
|
rounds: result.rounds,
|
|
302
305
|
stopReason: result.stopReason,
|
|
303
306
|
tokens: result.usage ?? null,
|
|
307
|
+
toolCalls,
|
|
308
|
+
durationMs,
|
|
304
309
|
});
|
|
305
310
|
} catch (error) {
|
|
306
311
|
partials.flush();
|
|
@@ -20,7 +20,7 @@ import { skillsPrompt } from './lib/skills.js';
|
|
|
20
20
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
21
21
|
import { createRunManager } from './lib/runManager.js';
|
|
22
22
|
import { createProcManager } from './lib/procs.js';
|
|
23
|
-
import {
|
|
23
|
+
import { createChannelsManager } from './lib/channels/index.js';
|
|
24
24
|
import { createAgentTools } from './lib/agentTools.js';
|
|
25
25
|
import { createStaticHandler } from './static.js';
|
|
26
26
|
import { requireLogin, requireCsrf } from './auth.js';
|
|
@@ -32,6 +32,7 @@ import { registerModels } from './routes/models.js';
|
|
|
32
32
|
import { registerProviders } from './routes/providers.js';
|
|
33
33
|
import { registerMcp } from './routes/mcp.js';
|
|
34
34
|
import { registerSkills } from './routes/skills.js';
|
|
35
|
+
import { registerChannels } from './routes/channels.js';
|
|
35
36
|
import { registerProcs } from './routes/procs.js';
|
|
36
37
|
import { registerPreview } from './routes/preview.js';
|
|
37
38
|
import { registerChat } from './routes/chat.js';
|
|
@@ -171,10 +172,11 @@ export async function createTamnoraServer({
|
|
|
171
172
|
ctx.agentTools = createAgentTools(ctx);
|
|
172
173
|
|
|
173
174
|
// Canales de mensajeria (Telegram via long polling) si hay token.
|
|
174
|
-
const channels =
|
|
175
|
-
|
|
176
|
-
:
|
|
177
|
-
|
|
175
|
+
const channels = createChannelsManager(ctx, {
|
|
176
|
+
enabled: telegram,
|
|
177
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
178
|
+
});
|
|
179
|
+
channels.load();
|
|
178
180
|
ctx.channels = channels;
|
|
179
181
|
|
|
180
182
|
const routes = [];
|
|
@@ -201,6 +203,7 @@ export async function createTamnoraServer({
|
|
|
201
203
|
registerProviders(route, ctx);
|
|
202
204
|
registerMcp(route, ctx);
|
|
203
205
|
registerSkills(route, ctx);
|
|
206
|
+
registerChannels(route, ctx);
|
|
204
207
|
registerProcs(route, ctx);
|
|
205
208
|
const preview = registerPreview(route, ctx);
|
|
206
209
|
registerChat(route, ctx);
|
|
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { createTamnoraServer } from '../src/index.js';
|
|
8
8
|
import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
9
|
+
import { completionFooter } from '../src/lib/channels/telegram.js';
|
|
9
10
|
|
|
10
11
|
const fakeProvider = {
|
|
11
12
|
id: 'fake',
|
|
@@ -224,6 +225,90 @@ test('keeps a run alive after the client disconnects and can stop it', async ()
|
|
|
224
225
|
}
|
|
225
226
|
});
|
|
226
227
|
|
|
228
|
+
test('done reports max_rounds and the tool count when the model never answers', async () => {
|
|
229
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-loop-'));
|
|
230
|
+
await writeFile(path.join(cwd, 'a.txt'), 'hola');
|
|
231
|
+
let seq = 0;
|
|
232
|
+
const provider = {
|
|
233
|
+
id: 'loop',
|
|
234
|
+
async streamChat() {
|
|
235
|
+
seq += 1;
|
|
236
|
+
return {
|
|
237
|
+
content: '',
|
|
238
|
+
reasoning: '',
|
|
239
|
+
toolCalls: [
|
|
240
|
+
{ id: `call_${seq}`, function: { name: 'read', arguments: JSON.stringify({ path: 'a.txt' }) } },
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
246
|
+
try {
|
|
247
|
+
const sse = await (
|
|
248
|
+
await fetch(`${base}/api/chat`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
body: JSON.stringify({ input: 'leé', model: 'm', maxRounds: 2 }),
|
|
252
|
+
})
|
|
253
|
+
).text();
|
|
254
|
+
assert.match(sse, /event: done/);
|
|
255
|
+
assert.match(sse, /"stopReason":"max_rounds"/);
|
|
256
|
+
const match = sse.match(/"toolCalls":(\d+)/);
|
|
257
|
+
assert.ok(match && Number(match[1]) >= 2, 'el done debe reportar las tools usadas');
|
|
258
|
+
assert.match(sse, /"durationMs":\d+/);
|
|
259
|
+
} finally {
|
|
260
|
+
await instance.close();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('completionFooter resume el cierre del turno', () => {
|
|
265
|
+
assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
|
|
266
|
+
assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
|
|
267
|
+
assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ máximo de rondas · 2 rondas · 2 herramientas · 300ms$/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('configures Telegram from the console (masked token)', async () => {
|
|
271
|
+
const { instance, base } = await startServer({ telegram: false });
|
|
272
|
+
const json = { 'Content-Type': 'application/json' };
|
|
273
|
+
try {
|
|
274
|
+
let data = await (await fetch(`${base}/api/channels`)).json();
|
|
275
|
+
assert.equal(data.telegram.tokenSet, false);
|
|
276
|
+
assert.equal(data.telegram.transcribeModel, 'whisper-1');
|
|
277
|
+
|
|
278
|
+
let res = await fetch(`${base}/api/channels/telegram`, {
|
|
279
|
+
method: 'PUT',
|
|
280
|
+
headers: json,
|
|
281
|
+
body: JSON.stringify({ allowedChatIds: '123, 456', transcribeModel: '' }),
|
|
282
|
+
});
|
|
283
|
+
data = await res.json();
|
|
284
|
+
assert.deepEqual(data.telegram.allowedChatIds, ['123', '456']);
|
|
285
|
+
assert.equal(data.telegram.transcribeModel, 'whisper-1');
|
|
286
|
+
|
|
287
|
+
res = await fetch(`${base}/api/channels/telegram`, {
|
|
288
|
+
method: 'PUT',
|
|
289
|
+
headers: json,
|
|
290
|
+
body: JSON.stringify({ token: '123456:ABCDEFghij', transcribeProvider: 'openai' }),
|
|
291
|
+
});
|
|
292
|
+
data = await res.json();
|
|
293
|
+
assert.equal(data.telegram.tokenSet, true);
|
|
294
|
+
assert.equal(data.telegram.transcribeProvider, 'openai');
|
|
295
|
+
assert.match(data.telegram.tokenHint, /ghij$/);
|
|
296
|
+
assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
|
|
297
|
+
|
|
298
|
+
// Mandar la mascara no pisa el token real.
|
|
299
|
+
res = await fetch(`${base}/api/channels/telegram`, {
|
|
300
|
+
method: 'PUT',
|
|
301
|
+
headers: json,
|
|
302
|
+
body: JSON.stringify({ token: data.telegram.tokenHint, allowedChatIds: ['1'] }),
|
|
303
|
+
});
|
|
304
|
+
data = await res.json();
|
|
305
|
+
assert.equal(data.telegram.tokenSet, true);
|
|
306
|
+
assert.match(data.telegram.tokenHint, /ghij$/);
|
|
307
|
+
} finally {
|
|
308
|
+
await instance.close();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
227
312
|
test('manages MCP servers from the console', async () => {
|
|
228
313
|
const { instance, base } = await startServer();
|
|
229
314
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { Store } from '../../core/src/index.js';
|
|
7
|
+
import { createApprovalBroker } from '../src/lib/approvals.js';
|
|
8
|
+
import { startTelegramChannel } from '../src/lib/channels/telegram.js';
|
|
9
|
+
|
|
10
|
+
const reply = (data) => ({ ok: true, json: async () => data });
|
|
11
|
+
|
|
12
|
+
function installFetchMock() {
|
|
13
|
+
const sent = [];
|
|
14
|
+
const updates = [];
|
|
15
|
+
const original = global.fetch;
|
|
16
|
+
global.fetch = async (url, opts = {}) => {
|
|
17
|
+
const method = String(url).split('/').pop();
|
|
18
|
+
const body = opts.body ? JSON.parse(opts.body) : {};
|
|
19
|
+
if (method === 'getUpdates') {
|
|
20
|
+
const out = updates.splice(0, updates.length);
|
|
21
|
+
if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
|
|
22
|
+
return reply({ ok: true, result: out });
|
|
23
|
+
}
|
|
24
|
+
if (method === 'sendMessage' || method === 'editMessageText') {
|
|
25
|
+
sent.push(body);
|
|
26
|
+
return reply({ ok: true, result: { message_id: sent.length } });
|
|
27
|
+
}
|
|
28
|
+
return reply({ ok: true, result: true });
|
|
29
|
+
};
|
|
30
|
+
return { sent, updates, restore: () => { global.fetch = original; } };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeCtx(cwd) {
|
|
34
|
+
const store = new Store(':memory:');
|
|
35
|
+
const provider = {
|
|
36
|
+
id: 'fake',
|
|
37
|
+
async streamChat(_config, { onEvent }) {
|
|
38
|
+
onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
|
|
39
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
cwd,
|
|
44
|
+
config: { maxRounds: 2, permissions: {}, telegram: {} },
|
|
45
|
+
app: {},
|
|
46
|
+
store,
|
|
47
|
+
approvals: createApprovalBroker({ timeoutMs: 1000 }),
|
|
48
|
+
effectiveProviderId: () => 'fake',
|
|
49
|
+
effectiveModel: () => 'm',
|
|
50
|
+
effectiveMode: () => 'agent',
|
|
51
|
+
providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
|
|
52
|
+
apiKeyFor: () => '',
|
|
53
|
+
customProviderDefs: () => [],
|
|
54
|
+
resolveProvider: () => ({ provider, config: {} }),
|
|
55
|
+
getMcp: async () => null,
|
|
56
|
+
systemPromptFor: async () => 'sys',
|
|
57
|
+
agentTools: { definitions: [], handles: () => false, execute: async () => '' },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitFor(fn, timeout = 3000) {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
while (Date.now() - start < timeout) {
|
|
64
|
+
if (fn()) return;
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
66
|
+
}
|
|
67
|
+
throw new Error('timeout esperando la condición');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('el canal de Telegram cierra el turno y muestra el panel', async () => {
|
|
71
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tg-'));
|
|
72
|
+
const ctx = makeCtx(cwd);
|
|
73
|
+
const mock = installFetchMock();
|
|
74
|
+
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
75
|
+
try {
|
|
76
|
+
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
77
|
+
await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
|
|
78
|
+
const completion = mock.sent.find((m) => /✓ terminado/.test(m.text ?? ''));
|
|
79
|
+
assert.match(completion.text, /listo/);
|
|
80
|
+
assert.match(completion.text, /1 ronda/);
|
|
81
|
+
|
|
82
|
+
mock.updates.push({ update_id: 2, message: { chat: { id: 7 }, text: '/menu' } });
|
|
83
|
+
await waitFor(() => mock.sent.some((m) => m.reply_markup?.inline_keyboard));
|
|
84
|
+
const menu = mock.sent.find((m) => m.reply_markup?.inline_keyboard);
|
|
85
|
+
const datas = menu.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
|
|
86
|
+
assert.ok(datas.includes('mode:plan'));
|
|
87
|
+
assert.ok(datas.includes('menu:models'));
|
|
88
|
+
assert.ok(datas.includes('menu:stop'));
|
|
89
|
+
} finally {
|
|
90
|
+
channel.stop();
|
|
91
|
+
mock.restore();
|
|
92
|
+
ctx.store.close();
|
|
93
|
+
}
|
|
94
|
+
});
|
package/package.json
CHANGED