@danieltmn/tamnora 0.9.1 → 0.9.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 +6 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/server/public/app.js +652 -9
- package/harness/packages/server/public/index.html +120 -1
- package/harness/packages/server/public/styles.css +19 -0
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +301 -93
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +9 -0
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Canal de Telegram: long polling (no necesita URL publica) con soporte de
|
|
2
2
|
// texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
|
|
3
|
-
// inline. Cada chat mapea a una sesion del harness
|
|
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).
|
|
4
5
|
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import { randomUUID } from 'node:crypto';
|
|
6
8
|
import { runAgent, createTools, resolveMode, listProviderModels, PROVIDER_META, sintetizar, MAX_TEXTO } from '../core.js';
|
|
7
|
-
import { resolveExistingDir } from '../workspace.js';
|
|
9
|
+
import { resolveExistingDir, listSubdirs } from '../workspace.js';
|
|
8
10
|
import {
|
|
9
11
|
combineToolset,
|
|
10
12
|
composeAgentMessages,
|
|
@@ -15,15 +17,23 @@ import {
|
|
|
15
17
|
import { fraseFin } from '../announcer.js';
|
|
16
18
|
import { mp3AOgg } from '../ffmpeg.js';
|
|
17
19
|
import { resolveTts } from '../ttsSettings.js';
|
|
20
|
+
import { transcribeAudio } from '../transcribe.js';
|
|
18
21
|
|
|
19
22
|
const API = 'https://api.telegram.org';
|
|
23
|
+
const MAX_PROJECTS = 24;
|
|
24
|
+
|
|
25
|
+
const RESUME_INSTRUCTION =
|
|
26
|
+
'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.';
|
|
20
27
|
|
|
21
28
|
const HELP = [
|
|
22
29
|
'Tamnora Harness — bot',
|
|
23
30
|
'',
|
|
31
|
+
'/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
|
|
32
|
+
'/new — nueva sesión (vuelve a preguntar el proyecto)',
|
|
33
|
+
'/resumen — resumen en texto de la conversación',
|
|
34
|
+
'/audioresumen — el resumen como nota de voz',
|
|
24
35
|
'/menu — panel con botones (modelo, modo, proveedor)',
|
|
25
|
-
'/
|
|
26
|
-
'/status — proveedor, modelo, modo y sesión',
|
|
36
|
+
'/status — proveedor, modelo, modo, proyecto y sesión',
|
|
27
37
|
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
28
38
|
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
29
39
|
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
@@ -111,13 +121,30 @@ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
|
|
|
111
121
|
*/
|
|
112
122
|
export function startTelegramChannel(
|
|
113
123
|
ctx,
|
|
114
|
-
{
|
|
124
|
+
{
|
|
125
|
+
token,
|
|
126
|
+
allowedChatIds = [],
|
|
127
|
+
transcribeModel = 'whisper-1',
|
|
128
|
+
transcribeProvider = '',
|
|
129
|
+
notify = {},
|
|
130
|
+
root = '',
|
|
131
|
+
log = () => {},
|
|
132
|
+
} = {}
|
|
115
133
|
) {
|
|
116
134
|
if (!token) return null;
|
|
117
135
|
const allowed = new Set(allowedChatIds.map(String));
|
|
136
|
+
const notifyConfig = {
|
|
137
|
+
enabled: notify?.enabled === true,
|
|
138
|
+
done: notify?.done !== false,
|
|
139
|
+
error: notify?.error !== false,
|
|
140
|
+
permission: notify?.permission !== false,
|
|
141
|
+
agent: notify?.agent !== false,
|
|
142
|
+
};
|
|
118
143
|
const controllers = new Map();
|
|
119
144
|
const ownSessions = new Set();
|
|
120
145
|
const modelChoices = new Map();
|
|
146
|
+
const projectChoices = new Map();
|
|
147
|
+
const pendingInputs = new Map();
|
|
121
148
|
let running = true;
|
|
122
149
|
let offset = 0;
|
|
123
150
|
|
|
@@ -175,52 +202,144 @@ export function startTelegramChannel(
|
|
|
175
202
|
await enviarNotaDeVoz(chatId, texto);
|
|
176
203
|
}
|
|
177
204
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
});
|
|
189
|
-
ctx.store.setSetting(key, sessionId);
|
|
205
|
+
// --- proyectos ------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
|
|
208
|
+
function projectRoot() {
|
|
209
|
+
const stored = ctx.store.getSetting('defaultCwd');
|
|
210
|
+
const candidate = stored || root || ctx.cwd;
|
|
211
|
+
try {
|
|
212
|
+
return resolveExistingDir(candidate);
|
|
213
|
+
} catch {
|
|
214
|
+
return ctx.cwd;
|
|
190
215
|
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function listProjects() {
|
|
219
|
+
try {
|
|
220
|
+
return listSubdirs(projectRoot()).map((dir) => ({ name: dir.name, path: dir.path }));
|
|
221
|
+
} catch {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function projectLabel(session) {
|
|
227
|
+
const cwd = session?.cwd || '';
|
|
228
|
+
return cwd ? path.basename(cwd) : 'sin carpeta';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
|
|
232
|
+
function projectIsOpen(session) {
|
|
233
|
+
const ids = ctx.presence?.ids?.() ?? [];
|
|
234
|
+
if (!ids.length) return false;
|
|
235
|
+
const cwd = session?.cwd || '';
|
|
236
|
+
return ids.some((id) => {
|
|
237
|
+
const open = ctx.store.getSession(id);
|
|
238
|
+
return open && (open.cwd || '') === cwd;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function activeSessionId(chatId) {
|
|
243
|
+
const id = ctx.store.getSetting(sessionKey(chatId));
|
|
244
|
+
if (id && ctx.store.getSession(id)) return id;
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createSessionForProject(chatId, cwd) {
|
|
249
|
+
const sessionId = ctx.store.createSession({
|
|
250
|
+
title: path.basename(cwd) || `Telegram ${chatId}`,
|
|
251
|
+
provider: ctx.effectiveProviderId(),
|
|
252
|
+
model: ctx.effectiveModel(),
|
|
253
|
+
cwd,
|
|
254
|
+
meta: { telegramChatId: String(chatId) },
|
|
255
|
+
});
|
|
256
|
+
ctx.store.setSetting(sessionKey(chatId), sessionId);
|
|
191
257
|
ctx.store.setSetting(reverseKey(sessionId), String(chatId));
|
|
192
258
|
return sessionId;
|
|
193
259
|
}
|
|
194
260
|
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
);
|
|
261
|
+
function resolveProjectArg(arg) {
|
|
262
|
+
const value = String(arg ?? '').trim();
|
|
263
|
+
if (!value) return null;
|
|
264
|
+
if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
|
|
265
|
+
const projects = listProjects();
|
|
266
|
+
const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
|
|
267
|
+
if (exact) return exact.path;
|
|
268
|
+
const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
|
|
269
|
+
return matches.length === 1 ? matches[0].path : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function sendProjectPicker(chatId, { text } = {}) {
|
|
273
|
+
const base = projectRoot();
|
|
274
|
+
const all = listProjects();
|
|
275
|
+
const shown = all.slice(0, MAX_PROJECTS);
|
|
276
|
+
projectChoices.set(String(chatId), shown);
|
|
277
|
+
const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
|
|
278
|
+
rows.push([{ text: `📁 usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
|
|
279
|
+
const intro = text || '¿En qué proyecto vas a trabajar?';
|
|
280
|
+
await send(chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
|
|
281
|
+
if (all.length > shown.length) {
|
|
282
|
+
await send(chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
|
|
217
283
|
}
|
|
218
|
-
|
|
219
|
-
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function selectProject(chatId, projectPath) {
|
|
287
|
+
let cwd;
|
|
288
|
+
try {
|
|
289
|
+
cwd = resolveExistingDir(projectPath);
|
|
290
|
+
} catch (error) {
|
|
291
|
+
return send(chatId, `No pude usar ese proyecto: ${error.message}`);
|
|
292
|
+
}
|
|
293
|
+
createSessionForProject(chatId, cwd);
|
|
294
|
+
const key = String(chatId);
|
|
295
|
+
const pending = pendingInputs.get(key);
|
|
296
|
+
pendingInputs.delete(key);
|
|
297
|
+
await send(chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
|
|
298
|
+
if (pending) run(chatId, pending.input, pending.images).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function transcribe(buffer, filename) {
|
|
302
|
+
return transcribeAudio(ctx, {
|
|
303
|
+
buffer,
|
|
304
|
+
filename,
|
|
305
|
+
providerId: transcribeProvider || undefined,
|
|
306
|
+
model: transcribeModel,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Resumen de la conversación de una sesión (sin tocar el historial). */
|
|
311
|
+
async function resumirSesion(sessionId) {
|
|
312
|
+
const messages = ctx.store.getMessages(sessionId);
|
|
313
|
+
if (!messages.length) throw new Error('La conversación está vacía.');
|
|
314
|
+
const session = ctx.store.getSession(sessionId);
|
|
315
|
+
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
316
|
+
const model = session?.model || ctx.effectiveModel();
|
|
317
|
+
if (!model) throw new Error('Falta el modelo.');
|
|
318
|
+
const transcript = messages
|
|
319
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
320
|
+
.join('\n');
|
|
321
|
+
const { provider, config } = resolveProviderFor(ctx, providerId);
|
|
322
|
+
const result = await runAgent({
|
|
323
|
+
provider,
|
|
324
|
+
config,
|
|
325
|
+
model,
|
|
326
|
+
messages: [
|
|
327
|
+
{ role: 'system', content: RESUME_INSTRUCTION },
|
|
328
|
+
{ role: 'user', content: transcript.slice(0, 60000) },
|
|
329
|
+
],
|
|
330
|
+
tools: [],
|
|
331
|
+
});
|
|
332
|
+
const summary = result.content?.trim();
|
|
333
|
+
if (!summary) throw new Error('El modelo no devolvió un resumen.');
|
|
334
|
+
return { summary };
|
|
220
335
|
}
|
|
221
336
|
|
|
222
337
|
async function run(chatId, input, images = []) {
|
|
223
|
-
const sessionId =
|
|
338
|
+
const sessionId = activeSessionId(chatId);
|
|
339
|
+
if (!sessionId) {
|
|
340
|
+
pendingInputs.set(String(chatId), { input, images });
|
|
341
|
+
return sendProjectPicker(chatId);
|
|
342
|
+
}
|
|
224
343
|
const session = ctx.store.getSession(sessionId);
|
|
225
344
|
const providerId = session.provider || ctx.effectiveProviderId();
|
|
226
345
|
const model = session.model || ctx.effectiveModel();
|
|
@@ -242,7 +361,7 @@ export function startTelegramChannel(
|
|
|
242
361
|
const onAsk = async (name, args) => {
|
|
243
362
|
const askId = randomUUID();
|
|
244
363
|
const preview = JSON.stringify(args ?? {}).slice(0, 300);
|
|
245
|
-
await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
|
|
364
|
+
await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
|
|
246
365
|
reply_markup: {
|
|
247
366
|
inline_keyboard: [
|
|
248
367
|
[
|
|
@@ -352,12 +471,18 @@ export function startTelegramChannel(
|
|
|
352
471
|
}
|
|
353
472
|
|
|
354
473
|
async function sendMenu(chatId) {
|
|
355
|
-
const sessionId =
|
|
356
|
-
const session = ctx.store.getSession(sessionId);
|
|
474
|
+
const sessionId = activeSessionId(chatId);
|
|
475
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
357
476
|
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
358
477
|
const model = session?.model || ctx.effectiveModel() || '—';
|
|
359
478
|
const mode = resolveMode(ctx.effectiveMode());
|
|
360
|
-
const text = [
|
|
479
|
+
const text = [
|
|
480
|
+
'⚙ Panel',
|
|
481
|
+
`proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
|
|
482
|
+
`proveedor: ${providerId}`,
|
|
483
|
+
`modelo: ${model}`,
|
|
484
|
+
`modo: ${mode.label}`,
|
|
485
|
+
].join('\n');
|
|
361
486
|
await send(chatId, text, {
|
|
362
487
|
reply_markup: {
|
|
363
488
|
inline_keyboard: [
|
|
@@ -370,23 +495,25 @@ export function startTelegramChannel(
|
|
|
370
495
|
{ text: 'proveedores', callback_data: 'menu:providers' },
|
|
371
496
|
],
|
|
372
497
|
[
|
|
498
|
+
{ text: '📁 proyecto', callback_data: 'menu:project' },
|
|
373
499
|
{ text: '🆕 nueva', callback_data: 'menu:new' },
|
|
374
|
-
{ text: '⏹ detener', callback_data: 'menu:stop' },
|
|
375
500
|
],
|
|
501
|
+
[{ text: '⏹ detener', callback_data: 'menu:stop' }],
|
|
376
502
|
],
|
|
377
503
|
},
|
|
378
504
|
});
|
|
379
505
|
}
|
|
380
506
|
|
|
381
507
|
async function sendModels(chatId) {
|
|
382
|
-
const sessionId =
|
|
383
|
-
const
|
|
508
|
+
const sessionId = activeSessionId(chatId);
|
|
509
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
510
|
+
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
384
511
|
const models = await listProviderModels({
|
|
385
512
|
...ctx.providerSettings(providerId),
|
|
386
513
|
apiKey: ctx.apiKeyFor(providerId),
|
|
387
514
|
}).catch(() => []);
|
|
388
515
|
|
|
389
|
-
const current =
|
|
516
|
+
const current = session?.model || ctx.effectiveModel();
|
|
390
517
|
if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
|
|
391
518
|
const capped = models.slice(0, 12);
|
|
392
519
|
modelChoices.set(String(chatId), capped.map((m) => m.id));
|
|
@@ -413,20 +540,49 @@ export function startTelegramChannel(
|
|
|
413
540
|
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
414
541
|
const cmd = raw.toLowerCase();
|
|
415
542
|
const arg = rest.join(' ').trim();
|
|
416
|
-
const sessionId =
|
|
417
|
-
if (cmd === 'start'
|
|
543
|
+
const sessionId = activeSessionId(chatId);
|
|
544
|
+
if (cmd === 'start') {
|
|
545
|
+
if (sessionId) {
|
|
546
|
+
return send(chatId, `Hola. Proyecto actual: «${projectLabel(ctx.store.getSession(sessionId))}».\n\n${HELP}`);
|
|
547
|
+
}
|
|
548
|
+
return sendProjectPicker(chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
|
|
549
|
+
}
|
|
550
|
+
if (cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
418
551
|
if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
|
|
419
552
|
if (cmd === 'new' || cmd === 'nueva') {
|
|
420
553
|
ctx.store.setSetting(sessionKey(chatId), null);
|
|
421
|
-
|
|
554
|
+
pendingInputs.delete(String(chatId));
|
|
555
|
+
return sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
556
|
+
}
|
|
557
|
+
if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
|
|
558
|
+
if (!arg) return sendProjectPicker(chatId);
|
|
559
|
+
const projectPath = resolveProjectArg(arg);
|
|
560
|
+
if (!projectPath) {
|
|
561
|
+
return send(chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
|
|
562
|
+
}
|
|
563
|
+
return selectProject(chatId, projectPath);
|
|
422
564
|
}
|
|
423
565
|
if (cmd === 'status') {
|
|
424
566
|
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
425
567
|
return send(
|
|
426
568
|
chatId,
|
|
427
|
-
`
|
|
569
|
+
`proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
428
570
|
);
|
|
429
571
|
}
|
|
572
|
+
if (cmd === 'resumen' || cmd === 'resume') {
|
|
573
|
+
if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
574
|
+
const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
|
|
575
|
+
if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
|
|
576
|
+
return send(chatId, `📝 Resumen\n\n${data.summary}`);
|
|
577
|
+
}
|
|
578
|
+
if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
|
|
579
|
+
if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
580
|
+
await send(chatId, '🎧 Preparando el resumen…');
|
|
581
|
+
const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
|
|
582
|
+
if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
|
|
583
|
+
await send(chatId, `📝 Resumen\n\n${data.summary}`);
|
|
584
|
+
return enviarNotaDeVoz(chatId, data.summary);
|
|
585
|
+
}
|
|
430
586
|
if (cmd === 'model' || cmd === 'modelo') {
|
|
431
587
|
if (!arg) return sendModels(chatId);
|
|
432
588
|
ctx.store.setSetting('activeModel', arg);
|
|
@@ -459,12 +615,21 @@ export function startTelegramChannel(
|
|
|
459
615
|
const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
|
|
460
616
|
if (!text) return send(chatId, 'No pude transcribir el audio.');
|
|
461
617
|
await send(chatId, `📝 ${text}`);
|
|
462
|
-
return
|
|
618
|
+
return deliver(chatId, text);
|
|
463
619
|
} catch (error) {
|
|
464
620
|
return send(chatId, `No pude procesar el audio: ${error.message}`);
|
|
465
621
|
}
|
|
466
622
|
}
|
|
467
623
|
|
|
624
|
+
/** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
|
|
625
|
+
async function deliver(chatId, input, images = []) {
|
|
626
|
+
if (!activeSessionId(chatId)) {
|
|
627
|
+
pendingInputs.set(String(chatId), { input, images });
|
|
628
|
+
return sendProjectPicker(chatId);
|
|
629
|
+
}
|
|
630
|
+
return run(chatId, input, images);
|
|
631
|
+
}
|
|
632
|
+
|
|
468
633
|
async function handleMessage(message) {
|
|
469
634
|
const chatId = String(message.chat.id);
|
|
470
635
|
if (!authorized(chatId)) {
|
|
@@ -485,13 +650,13 @@ export function startTelegramChannel(
|
|
|
485
650
|
const largest = message.photo[message.photo.length - 1];
|
|
486
651
|
const buffer = await downloadFile(token, largest.file_id);
|
|
487
652
|
const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
|
|
488
|
-
await
|
|
653
|
+
await deliver(chatId, text || '(imagen)', [dataUrl]);
|
|
489
654
|
} catch (error) {
|
|
490
655
|
await send(chatId, `No pude procesar la imagen: ${error.message}`);
|
|
491
656
|
}
|
|
492
657
|
return;
|
|
493
658
|
}
|
|
494
|
-
if (text) await
|
|
659
|
+
if (text) await deliver(chatId, text);
|
|
495
660
|
}
|
|
496
661
|
|
|
497
662
|
async function handleCallback(query) {
|
|
@@ -502,19 +667,32 @@ export function startTelegramChannel(
|
|
|
502
667
|
if (kind === 'perm') {
|
|
503
668
|
ctx.approvals.decide(a, b === 'allow');
|
|
504
669
|
toast = b === 'allow' ? 'Permitido' : 'Denegado';
|
|
670
|
+
} else if (kind === 'proj') {
|
|
671
|
+
if (a === 'root') {
|
|
672
|
+
await selectProject(chatId, projectRoot());
|
|
673
|
+
toast = 'Proyecto elegido';
|
|
674
|
+
} else {
|
|
675
|
+
const choice = (projectChoices.get(chatId) ?? [])[Number(a)];
|
|
676
|
+
if (choice) {
|
|
677
|
+
await selectProject(chatId, choice.path);
|
|
678
|
+
toast = `Proyecto ${choice.name}`;
|
|
679
|
+
} else {
|
|
680
|
+
toast = 'Elegí de nuevo';
|
|
681
|
+
}
|
|
682
|
+
}
|
|
505
683
|
} else if (kind === 'mode') {
|
|
506
684
|
ctx.store.setSetting('activeMode', resolveMode(a).id);
|
|
507
685
|
toast = `Modo ${resolveMode(a).label}`;
|
|
508
686
|
} else if (kind === 'provider') {
|
|
509
687
|
ctx.store.setSetting('activeProvider', a);
|
|
510
|
-
const sid =
|
|
688
|
+
const sid = activeSessionId(chatId);
|
|
511
689
|
if (sid) ctx.store.touchSession(sid, { provider: a });
|
|
512
690
|
toast = `Proveedor ${a}`;
|
|
513
691
|
} else if (kind === 'model') {
|
|
514
692
|
const id = (modelChoices.get(chatId) ?? [])[Number(a)];
|
|
515
693
|
if (id) {
|
|
516
694
|
ctx.store.setSetting('activeModel', id);
|
|
517
|
-
const sid =
|
|
695
|
+
const sid = activeSessionId(chatId);
|
|
518
696
|
if (sid) ctx.store.touchSession(sid, { model: id });
|
|
519
697
|
toast = `Modelo ${id}`;
|
|
520
698
|
}
|
|
@@ -522,9 +700,11 @@ export function startTelegramChannel(
|
|
|
522
700
|
if (a === 'models') await sendModels(chatId);
|
|
523
701
|
else if (a === 'providers') await sendProviders(chatId);
|
|
524
702
|
else if (a === 'home') await sendMenu(chatId);
|
|
703
|
+
else if (a === 'project') await sendProjectPicker(chatId, { text: 'Elegí el proyecto:' });
|
|
525
704
|
else if (a === 'new') {
|
|
526
705
|
ctx.store.setSetting(sessionKey(chatId), null);
|
|
527
|
-
|
|
706
|
+
pendingInputs.delete(String(chatId));
|
|
707
|
+
await sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
528
708
|
} else if (a === 'stop') {
|
|
529
709
|
controllers.get(chatId)?.abort();
|
|
530
710
|
await send(chatId, 'Cancelando…');
|
|
@@ -563,6 +743,16 @@ export function startTelegramChannel(
|
|
|
563
743
|
|
|
564
744
|
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
565
745
|
|
|
746
|
+
/** Chats que deben recibir un aviso de una sesión (vínculo explícito o proyecto abierto). */
|
|
747
|
+
function notifyTargets(sessionId) {
|
|
748
|
+
const explicit = ctx.store.getSetting(reverseKey(sessionId));
|
|
749
|
+
if (explicit) return [String(explicit)];
|
|
750
|
+
if (!notifyConfig.enabled) return [];
|
|
751
|
+
const session = ctx.store.getSession(sessionId);
|
|
752
|
+
if (!session || !projectIsOpen(session)) return [];
|
|
753
|
+
return [...allowed];
|
|
754
|
+
}
|
|
755
|
+
|
|
566
756
|
return {
|
|
567
757
|
id: 'telegram',
|
|
568
758
|
stop() {
|
|
@@ -570,45 +760,63 @@ export function startTelegramChannel(
|
|
|
570
760
|
for (const controller of controllers.values()) controller.abort();
|
|
571
761
|
controllers.clear();
|
|
572
762
|
},
|
|
573
|
-
/** Avisa
|
|
763
|
+
/** Avisa sobre un run de otra interfaz (la consola), siempre con el proyecto. */
|
|
574
764
|
notify(sessionId, event) {
|
|
575
765
|
if (ownSessions.has(sessionId)) return;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (event.type === '
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
766
|
+
|
|
767
|
+
// La tool `speak` entrega audio en vivo a un chat vinculado.
|
|
768
|
+
if (event.type === 'tool_call') {
|
|
769
|
+
if (event.data?.name !== 'speak') return;
|
|
770
|
+
const linked = ctx.store.getSetting(reverseKey(sessionId));
|
|
771
|
+
if (linked) {
|
|
772
|
+
enviarNotaDeVoz(linked, event.data?.args?.texto, {
|
|
773
|
+
voice: event.data?.args?.voice,
|
|
774
|
+
rate: event.data?.args?.rate,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const targets = notifyTargets(sessionId);
|
|
781
|
+
if (!targets.length) return;
|
|
782
|
+
const session = ctx.store.getSession(sessionId);
|
|
783
|
+
const project = projectLabel(session);
|
|
784
|
+
|
|
785
|
+
if (event.type === 'done' && notifyConfig.done) {
|
|
786
|
+
const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
|
|
787
|
+
const decir =
|
|
582
788
|
event.data?.say ??
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
)
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
} else if (event.type === 'permission') {
|
|
789
|
+
fraseFin({
|
|
790
|
+
stopReason: event.data?.stopReason,
|
|
791
|
+
rounds: event.data?.rounds,
|
|
792
|
+
toolCalls: event.data?.toolCalls,
|
|
793
|
+
durationMs: event.data?.durationMs,
|
|
794
|
+
});
|
|
795
|
+
for (const chatId of targets) {
|
|
796
|
+
send(chatId, text).catch(() => {});
|
|
797
|
+
sendResumenDeVoz(chatId, `${project}. ${decir}`);
|
|
798
|
+
}
|
|
799
|
+
} else if (event.type === 'error' && notifyConfig.error) {
|
|
800
|
+
const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
|
|
801
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
802
|
+
} else if (event.type === 'agent_end' && notifyConfig.agent) {
|
|
803
|
+
const text = `[${project}] 🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`;
|
|
804
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
805
|
+
} else if (event.type === 'permission' && notifyConfig.permission) {
|
|
600
806
|
const askId = event.data?.askId;
|
|
601
807
|
if (!askId) return;
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
[
|
|
606
|
-
|
|
607
|
-
|
|
808
|
+
for (const chatId of targets) {
|
|
809
|
+
send(chatId, `[${project}] ⚠ Permiso para ${event.data?.name ?? 'una tool'}`, {
|
|
810
|
+
reply_markup: {
|
|
811
|
+
inline_keyboard: [
|
|
812
|
+
[
|
|
813
|
+
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
814
|
+
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
815
|
+
],
|
|
608
816
|
],
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
}
|
|
817
|
+
},
|
|
818
|
+
}).catch(() => {});
|
|
819
|
+
}
|
|
612
820
|
}
|
|
613
821
|
},
|
|
614
822
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Presencia de pestañas abiertas en la consola. El cliente reporta qué sesiones
|
|
2
|
+
// (pestañas) tiene abiertas; el canal de Telegram la usa para saber qué
|
|
3
|
+
// proyectos están "habilitados" para avisar (los abiertos en el chat).
|
|
4
|
+
//
|
|
5
|
+
// Es efímera a propósito: si la consola se cierra no hay un "adiós", así que
|
|
6
|
+
// cada entrada vence por TTL y el cliente la refresca con un latido.
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 90000;
|
|
9
|
+
|
|
10
|
+
/** Tracker en memoria: sessionId -> último latido. */
|
|
11
|
+
export function createPresence({ ttlMs = DEFAULT_TTL_MS, now = Date.now } = {}) {
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
|
|
14
|
+
const alive = (ts) => now() - ts <= ttlMs;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
/** Marca como abiertas (vistas recién) esas sesiones. */
|
|
18
|
+
touch(ids = []) {
|
|
19
|
+
const ts = now();
|
|
20
|
+
for (const id of ids) if (id) seen.set(String(id), ts);
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
/** Marca como cerradas (quita la sesión sin esperar el TTL). */
|
|
24
|
+
close(ids = []) {
|
|
25
|
+
for (const id of ids) seen.delete(String(id));
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
has(sessionId) {
|
|
29
|
+
const id = String(sessionId ?? '');
|
|
30
|
+
const ts = seen.get(id);
|
|
31
|
+
if (ts === undefined) return false;
|
|
32
|
+
if (!alive(ts)) {
|
|
33
|
+
seen.delete(id);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
/** Ids vivos (purga los vencidos de paso). */
|
|
39
|
+
ids() {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const [id, ts] of seen) {
|
|
42
|
+
if (alive(ts)) out.push(id);
|
|
43
|
+
else seen.delete(id);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
},
|
|
47
|
+
clear() {
|
|
48
|
+
seen.clear();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|