@danieltmn/tamnora 0.22.0 → 0.23.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/browser/index.js +27 -13
  12. package/harness/packages/core/src/tts/edge.js +34 -4
  13. package/harness/packages/core/test/agent.test.js +18 -0
  14. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  15. package/harness/packages/core/test/tts.test.js +81 -0
  16. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  17. package/harness/packages/server/public/app.js +4 -3
  18. package/harness/packages/server/public/cards.js +470 -0
  19. package/harness/packages/server/public/chat.js +1343 -1893
  20. package/harness/packages/server/public/chatFormat.js +156 -0
  21. package/harness/packages/server/public/chrome.js +5 -4
  22. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  23. package/harness/packages/server/public/icons/logo_light.png +0 -0
  24. package/harness/packages/server/public/index.html +22 -23
  25. package/harness/packages/server/public/settings.js +4 -0
  26. package/harness/packages/server/public/styles.css +20 -50
  27. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  28. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  29. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  30. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  31. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  32. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  33. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  34. package/harness/packages/server/src/lib/channels/telegram.js +163 -933
  35. package/harness/packages/server/src/lib/scheduler.js +1 -0
  36. package/harness/packages/server/src/lib/tasks.js +1 -0
  37. package/harness/packages/server/src/routes/chat.js +1 -1
  38. package/harness/packages/server/src/routes/providers.js +2 -0
  39. package/harness/packages/server/src/server.js +6 -0
  40. package/harness/packages/server/test/chatFormat.test.js +119 -0
  41. package/harness/packages/server/test/consoleModules.test.js +301 -0
  42. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  43. package/harness/packages/server/test/helpers.js +15 -1
  44. package/harness/packages/server/test/ops.test.js +9 -5
  45. package/harness/packages/server/test/settings.test.js +18 -1
  46. package/harness/scripts/canary.mjs +24 -0
  47. package/package.json +3 -2
@@ -1,933 +1,163 @@
1
- // Canal de Telegram: long polling (no necesita URL publica) con soporte de
2
- // texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
3
- // inline. Cada chat mapea a una sesion del harness, que ahora empieza eligiendo
4
- // proyecto (una carpeta de la raiz configurada en la consola).
5
-
6
- import path from 'node:path';
7
- import { readFile } from 'node:fs/promises';
8
- import { randomUUID } from 'node:crypto';
9
- import {
10
- runAgent,
11
- createTools,
12
- resolveMode,
13
- listProviderModels,
14
- PROVIDER_META,
15
- sintetizar,
16
- MAX_TEXTO,
17
- screenshotsDir,
18
- } from '../core.js';
19
- import { resolveExistingDir, listSubdirs } from '../workspace.js';
20
- import {
21
- combineToolset,
22
- composeAgentMessages,
23
- buildUserMessage,
24
- resolveProviderFor,
25
- toolCallPayload,
26
- } from '../agentRun.js';
27
- import { mp3AOgg } from '../ffmpeg.js';
28
- import { createRulesInjector } from '../rules.js';
29
- import { resolveTts } from '../ttsSettings.js';
30
- import { transcribeAudio } from '../transcribe.js';
31
-
32
- const API = 'https://api.telegram.org';
33
- const MAX_PROJECTS = 24;
34
-
35
- const RESUME_INSTRUCTION =
36
- 'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
37
-
38
- const HELP = [
39
- 'Tamnora Harness — bot',
40
- '',
41
- '/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
42
- '/new — nueva sesión (vuelve a preguntar el proyecto)',
43
- '/resumen — resumen en texto de la conversación',
44
- '/audioresumen — el resumen como nota de voz',
45
- '/menu — panel con botones (modelo, modo, proveedor)',
46
- '/status — proveedor, modelo, modo, proyecto y sesión',
47
- '/model [id] — cambia el modelo (sin id: lista con botones)',
48
- '/provider [id] — cambia el proveedor (sin id: lista)',
49
- '/mode [plan|build] — cambia el modo (sin arg: panel)',
50
- '/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
51
- '/stop — cancela la respuesta en curso',
52
- '/help — esta ayuda',
53
- '',
54
- 'Podés mandar texto, imágenes o notas de voz.',
55
- ].join('\n');
56
-
57
- function humanSize(bytes) {
58
- const n = Number(bytes) || 0;
59
- if (n < 1024) return `${n} B`;
60
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
61
- return `${(n / 1024 / 1024).toFixed(1)} MB`;
62
- }
63
-
64
- function oneLine(value, max = 400) {
65
- const text = String(value ?? '').replace(/\s+/g, ' ').trim();
66
- return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
67
- }
68
-
69
- /**
70
- * Explica en una frase qué va a hacer el agente si se aprueba el permiso, en
71
- * vez de volcar el JSON crudo (que para `write` mostraba medio archivo).
72
- */
73
- export function describePermission(name, args = {}) {
74
- const a = args && typeof args === 'object' ? args : {};
75
- switch (name) {
76
- case 'bash':
77
- return { action: 'ejecutar un comando en el proyecto', detail: oneLine(a.command, 700) };
78
- case 'write':
79
- return {
80
- action: `escribir el archivo ${a.path ?? '(sin ruta)'}`,
81
- detail: a.content ? `Contenido: ${humanSize(Buffer.byteLength(String(a.content)))}` : '',
82
- };
83
- case 'edit':
84
- return {
85
- action: `editar el archivo ${a.path ?? '(sin ruta)'}`,
86
- detail: a.oldString ? `Reemplaza: "${oneLine(a.oldString, 100)}"` : '',
87
- };
88
- case 'git_commit':
89
- return { action: 'hacer un commit', detail: oneLine(a.message, 200) };
90
- case 'screenshot':
91
- return { action: 'sacar una captura de la página', detail: oneLine(a.url, 200) };
92
- case 'console_errors':
93
- return { action: 'revisar la consola de una página', detail: oneLine(a.url, 200) };
94
- case 'fetch_url':
95
- return { action: 'consultar una URL', detail: oneLine(a.url, 200) };
96
- case 'read':
97
- return { action: `leer ${oneLine(a.path, 120) || 'un archivo'}`, detail: '' };
98
- default:
99
- return { action: `usar la herramienta "${name}"`, detail: oneLine(JSON.stringify(a), 200) };
100
- }
101
- }
102
-
103
- function permissionMessage(project, name, args) {
104
- const { action, detail } = describePermission(name, args);
105
- const lines = [`⚠ [${project}] Permiso: el agente quiere ${action}.`];
106
- if (detail) lines.push('', detail);
107
- lines.push('', '¿Qué hacemos?');
108
- return lines.join('\n');
109
- }
110
-
111
- function permissionKeyboard(askId) {
112
- return {
113
- inline_keyboard: [
114
- [
115
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
116
- { text: 'Permitir todo', callback_data: `perm:${askId}:all` },
117
- ],
118
- [{ text: 'Denegar', callback_data: `perm:${askId}:deny` }],
119
- ],
120
- };
121
- }
122
-
123
- async function tg(token, method, payload) {
124
- const res = await fetch(`${API}/bot${token}/${method}`, {
125
- method: 'POST',
126
- headers: { 'Content-Type': 'application/json' },
127
- body: JSON.stringify(payload ?? {}),
128
- });
129
- let data = null;
130
- try {
131
- data = await res.json();
132
- } catch {
133
- data = null;
134
- }
135
- if (!data?.ok) {
136
- const error = new Error(data?.description || `HTTP ${res.status}`);
137
- error.status = res.status;
138
- throw error;
139
- }
140
- return data.result;
141
- }
142
-
143
- /** Valida un token consultando `getMe`. Devuelve `{ ok, username, name }` o `{ ok:false, error }`. */
144
- export async function getTelegramBotInfo(token) {
145
- if (!token) return { ok: false, error: 'Falta el token.' };
146
- try {
147
- const result = await tg(token, 'getMe', {});
148
- return { ok: true, username: result?.username ?? null, name: result?.first_name ?? null };
149
- } catch (error) {
150
- return { ok: false, error: error?.message ?? String(error) };
151
- }
152
- }
153
-
154
- async function downloadFile(token, fileId) {
155
- const file = await tg(token, 'getFile', { file_id: fileId });
156
- const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
157
- if (!res.ok) throw new Error(`no se pudo bajar el archivo (HTTP ${res.status})`);
158
- return Buffer.from(await res.arrayBuffer());
159
- }
160
-
161
- /** Variante multipart de `tg` (sendVoice/sendAudio llevan el archivo adjunto). */
162
- async function tgForm(token, method, form) {
163
- const res = await fetch(`${API}/bot${token}/${method}`, { method: 'POST', body: form });
164
- let data = null;
165
- try {
166
- data = await res.json();
167
- } catch {
168
- data = null;
169
- }
170
- if (!data?.ok) {
171
- const error = new Error(data?.description || `HTTP ${res.status}`);
172
- error.status = res.status;
173
- throw error;
174
- }
175
- return data.result;
176
- }
177
-
178
- function splitMessage(text, max = 3900) {
179
- const value = String(text ?? '').trim() || '(sin texto)';
180
- const parts = [];
181
- for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
182
- return parts;
183
- }
184
-
185
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
186
-
187
- /** Linea de cierre del turno (rondas, herramientas, duración). */
188
- export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
189
- const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
190
- if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
191
- if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
192
- const maxed = ['max_rounds', 'no_progress', 'budget'].includes(stopReason);
193
- return `${maxed ? '⚠ corte de seguridad' : '✓ terminado'} · ${parts.join(' · ')}`;
194
- }
195
-
196
- /**
197
- * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
198
- */
199
- export function startTelegramChannel(
200
- ctx,
201
- {
202
- token,
203
- allowedChatIds = [],
204
- transcribeModel = 'whisper-1',
205
- transcribeProvider = '',
206
- notify = {},
207
- root = '',
208
- log = () => {},
209
- } = {}
210
- ) {
211
- if (!token) return null;
212
- const allowed = new Set(allowedChatIds.map(String));
213
- const notifyConfig = {
214
- enabled: notify?.enabled === true,
215
- done: notify?.done !== false,
216
- error: notify?.error !== false,
217
- permission: notify?.permission !== false,
218
- agent: notify?.agent !== false,
219
- };
220
- const controllers = new Map();
221
- const ownSessions = new Set();
222
- const modelChoices = new Map();
223
- const projectChoices = new Map();
224
- const pendingInputs = new Map();
225
- const askSessions = new Map(); // askId -> sessionId, para "Permitir todo"
226
- let running = true;
227
- let offset = 0;
228
-
229
- const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
230
- const sessionKey = (chatId) => `telegram:chat:${chatId}`;
231
- const reverseKey = (sessionId) => `telegram:session:${sessionId}`;
232
-
233
- async function send(chatId, text, extra = {}) {
234
- let first = null;
235
- for (const chunk of splitMessage(text)) {
236
- const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true, ...extra });
237
- if (!first) first = result;
238
- }
239
- return first;
240
- }
241
-
242
- async function edit(chatId, messageId, text) {
243
- const parts = splitMessage(text);
244
- await tg(token, 'editMessageText', { chat_id: chatId, message_id: messageId, text: parts[0], disable_web_page_preview: true }).catch(() => null);
245
- for (let i = 1; i < parts.length; i++) {
246
- await tg(token, 'sendMessage', { chat_id: chatId, text: parts[i], disable_web_page_preview: true }).catch(() => null);
247
- }
248
- }
249
-
250
- /**
251
- * Nota de voz con un texto cualquiera. Con ffmpeg manda OGG/Opus
252
- * (`sendVoice`, aparece como nota de voz); sin ffmpeg cae a audio MP3.
253
- * Un fallo del TTS no debe romper el run: se loguea y sigue.
254
- */
255
- async function enviarNotaDeVoz(chatId, texto, { voice, rate, pitch } = {}) {
256
- const tts = resolveTts(ctx.app);
257
- const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
258
- if (!frase) return;
259
- try {
260
- const mp3 = await sintetizar(frase, {
261
- voice: voice || tts.voice,
262
- rate: rate || tts.rate,
263
- pitch: pitch || tts.pitch,
264
- });
265
- const ogg = await mp3AOgg(mp3);
266
- const form = new FormData();
267
- form.append('chat_id', chatId);
268
- if (ogg) {
269
- form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'resumen.ogg');
270
- await tgForm(token, 'sendVoice', form);
271
- } else {
272
- form.append('audio', new Blob([mp3], { type: 'audio/mpeg' }), 'resumen.mp3');
273
- await tgForm(token, 'sendAudio', form);
274
- }
275
- } catch (error) {
276
- log(`telegram: nota de voz falló: ${error?.message ?? error}`);
277
- }
278
- }
279
-
280
- /** Manda una imagen (una captura del tool `screenshot`). */
281
- async function enviarFoto(chatId, buffer, caption) {
282
- try {
283
- const form = new FormData();
284
- form.append('chat_id', chatId);
285
- if (caption) form.append('caption', String(caption).slice(0, 1000));
286
- form.append('photo', new Blob([buffer], { type: 'image/png' }), 'captura.png');
287
- await tgForm(token, 'sendPhoto', form);
288
- } catch (error) {
289
- log(`telegram: foto falló: ${error?.message ?? error}`);
290
- }
291
- }
292
-
293
- /** Entrega una captura guardada en la casa (por nombre, sin path traversal). */
294
- async function enviarCaptura(chatId, name, caption) {
295
- const safe = String(name ?? '');
296
- if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(safe)) return;
297
- let buffer;
298
- try {
299
- buffer = await readFile(path.join(screenshotsDir(), safe));
300
- } catch {
301
- return;
302
- }
303
- await enviarFoto(chatId, buffer, caption);
304
- }
305
-
306
- // --- proyectos ------------------------------------------------------------
307
-
308
- /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
309
- function projectRoot() {
310
- const stored = ctx.store.getSetting('defaultCwd');
311
- const candidate = stored || root || ctx.cwd;
312
- try {
313
- return resolveExistingDir(candidate);
314
- } catch {
315
- return ctx.cwd;
316
- }
317
- }
318
-
319
- function listProjects() {
320
- try {
321
- return listSubdirs(projectRoot()).map((dir) => ({ name: dir.name, path: dir.path }));
322
- } catch {
323
- return [];
324
- }
325
- }
326
-
327
- function projectLabel(session) {
328
- const cwd = session?.cwd || '';
329
- return cwd ? path.basename(cwd) : 'sin carpeta';
330
- }
331
-
332
- /** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
333
- function projectIsOpen(session) {
334
- const ids = ctx.presence?.ids?.() ?? [];
335
- if (!ids.length) return false;
336
- const cwd = session?.cwd || '';
337
- return ids.some((id) => {
338
- const open = ctx.store.getSession(id);
339
- return open && (open.cwd || '') === cwd;
340
- });
341
- }
342
-
343
- function activeSessionId(chatId) {
344
- const id = ctx.store.getSetting(sessionKey(chatId));
345
- if (id && ctx.store.getSession(id)) return id;
346
- return null;
347
- }
348
-
349
- function createSessionForProject(chatId, cwd) {
350
- const sessionId = ctx.store.createSession({
351
- title: path.basename(cwd) || `Telegram ${chatId}`,
352
- provider: ctx.effectiveProviderId(),
353
- model: ctx.effectiveModel(),
354
- cwd,
355
- meta: { telegramChatId: String(chatId) },
356
- });
357
- ctx.store.setSetting(sessionKey(chatId), sessionId);
358
- ctx.store.setSetting(reverseKey(sessionId), String(chatId));
359
- return sessionId;
360
- }
361
-
362
- function resolveProjectArg(arg) {
363
- const value = String(arg ?? '').trim();
364
- if (!value) return null;
365
- if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
366
- const projects = listProjects();
367
- const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
368
- if (exact) return exact.path;
369
- const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
370
- return matches.length === 1 ? matches[0].path : null;
371
- }
372
-
373
- async function sendProjectPicker(chatId, { text } = {}) {
374
- const base = projectRoot();
375
- const all = listProjects();
376
- const shown = all.slice(0, MAX_PROJECTS);
377
- projectChoices.set(String(chatId), shown);
378
- const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
379
- rows.push([{ text: `📁 usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
380
- const intro = text || '¿En qué proyecto vas a trabajar?';
381
- await send(chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
382
- if (all.length > shown.length) {
383
- await send(chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
384
- }
385
- }
386
-
387
- async function selectProject(chatId, projectPath) {
388
- let cwd;
389
- try {
390
- cwd = resolveExistingDir(projectPath);
391
- } catch (error) {
392
- return send(chatId, `No pude usar ese proyecto: ${error.message}`);
393
- }
394
- createSessionForProject(chatId, cwd);
395
- const key = String(chatId);
396
- const pending = pendingInputs.get(key);
397
- pendingInputs.delete(key);
398
- await send(chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
399
- if (pending) run(chatId, pending.input, pending.images).catch((error) => log(`telegram: ${error?.message ?? error}`));
400
- }
401
-
402
- function transcribe(buffer, filename) {
403
- return transcribeAudio(ctx, {
404
- buffer,
405
- filename,
406
- providerId: transcribeProvider || undefined,
407
- model: transcribeModel,
408
- });
409
- }
410
-
411
- /** Resumen de la conversación de una sesión (sin tocar el historial). */
412
- async function resumirSesion(sessionId) {
413
- const messages = ctx.store.getMessages(sessionId);
414
- if (!messages.length) throw new Error('La conversación está vacía.');
415
- const session = ctx.store.getSession(sessionId);
416
- const providerId = session?.provider || ctx.effectiveProviderId();
417
- const model = session?.model || ctx.effectiveModel();
418
- if (!model) throw new Error('Falta el modelo.');
419
- const transcript = messages
420
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
421
- .join('\n');
422
- const { provider, config } = resolveProviderFor(ctx, providerId);
423
- const result = await runAgent({
424
- provider,
425
- config,
426
- model,
427
- messages: [
428
- { role: 'system', content: RESUME_INSTRUCTION },
429
- { role: 'user', content: transcript.slice(0, 60000) },
430
- ],
431
- tools: [],
432
- });
433
- const summary = result.content?.trim();
434
- if (!summary) throw new Error('El modelo no devolvió un resumen.');
435
- return { summary };
436
- }
437
-
438
- async function run(chatId, input, images = []) {
439
- const sessionId = activeSessionId(chatId);
440
- if (!sessionId) {
441
- pendingInputs.set(String(chatId), { input, images });
442
- return sendProjectPicker(chatId);
443
- }
444
- const session = ctx.store.getSession(sessionId);
445
- const providerId = session.provider || ctx.effectiveProviderId();
446
- const model = session.model || ctx.effectiveModel();
447
- if (!model) {
448
- await send(chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
449
- return;
450
- }
451
- const mode = resolveMode(ctx.effectiveMode());
452
- const cwd = resolveExistingDir(session.cwd || ctx.cwd);
453
- const history = ctx.store.getMessages(sessionId);
454
-
455
- let mcp = null;
456
- try {
457
- mcp = await ctx.getMcp();
458
- } catch {
459
- mcp = null;
460
- }
461
-
462
- const onAsk = async (name, args) => {
463
- // "Permitir todo" activa el auto-approve de la sesion (compartido con la
464
- // consola): a partir de ahi no se vuelve a preguntar.
465
- if (ctx.isAutoApprove?.(sessionId)) return true;
466
- const askId = randomUUID();
467
- askSessions.set(askId, sessionId);
468
- await send(chatId, permissionMessage(projectLabel(session), name, args), {
469
- reply_markup: permissionKeyboard(askId),
470
- });
471
- return ctx.approvals.ask(askId, { sessionId, name, args });
472
- };
473
-
474
- const local = createTools({
475
- root: cwd,
476
- enabled: mode.tools ?? undefined,
477
- permissions: ctx.config.permissions,
478
- yes: false,
479
- onAsk,
480
- mcp,
481
- mcpMode: ctx.effectiveMcpMode(),
482
- mcpPinned: ctx.effectiveMcpPinned(),
483
- });
484
- const exposeAdmin = !mode.tools;
485
- const admin = ctx.agentTools;
486
- const { definitions, execute } = combineToolset({
487
- local,
488
- admin,
489
- plugins: exposeAdmin ? ctx.plugins : null,
490
- exposeAdmin,
491
- approve: onAsk,
492
- });
493
- const { provider, config } = resolveProviderFor(ctx, providerId);
494
- const userMessage = buildUserMessage(input, images);
495
- const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage });
496
-
497
- const controller = new AbortController();
498
- controllers.set(String(chatId), controller);
499
- ownSessions.add(sessionId);
500
- const startedAt = Date.now();
501
- const message = await send(chatId, '…');
502
- let full = '';
503
- let activity = '';
504
- let lastEdit = 0;
505
-
506
- const render = () => {
507
- const body = full.trim();
508
- const suffix = activity ? `\n\n— ${activity}` : '';
509
- return `${body || '…'}${suffix}`;
510
- };
511
-
512
- try {
513
- await tg(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => null);
514
- const result = await runAgent({
515
- provider,
516
- config,
517
- model,
518
- messages,
519
- tools: definitions,
520
- execute,
521
- toolContext: { cwd, sessionId },
522
- onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
523
- redactExtra: ctx.secretExtra?.() ?? [],
524
- signal: controller.signal,
525
- maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
526
- maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
527
- maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
528
- maxOutputTokens: ctx.effectiveMaxOutputTokens?.() ?? ctx.config.maxOutputTokens ?? 0,
529
- onEvent(event) {
530
- if (event.type === 'text') {
531
- full = event.full;
532
- } else if (event.type === 'tool_call') {
533
- activity = `${event.name} ${toolCallPayload(event).summary}`.trim();
534
- if (event.name === 'speak') {
535
- enviarNotaDeVoz(chatId, event.args?.texto, { voice: event.args?.voice, rate: event.args?.rate, pitch: event.args?.pitch });
536
- }
537
- } else if (event.type === 'tool_result') {
538
- activity = `${event.name} ✓`;
539
- if (event.name === 'screenshot' && event.meta?.screenshot?.name) {
540
- enviarCaptura(chatId, event.meta.screenshot.name, event.meta.screenshot.url);
541
- }
542
- } else {
543
- return;
544
- }
545
- const now = Date.now();
546
- if (now - lastEdit > 1200) {
547
- lastEdit = now;
548
- edit(chatId, message.message_id, render());
549
- }
550
- },
551
- });
552
- const newMessages = result.messages.slice(messages.length);
553
- const toolCalls = newMessages.filter((m) => m.role === 'tool').length;
554
- const durationMs = Date.now() - startedAt;
555
- let body = result.content?.trim() || full.trim();
556
- if (!body) {
557
- if (result.stopReason === 'no_progress') {
558
- body = '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.';
559
- } else if (result.stopReason === 'budget') {
560
- body = '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.';
561
- } else if (result.stopReason === 'max_rounds') {
562
- body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
563
- toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
564
- }`;
565
- } else if (toolCalls) {
566
- body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
567
- } else {
568
- body = '(sin texto)';
569
- }
570
- }
571
- const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
572
- await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
573
- ctx.store.addMessages(sessionId, newMessages);
574
- ctx.store.touchSession(sessionId, { provider: providerId, model });
575
- } catch (error) {
576
- await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
577
- } finally {
578
- controllers.delete(String(chatId));
579
- ownSessions.delete(sessionId);
580
- mcp?.close?.();
581
- }
582
- }
583
-
584
- async function sendMenu(chatId) {
585
- const sessionId = activeSessionId(chatId);
586
- const session = sessionId ? ctx.store.getSession(sessionId) : null;
587
- const providerId = session?.provider || ctx.effectiveProviderId();
588
- const model = session?.model || ctx.effectiveModel() || '—';
589
- const mode = resolveMode(ctx.effectiveMode());
590
- const text = [
591
- '⚙ Panel',
592
- `proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
593
- `proveedor: ${providerId}`,
594
- `modelo: ${model}`,
595
- `modo: ${mode.label}`,
596
- ].join('\n');
597
- await send(chatId, text, {
598
- reply_markup: {
599
- inline_keyboard: [
600
- [
601
- { text: mode.id === 'plan' ? '● Plan' : 'Plan', callback_data: 'mode:plan' },
602
- { text: mode.id === 'agent' ? '● Build' : 'Build', callback_data: 'mode:agent' },
603
- ],
604
- [
605
- { text: 'modelos', callback_data: 'menu:models' },
606
- { text: 'proveedores', callback_data: 'menu:providers' },
607
- ],
608
- [
609
- { text: '📁 proyecto', callback_data: 'menu:project' },
610
- { text: '🆕 nueva', callback_data: 'menu:new' },
611
- ],
612
- [{ text: '⏹ detener', callback_data: 'menu:stop' }],
613
- ],
614
- },
615
- });
616
- }
617
-
618
- async function sendModels(chatId) {
619
- const sessionId = activeSessionId(chatId);
620
- const session = sessionId ? ctx.store.getSession(sessionId) : null;
621
- const providerId = session?.provider || ctx.effectiveProviderId();
622
- const models = await listProviderModels({
623
- ...ctx.providerSettings(providerId),
624
- apiKey: ctx.apiKeyFor(providerId),
625
- }).catch(() => []);
626
-
627
- const current = session?.model || ctx.effectiveModel();
628
- if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
629
- const capped = models.slice(0, 12);
630
- modelChoices.set(String(chatId), capped.map((m) => m.id));
631
- const rows = capped.map((m, i) => [
632
- { text: `${m.id === current ? '● ' : ''}${m.label || m.id}`, callback_data: `model:${i}` },
633
- ]);
634
- rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
635
- await send(chatId, `Modelos de ${providerId} (elegí uno):`, { reply_markup: { inline_keyboard: rows } });
636
- if (models.length > capped.length) {
637
- send(chatId, `Hay ${models.length} modelos; mostré 12. Para otro, usá /model <id>.`).catch(() => {});
638
- }
639
- }
640
-
641
- async function sendProviders(chatId) {
642
- const custom = ctx.customProviderDefs?.() ?? [];
643
- const ids = [...new Set([...PROVIDER_META.map((p) => p.id), ...custom.map((p) => p.id)])];
644
- const current = ctx.effectiveProviderId();
645
- const rows = ids.map((id) => [{ text: `${id === current ? '● ' : ''}${id}`, callback_data: `provider:${id}` }]);
646
- rows.push([{ text: '‹ panel', callback_data: 'menu:home' }]);
647
- await send(chatId, 'Proveedores (elegí uno):', { reply_markup: { inline_keyboard: rows } });
648
- }
649
-
650
- async function handleCommand(chatId, text) {
651
- const [raw, ...rest] = text.slice(1).split(/\s+/);
652
- const cmd = raw.toLowerCase();
653
- const arg = rest.join(' ').trim();
654
- const sessionId = activeSessionId(chatId);
655
- if (cmd === 'start') {
656
- if (sessionId) {
657
- return send(chatId, `Hola. Proyecto actual: «${projectLabel(ctx.store.getSession(sessionId))}».\n\n${HELP}`);
658
- }
659
- return sendProjectPicker(chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
660
- }
661
- if (cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
662
- if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
663
- if (cmd === 'new' || cmd === 'nueva') {
664
- ctx.store.setSetting(sessionKey(chatId), null);
665
- pendingInputs.delete(String(chatId));
666
- return sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
667
- }
668
- if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
669
- if (!arg) return sendProjectPicker(chatId);
670
- const projectPath = resolveProjectArg(arg);
671
- if (!projectPath) {
672
- return send(chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
673
- }
674
- return selectProject(chatId, projectPath);
675
- }
676
- if (cmd === 'status') {
677
- const session = sessionId ? ctx.store.getSession(sessionId) : null;
678
- return send(
679
- chatId,
680
- `proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsolo lo pedido: ${ctx.store.getSetting('strictScope') === true ? 'sí' : 'no'}\nsesión: ${session?.title || '—'}`
681
- );
682
- }
683
- if (cmd === 'resumen' || cmd === 'resume') {
684
- if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
685
- const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
686
- if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
687
- return send(chatId, `📝 Resumen\n\n${data.summary}`);
688
- }
689
- if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
690
- if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
691
- await send(chatId, '🎧 Preparando el resumen…');
692
- const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
693
- if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
694
- await send(chatId, `📝 Resumen\n\n${data.summary}`);
695
- return enviarNotaDeVoz(chatId, data.summary);
696
- }
697
- if (cmd === 'model' || cmd === 'modelo') {
698
- if (!arg) return sendModels(chatId);
699
- ctx.store.setSetting('activeModel', arg);
700
- if (sessionId) ctx.store.touchSession(sessionId, { model: arg });
701
- return send(chatId, `modelo: ${arg}`);
702
- }
703
- if (cmd === 'provider' || cmd === 'proveedor') {
704
- if (!arg) return sendProviders(chatId);
705
- ctx.store.setSetting('activeProvider', arg);
706
- if (sessionId) ctx.store.touchSession(sessionId, { provider: arg });
707
- return send(chatId, `proveedor: ${arg}`);
708
- }
709
- if (cmd === 'mode' || cmd === 'modo') {
710
- if (!arg) return sendMenu(chatId);
711
- const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
712
- ctx.store.setSetting('activeMode', resolveMode(id).id);
713
- return send(chatId, `modo: ${resolveMode(id).label}`);
714
- }
715
- if (cmd === 'stop' || cmd === 'parar') {
716
- controllers.get(String(chatId))?.abort();
717
- return send(chatId, 'Cancelando…');
718
- }
719
- if (cmd === 'estricto' || cmd === 'strict') {
720
- const current = ctx.store.getSetting('strictScope') === true;
721
- const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
722
- ctx.store.setSetting('strictScope', value);
723
- return send(chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
724
- }
725
- return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
726
- }
727
-
728
- async function handleVoice(chatId, voice) {
729
- await send(chatId, '🎧 Transcribiendo…');
730
- try {
731
- const buffer = await downloadFile(token, voice.file_id);
732
- const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
733
- if (!text) return send(chatId, 'No pude transcribir el audio.');
734
- await send(chatId, `📝 ${text}`);
735
- return deliver(chatId, text);
736
- } catch (error) {
737
- return send(chatId, `No pude procesar el audio: ${error.message}`);
738
- }
739
- }
740
-
741
- /** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
742
- async function deliver(chatId, input, images = []) {
743
- if (!activeSessionId(chatId)) {
744
- pendingInputs.set(String(chatId), { input, images });
745
- return sendProjectPicker(chatId);
746
- }
747
- return run(chatId, input, images);
748
- }
749
-
750
- async function handleMessage(message) {
751
- const chatId = String(message.chat.id);
752
- if (!authorized(chatId)) {
753
- await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
754
- return;
755
- }
756
- const text = String(message.text ?? message.caption ?? '').trim();
757
- if (text.startsWith('/')) {
758
- await handleCommand(chatId, text);
759
- return;
760
- }
761
- if (message.voice || message.audio) {
762
- await handleVoice(chatId, message.voice ?? message.audio);
763
- return;
764
- }
765
- if (message.photo?.length) {
766
- try {
767
- const largest = message.photo[message.photo.length - 1];
768
- const buffer = await downloadFile(token, largest.file_id);
769
- const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
770
- await deliver(chatId, text || '(imagen)', [dataUrl]);
771
- } catch (error) {
772
- await send(chatId, `No pude procesar la imagen: ${error.message}`);
773
- }
774
- return;
775
- }
776
- if (text) await deliver(chatId, text);
777
- }
778
-
779
- async function handleCallback(query) {
780
- const chatId = String(query.message?.chat.id ?? '');
781
- const [kind, a, b] = String(query.data ?? '').split(':');
782
- let toast = '';
783
-
784
- if (kind === 'perm') {
785
- const all = b === 'all';
786
- ctx.approvals.decide(a, b === 'allow' || all);
787
- const sessionId = askSessions.get(a);
788
- if (sessionId) askSessions.delete(a);
789
- if (all && sessionId) ctx.setAutoApprove?.(sessionId, true);
790
- toast = all ? 'Permitidos todos' : b === 'allow' ? 'Permitido' : 'Denegado';
791
- } else if (kind === 'proj') {
792
- if (a === 'root') {
793
- await selectProject(chatId, projectRoot());
794
- toast = 'Proyecto elegido';
795
- } else {
796
- const choice = (projectChoices.get(chatId) ?? [])[Number(a)];
797
- if (choice) {
798
- await selectProject(chatId, choice.path);
799
- toast = `Proyecto ${choice.name}`;
800
- } else {
801
- toast = 'Elegí de nuevo';
802
- }
803
- }
804
- } else if (kind === 'mode') {
805
- ctx.store.setSetting('activeMode', resolveMode(a).id);
806
- toast = `Modo ${resolveMode(a).label}`;
807
- } else if (kind === 'provider') {
808
- ctx.store.setSetting('activeProvider', a);
809
- const sid = activeSessionId(chatId);
810
- if (sid) ctx.store.touchSession(sid, { provider: a });
811
- toast = `Proveedor ${a}`;
812
- } else if (kind === 'model') {
813
- const id = (modelChoices.get(chatId) ?? [])[Number(a)];
814
- if (id) {
815
- ctx.store.setSetting('activeModel', id);
816
- const sid = activeSessionId(chatId);
817
- if (sid) ctx.store.touchSession(sid, { model: id });
818
- toast = `Modelo ${id}`;
819
- }
820
- } else if (kind === 'menu') {
821
- if (a === 'models') await sendModels(chatId);
822
- else if (a === 'providers') await sendProviders(chatId);
823
- else if (a === 'home') await sendMenu(chatId);
824
- else if (a === 'project') await sendProjectPicker(chatId, { text: 'Elegí el proyecto:' });
825
- else if (a === 'new') {
826
- ctx.store.setSetting(sessionKey(chatId), null);
827
- pendingInputs.delete(String(chatId));
828
- await sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
829
- } else if (a === 'stop') {
830
- controllers.get(chatId)?.abort();
831
- await send(chatId, 'Cancelando…');
832
- }
833
- toast = 'ok';
834
- }
835
-
836
- await tg(token, 'answerCallbackQuery', { callback_query_id: query.id, text: toast }).catch(() => null);
837
- // Solo los permisos cierran su tarjeta; el panel conserva los botones.
838
- if (kind === 'perm' && query.message) {
839
- await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
840
- }
841
- }
842
-
843
- async function loop() {
844
- while (running) {
845
- let updates = [];
846
- try {
847
- updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message', 'callback_query'] });
848
- } catch (error) {
849
- if (error.status === 401 || error.status === 404) {
850
- log(`telegram: token inválido (${error.message}); se detiene el bot.`);
851
- running = false;
852
- return;
853
- }
854
- await sleep(3000);
855
- continue;
856
- }
857
- for (const update of updates) {
858
- offset = update.update_id + 1;
859
- if (update.message) handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
860
- else if (update.callback_query) handleCallback(update.callback_query).catch((error) => log(`telegram: ${error?.message ?? error}`));
861
- }
862
- }
863
- }
864
-
865
- loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
866
-
867
- /** Chats que deben recibir un aviso de una sesión (vínculo explícito o proyecto abierto). */
868
- function notifyTargets(sessionId) {
869
- const explicit = ctx.store.getSetting(reverseKey(sessionId));
870
- if (explicit) return [String(explicit)];
871
- if (!notifyConfig.enabled) return [];
872
- const session = ctx.store.getSession(sessionId);
873
- if (!session || !projectIsOpen(session)) return [];
874
- return [...allowed];
875
- }
876
-
877
- return {
878
- id: 'telegram',
879
- stop() {
880
- running = false;
881
- for (const controller of controllers.values()) controller.abort();
882
- controllers.clear();
883
- },
884
- /** Avisa sobre un run de otra interfaz (la consola), siempre con el proyecto. */
885
- notify(sessionId, event) {
886
- if (ownSessions.has(sessionId)) return;
887
-
888
- // Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
889
- if (event.type === 'tool_call') {
890
- if (event.data?.name !== 'speak') return;
891
- const linked = ctx.store.getSetting(reverseKey(sessionId));
892
- if (linked) {
893
- enviarNotaDeVoz(linked, event.data?.args?.texto, {
894
- voice: event.data?.args?.voice,
895
- rate: event.data?.args?.rate,
896
- pitch: event.data?.args?.pitch,
897
- });
898
- }
899
- return;
900
- }
901
- if (event.type === 'tool_result' && event.data?.name === 'screenshot') {
902
- const linked = ctx.store.getSetting(reverseKey(sessionId));
903
- const shot = event.data?.meta?.screenshot;
904
- if (linked && shot?.name) enviarCaptura(linked, shot.name, shot.url);
905
- return;
906
- }
907
-
908
- const targets = notifyTargets(sessionId);
909
- if (!targets.length) return;
910
- const session = ctx.store.getSession(sessionId);
911
- const project = projectLabel(session);
912
-
913
- if (event.type === 'done' && notifyConfig.done) {
914
- const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
915
- for (const chatId of targets) send(chatId, text).catch(() => {});
916
- } else if (event.type === 'error' && notifyConfig.error) {
917
- const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
918
- for (const chatId of targets) send(chatId, text).catch(() => {});
919
- } else if (event.type === 'agent_end' && notifyConfig.agent) {
920
- const text = `[${project}] 🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`;
921
- for (const chatId of targets) send(chatId, text).catch(() => {});
922
- } else if (event.type === 'permission' && notifyConfig.permission) {
923
- const askId = event.data?.askId;
924
- if (!askId) return;
925
- if (event.data?.sessionId) askSessions.set(askId, event.data.sessionId);
926
- const text = permissionMessage(project, event.data?.name ?? 'una tool', event.data?.args);
927
- for (const chatId of targets) {
928
- send(chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
929
- }
930
- }
931
- },
932
- };
933
- }
1
+ // Canal de Telegram: long polling (no necesita URL publica) con soporte de
2
+ // texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
3
+ // inline. Cada chat mapea a una sesion del harness, que ahora empieza eligiendo
4
+ // proyecto (una carpeta de la raiz configurada en la consola).
5
+ //
6
+ // La logica vive en los modulos de `telegram/`: este ensambla el estado del
7
+ // canal (`T`), corre el polling y maneja los avisos de otros canales.
8
+
9
+ import { send, tg } from './telegram/api.js';
10
+ import { permissionMessage, permissionKeyboard } from './telegram/permissions.js';
11
+ import { projectIsOpen, projectLabel } from './telegram/projects.js';
12
+ import { enviarCaptura, enviarNotaDeVoz } from './telegram/media.js';
13
+ import { handleCallback, handleMessage } from './telegram/commands.js';
14
+ import { run } from './telegram/run.js';
15
+
16
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
+
18
+ export { describePermission, completionFooter } from './telegram/text.js';
19
+ export { getTelegramBotInfo } from './telegram/api.js';
20
+
21
+ /**
22
+ * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
23
+ */
24
+ export function startTelegramChannel(
25
+ ctx,
26
+ {
27
+ token,
28
+ allowedChatIds = [],
29
+ transcribeModel = 'whisper-1',
30
+ transcribeProvider = '',
31
+ notify = {},
32
+ root = '',
33
+ log = () => {},
34
+ } = {}
35
+ ) {
36
+ if (!token) return null;
37
+ const allowed = new Set(allowedChatIds.map(String));
38
+ const notifyConfig = {
39
+ enabled: notify?.enabled === true,
40
+ done: notify?.done !== false,
41
+ error: notify?.error !== false,
42
+ permission: notify?.permission !== false,
43
+ agent: notify?.agent !== false,
44
+ };
45
+
46
+ // Estado compartido entre los modulos de `telegram/`. Cada funcion recibe `T`
47
+ // como primer parametro y lee/escribe este estado.
48
+ const T = {
49
+ ctx,
50
+ token,
51
+ root,
52
+ log,
53
+ transcribeModel,
54
+ transcribeProvider,
55
+ controllers: new Map(),
56
+ ownSessions: new Set(),
57
+ modelChoices: new Map(),
58
+ projectChoices: new Map(),
59
+ pendingInputs: new Map(),
60
+ askSessions: new Map(), // askId -> sessionId, para "Permitir todo"
61
+ authorized: (chatId) => allowed.size > 0 && allowed.has(String(chatId)),
62
+ sessionKey: (chatId) => `telegram:chat:${chatId}`,
63
+ reverseKey: (sessionId) => `telegram:session:${sessionId}`,
64
+ run,
65
+ };
66
+ let running = true;
67
+ let offset = 0;
68
+
69
+ async function loop() {
70
+ while (running) {
71
+ let updates = [];
72
+ try {
73
+ updates = await tg(T.token, 'getUpdates', {
74
+ offset,
75
+ timeout: 25,
76
+ allowed_updates: ['message', 'callback_query'],
77
+ });
78
+ } catch (error) {
79
+ if (error.status === 401 || error.status === 404) {
80
+ log(`telegram: token invalido (${error.message}); se detiene el bot.`);
81
+ running = false;
82
+ return;
83
+ }
84
+ await sleep(3000);
85
+ continue;
86
+ }
87
+ for (const update of updates) {
88
+ offset = update.update_id + 1;
89
+ if (update.message) handleMessage(T, update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
90
+ else if (update.callback_query) handleCallback(T, update.callback_query).catch((error) => log(`telegram: ${error?.message ?? error}`));
91
+ }
92
+ }
93
+ }
94
+
95
+ loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
96
+
97
+ /** Chats que deben recibir un aviso de una sesion (vinculo explicito o proyecto abierto). */
98
+ function notifyTargets(sessionId) {
99
+ const explicit = ctx.store.getSetting(T.reverseKey(sessionId));
100
+ if (explicit) return [String(explicit)];
101
+ if (!notifyConfig.enabled) return [];
102
+ const session = ctx.store.getSession(sessionId);
103
+ if (!session || !projectIsOpen(T, session)) return [];
104
+ return [...allowed];
105
+ }
106
+
107
+ return {
108
+ id: 'telegram',
109
+ stop() {
110
+ running = false;
111
+ for (const controller of T.controllers.values()) controller.abort();
112
+ T.controllers.clear();
113
+ },
114
+ /** Avisa sobre un run de otra interfaz (la consola), siempre con el proyecto. */
115
+ notify(sessionId, event) {
116
+ if (T.ownSessions.has(sessionId)) return;
117
+
118
+ // Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
119
+ if (event.type === 'tool_call') {
120
+ if (event.data?.name !== 'speak') return;
121
+ const linked = ctx.store.getSetting(T.reverseKey(sessionId));
122
+ if (linked) {
123
+ enviarNotaDeVoz(T, linked, event.data?.args?.texto, {
124
+ voice: event.data?.args?.voice,
125
+ rate: event.data?.args?.rate,
126
+ pitch: event.data?.args?.pitch,
127
+ });
128
+ }
129
+ return;
130
+ }
131
+ if (event.type === 'tool_result' && event.data?.name === 'screenshot') {
132
+ const linked = ctx.store.getSetting(T.reverseKey(sessionId));
133
+ const shot = event.data?.meta?.screenshot;
134
+ if (linked && shot?.name) enviarCaptura(T, linked, shot.name, shot.url);
135
+ return;
136
+ }
137
+
138
+ const targets = notifyTargets(sessionId);
139
+ if (!targets.length) return;
140
+ const session = ctx.store.getSession(sessionId);
141
+ const project = projectLabel(session);
142
+
143
+ if (event.type === 'done' && notifyConfig.done) {
144
+ const text = `[${project}] ✓ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
145
+ for (const chatId of targets) send(T, chatId, text).catch(() => {});
146
+ } else if (event.type === 'error' && notifyConfig.error) {
147
+ const text = `[${project}] ❌ Error en la sesión: ${event.data?.message ?? ''}`;
148
+ for (const chatId of targets) send(T, chatId, text).catch(() => {});
149
+ } else if (event.type === 'agent_end' && notifyConfig.agent) {
150
+ const text = `[${project}] 🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`;
151
+ for (const chatId of targets) send(T, chatId, text).catch(() => {});
152
+ } else if (event.type === 'permission' && notifyConfig.permission) {
153
+ const askId = event.data?.askId;
154
+ if (!askId) return;
155
+ if (event.data?.sessionId) T.askSessions.set(askId, event.data.sessionId);
156
+ const text = permissionMessage(project, event.data?.name ?? 'una tool', event.data?.args);
157
+ for (const chatId of targets) {
158
+ send(T, chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
159
+ }
160
+ }
161
+ },
162
+ };
163
+ }