@danieltmn/tamnora 0.21.2 → 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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +7 -1
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +347 -6757
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -0
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +813 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +40 -41
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1080 -0
- package/harness/packages/server/public/styles.css +45 -52
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -932
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +12 -2
- package/harness/packages/server/src/lib/tasks.js +21 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +16 -5
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +14 -9
- package/harness/packages/server/src/server.js +25 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +121 -0
- package/harness/packages/server/test/ops.test.js +515 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +482 -0
- package/harness/scripts/canary.mjs +24 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +3 -2
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -125,17 +125,19 @@ export function requireCsrf(app, req, res) {
|
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
126
|
// Bloqueo por intentos fallidos (en memoria: el server es un proceso).
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
|
-
// La clave
|
|
129
|
-
//
|
|
128
|
+
// La clave es solo la IP: con `ip|usuario` cada intento con un nombre distinto
|
|
129
|
+
// tenia su propio contador y el bloqueo se esquivaba rotando el username. Y el
|
|
130
|
+
// usuario se ignora a proposito: X-Forwarded-For lo controla el cliente y con
|
|
131
|
+
// un header falsificado se rotaba de IP y se esquivaba el bloqueo.
|
|
130
132
|
const MAX_ATTEMPTS = 5;
|
|
131
133
|
const WINDOW_MS = 15 * 60 * 1000;
|
|
132
134
|
const LOCK_MS = 15 * 60 * 1000;
|
|
133
135
|
const MAX_KEYS = 256;
|
|
134
136
|
const attempts = new Map();
|
|
135
137
|
|
|
136
|
-
function clientKey(req
|
|
138
|
+
function clientKey(req) {
|
|
137
139
|
const ip = (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
138
|
-
return
|
|
140
|
+
return String(ip);
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
function sweepStale(now) {
|
|
@@ -146,18 +148,18 @@ function sweepStale(now) {
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
export function loginLockRemaining(req
|
|
151
|
+
export function loginLockRemaining(req) {
|
|
150
152
|
const now = Date.now();
|
|
151
|
-
const st = attempts.get(clientKey(req
|
|
153
|
+
const st = attempts.get(clientKey(req));
|
|
152
154
|
if (!st || !st.lockUntil) return 0;
|
|
153
155
|
const left = st.lockUntil - now;
|
|
154
156
|
return left > 0 ? Math.ceil(left / 1000) : 0;
|
|
155
157
|
}
|
|
156
158
|
|
|
157
|
-
export function loginRecordFailure(req
|
|
159
|
+
export function loginRecordFailure(req) {
|
|
158
160
|
const now = Date.now();
|
|
159
161
|
sweepStale(now);
|
|
160
|
-
const key = clientKey(req
|
|
162
|
+
const key = clientKey(req);
|
|
161
163
|
let st = attempts.get(key);
|
|
162
164
|
if (!st || (!st.lockUntil && now - st.first > WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
|
|
163
165
|
st.count += 1;
|
|
@@ -165,6 +167,6 @@ export function loginRecordFailure(req, user) {
|
|
|
165
167
|
attempts.set(key, st);
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
export function loginClear(req
|
|
169
|
-
attempts.delete(clientKey(req
|
|
170
|
+
export function loginClear(req) {
|
|
171
|
+
attempts.delete(clientKey(req));
|
|
170
172
|
}
|
|
@@ -49,7 +49,12 @@ export function combineLifecycle(handlers = []) {
|
|
|
49
49
|
for (const handler of active) {
|
|
50
50
|
const out = await Promise.resolve()
|
|
51
51
|
.then(() => handler(event))
|
|
52
|
-
.catch(() =>
|
|
52
|
+
.catch((error) => {
|
|
53
|
+
// El run no se corta por un handler, pero el fallo no puede ser
|
|
54
|
+
// invisible: sin esto un hook/regla roto desaparece sin rastro.
|
|
55
|
+
process.stderr.write(`tamnora: handler de ciclo de vida falló: ${error?.message ?? error}\n`);
|
|
56
|
+
return undefined;
|
|
57
|
+
});
|
|
53
58
|
if (out && typeof out === 'object' && typeof out.inject === 'string' && out.inject.trim()) {
|
|
54
59
|
injects.push(out.inject);
|
|
55
60
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Cliente crudo del API de Telegram. Nada de logica del canal aca: solo la
|
|
2
|
+
// llamada HTTP, con sus variantes (json y multipart) y el manejo de errores.
|
|
3
|
+
|
|
4
|
+
const API = 'https://api.telegram.org';
|
|
5
|
+
|
|
6
|
+
async function tg(token, method, payload) {
|
|
7
|
+
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
8
|
+
method: 'POST',
|
|
9
|
+
headers: { 'Content-Type': 'application/json' },
|
|
10
|
+
body: JSON.stringify(payload ?? {}),
|
|
11
|
+
});
|
|
12
|
+
let data = null;
|
|
13
|
+
try {
|
|
14
|
+
data = await res.json();
|
|
15
|
+
} catch {
|
|
16
|
+
data = null;
|
|
17
|
+
}
|
|
18
|
+
if (!data?.ok) {
|
|
19
|
+
const error = new Error(data?.description || `HTTP ${res.status}`);
|
|
20
|
+
error.status = res.status;
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
return data.result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Variante multipart de `tg` (sendVoice/sendAudio llevan el archivo adjunto). */
|
|
27
|
+
async function tgForm(token, method, form) {
|
|
28
|
+
const res = await fetch(`${API}/bot${token}/${method}`, { method: 'POST', body: form });
|
|
29
|
+
let data = null;
|
|
30
|
+
try {
|
|
31
|
+
data = await res.json();
|
|
32
|
+
} catch {
|
|
33
|
+
data = null;
|
|
34
|
+
}
|
|
35
|
+
if (!data?.ok) {
|
|
36
|
+
const error = new Error(data?.description || `HTTP ${res.status}`);
|
|
37
|
+
error.status = res.status;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
return data.result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function downloadFile(token, fileId) {
|
|
44
|
+
const file = await tg(token, 'getFile', { file_id: fileId });
|
|
45
|
+
const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
|
|
46
|
+
if (!res.ok) throw new Error(`no se pudo bajar el archivo (HTTP ${res.status})`);
|
|
47
|
+
return Buffer.from(await res.arrayBuffer());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Valida un token consultando `getMe`. Devuelve `{ ok, username, name }` o `{ ok:false, error }`. */
|
|
51
|
+
export async function getTelegramBotInfo(token) {
|
|
52
|
+
if (!token) return { ok: false, error: 'Falta el token.' };
|
|
53
|
+
try {
|
|
54
|
+
const result = await tg(token, 'getMe', {});
|
|
55
|
+
return { ok: true, username: result?.username ?? null, name: result?.first_name ?? null };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Telegram corta los mensajes largos: se parten y se mandan de a trozos. */
|
|
62
|
+
function splitMessage(text, max = 3900) {
|
|
63
|
+
const value = String(text ?? '').trim() || '(sin texto)';
|
|
64
|
+
const parts = [];
|
|
65
|
+
for (let i = 0; i < value.length; i += max) parts.push(value.slice(i, i + max));
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Manda `text` a un chat (partido si hace falta) y devuelve el primer mensaje. */
|
|
70
|
+
async function send(T, chatId, text, extra = {}) {
|
|
71
|
+
let first = null;
|
|
72
|
+
for (const chunk of splitMessage(text)) {
|
|
73
|
+
const result = await tg(T.token, 'sendMessage', {
|
|
74
|
+
chat_id: chatId,
|
|
75
|
+
text: chunk,
|
|
76
|
+
disable_web_page_preview: true,
|
|
77
|
+
...extra,
|
|
78
|
+
});
|
|
79
|
+
if (!first) first = result;
|
|
80
|
+
}
|
|
81
|
+
return first;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Edita un mensaje ya enviado (el resto del texto sale como mensajes nuevos). */
|
|
85
|
+
async function edit(T, chatId, messageId, text) {
|
|
86
|
+
const parts = splitMessage(text);
|
|
87
|
+
await tg(T.token, 'editMessageText', {
|
|
88
|
+
chat_id: chatId,
|
|
89
|
+
message_id: messageId,
|
|
90
|
+
text: parts[0],
|
|
91
|
+
disable_web_page_preview: true,
|
|
92
|
+
}).catch(() => null);
|
|
93
|
+
for (let i = 1; i < parts.length; i++) {
|
|
94
|
+
await tg(T.token, 'sendMessage', {
|
|
95
|
+
chat_id: chatId,
|
|
96
|
+
text: parts[i],
|
|
97
|
+
disable_web_page_preview: true,
|
|
98
|
+
}).catch(() => null);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { API, downloadFile, edit, send, splitMessage, tg, tgForm };
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Mensajes entrantes de Telegram: comandos (`/proyecto`, `/model`, ...), notas
|
|
2
|
+
// de voz, fotos y los botones de los paneles.
|
|
3
|
+
|
|
4
|
+
import { resolveMode, listProviderModels, PROVIDER_META } from '../../core.js';
|
|
5
|
+
import { downloadFile, send, tg } from './api.js';
|
|
6
|
+
import { enviarNotaDeVoz } from './media.js';
|
|
7
|
+
import {
|
|
8
|
+
activeSessionId,
|
|
9
|
+
projectLabel,
|
|
10
|
+
projectRoot,
|
|
11
|
+
resolveProjectArg,
|
|
12
|
+
selectProject,
|
|
13
|
+
sendProjectPicker,
|
|
14
|
+
} from './projects.js';
|
|
15
|
+
import { resumirSesion, run } from './run.js';
|
|
16
|
+
import { transcribeAudio } from '../../transcribe.js';
|
|
17
|
+
|
|
18
|
+
const HELP = [
|
|
19
|
+
'Tamnora Harness — bot',
|
|
20
|
+
'',
|
|
21
|
+
'/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
|
|
22
|
+
'/new — nueva sesión (vuelve a preguntar el proyecto)',
|
|
23
|
+
'/resumen — resumen en texto de la conversación',
|
|
24
|
+
'/audioresumen — el resumen como nota de voz',
|
|
25
|
+
'/menu — panel con botones (modelo, modo, proveedor)',
|
|
26
|
+
'/status — proveedor, modelo, modo, proyecto y sesión',
|
|
27
|
+
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
28
|
+
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
29
|
+
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
30
|
+
'/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
|
|
31
|
+
'/stop — cancela la respuesta en curso',
|
|
32
|
+
'/help — esta ayuda',
|
|
33
|
+
'',
|
|
34
|
+
'Podés mandar texto, imágenes o notas de voz.',
|
|
35
|
+
].join('\n');
|
|
36
|
+
|
|
37
|
+
function transcribe(T, buffer, filename) {
|
|
38
|
+
return transcribeAudio(T.ctx, {
|
|
39
|
+
buffer,
|
|
40
|
+
filename,
|
|
41
|
+
providerId: T.transcribeProvider || undefined,
|
|
42
|
+
model: T.transcribeModel,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function sendMenu(T, chatId) {
|
|
47
|
+
const sessionId = activeSessionId(T, chatId);
|
|
48
|
+
const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
|
|
49
|
+
const providerId = session?.provider || T.ctx.effectiveProviderId();
|
|
50
|
+
const model = session?.model || T.ctx.effectiveModel() || '-';
|
|
51
|
+
const mode = resolveMode(T.ctx.effectiveMode());
|
|
52
|
+
const text = [
|
|
53
|
+
'⚙ Panel',
|
|
54
|
+
`proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
|
|
55
|
+
`proveedor: ${providerId}`,
|
|
56
|
+
`modelo: ${model}`,
|
|
57
|
+
`modo: ${mode.label}`,
|
|
58
|
+
].join('\n');
|
|
59
|
+
await send(T, chatId, text, {
|
|
60
|
+
reply_markup: {
|
|
61
|
+
inline_keyboard: [
|
|
62
|
+
[
|
|
63
|
+
{ text: mode.id === 'plan' ? '⚙ Plan' : 'Plan', callback_data: 'mode:plan' },
|
|
64
|
+
{ text: mode.id === 'agent' ? '⚙ Build' : 'Build', callback_data: 'mode:agent' },
|
|
65
|
+
],
|
|
66
|
+
[
|
|
67
|
+
{ text: 'modelos', callback_data: 'menu:models' },
|
|
68
|
+
{ text: 'proveedores', callback_data: 'menu:providers' },
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
{ text: '?? proyecto', callback_data: 'menu:project' },
|
|
72
|
+
{ text: '?? nueva', callback_data: 'menu:new' },
|
|
73
|
+
],
|
|
74
|
+
[{ text: '⚙ detener', callback_data: 'menu:stop' }],
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function sendModels(T, chatId) {
|
|
81
|
+
const sessionId = activeSessionId(T, chatId);
|
|
82
|
+
const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
|
|
83
|
+
const providerId = session?.provider || T.ctx.effectiveProviderId();
|
|
84
|
+
const models = await listProviderModels({
|
|
85
|
+
...T.ctx.providerSettings(providerId),
|
|
86
|
+
apiKey: T.ctx.apiKeyFor(providerId),
|
|
87
|
+
}).catch(() => []);
|
|
88
|
+
|
|
89
|
+
const current = session?.model || T.ctx.effectiveModel();
|
|
90
|
+
if (!models?.length) return send(T, chatId, 'No pude listar modelos. Usá /model <id>.');
|
|
91
|
+
const capped = models.slice(0, 12);
|
|
92
|
+
T.modelChoices.set(String(chatId), capped.map((m) => m.id));
|
|
93
|
+
const rows = capped.map((m, i) => [
|
|
94
|
+
{ text: `${m.id === current ? '? ' : ''}${m.label || m.id}`, callback_data: `model:${i}` },
|
|
95
|
+
]);
|
|
96
|
+
rows.push([{ text: '< panel', callback_data: 'menu:home' }]);
|
|
97
|
+
await send(T, chatId, `Modelos de ${providerId} (elegí uno):`, { reply_markup: { inline_keyboard: rows } });
|
|
98
|
+
if (models.length > capped.length) {
|
|
99
|
+
send(T, chatId, `Hay ${models.length} modelos; mostré 12. Para otro, usá /model <id>.`).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function sendProviders(T, chatId) {
|
|
104
|
+
const custom = T.ctx.customProviderDefs?.() ?? [];
|
|
105
|
+
const ids = [...new Set([...PROVIDER_META.map((p) => p.id), ...custom.map((p) => p.id)])];
|
|
106
|
+
const current = T.ctx.effectiveProviderId();
|
|
107
|
+
const rows = ids.map((id) => [{ text: `${id === current ? '? ' : ''}${id}`, callback_data: `provider:${id}` }]);
|
|
108
|
+
rows.push([{ text: '< panel', callback_data: 'menu:home' }]);
|
|
109
|
+
await send(T, chatId, 'Proveedores (elegí uno):', { reply_markup: { inline_keyboard: rows } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function handleCommand(T, chatId, text) {
|
|
113
|
+
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
114
|
+
const cmd = raw.toLowerCase();
|
|
115
|
+
const arg = rest.join(' ').trim();
|
|
116
|
+
const sessionId = activeSessionId(T, chatId);
|
|
117
|
+
if (cmd === 'start') {
|
|
118
|
+
if (sessionId) {
|
|
119
|
+
return send(T, chatId, `Hola. Proyecto actual: «${projectLabel(T.ctx.store.getSession(sessionId))}».\n\n${HELP}`);
|
|
120
|
+
}
|
|
121
|
+
return sendProjectPicker(T, chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
|
|
122
|
+
}
|
|
123
|
+
if (cmd === 'help' || cmd === 'ayuda') return send(T, chatId, HELP);
|
|
124
|
+
if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(T, chatId);
|
|
125
|
+
if (cmd === 'new' || cmd === 'nueva') {
|
|
126
|
+
T.ctx.store.setSetting(T.sessionKey(chatId), null);
|
|
127
|
+
T.pendingInputs.delete(String(chatId));
|
|
128
|
+
return sendProjectPicker(T, chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
129
|
+
}
|
|
130
|
+
if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
|
|
131
|
+
if (!arg) return sendProjectPicker(T, chatId);
|
|
132
|
+
const projectPath = resolveProjectArg(T, arg);
|
|
133
|
+
if (!projectPath) {
|
|
134
|
+
return send(T, chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
|
|
135
|
+
}
|
|
136
|
+
return selectProject(T, chatId, projectPath);
|
|
137
|
+
}
|
|
138
|
+
if (cmd === 'status') {
|
|
139
|
+
const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
|
|
140
|
+
return send(
|
|
141
|
+
T,
|
|
142
|
+
chatId,
|
|
143
|
+
`proyecto: ${session ? projectLabel(session) : '-'}\nproveedor: ${session?.provider || T.ctx.effectiveProviderId()}\nmodelo: ${session?.model || T.ctx.effectiveModel() || '-'}\nmodo: ${T.ctx.effectiveMode()}\nsolo lo pedido: ${T.ctx.store.getSetting('strictScope') === true ? 'sí' : 'no'}\nsesión: ${session?.title || '-'}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (cmd === 'resumen' || cmd === 'resume') {
|
|
147
|
+
if (!sessionId) return send(T, chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
148
|
+
const data = await resumirSesion(T, sessionId).catch((error) => ({ error: error.message }));
|
|
149
|
+
if (data.error) return send(T, chatId, `No pude resumir: ${data.error}`);
|
|
150
|
+
return send(T, chatId, `?📝 Resumen\n\n${data.summary}`);
|
|
151
|
+
}
|
|
152
|
+
if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
|
|
153
|
+
if (!sessionId) return send(T, chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
154
|
+
await send(T, chatId, '?📝 Preparando el resumen.');
|
|
155
|
+
const data = await resumirSesion(T, sessionId).catch((error) => ({ error: error.message }));
|
|
156
|
+
if (data.error) return send(T, chatId, `No pude resumir: ${data.error}`);
|
|
157
|
+
await send(T, chatId, `?📝 Resumen\n\n${data.summary}`);
|
|
158
|
+
return enviarNotaDeVoz(T, chatId, data.summary);
|
|
159
|
+
}
|
|
160
|
+
if (cmd === 'model' || cmd === 'modelo') {
|
|
161
|
+
if (!arg) return sendModels(T, chatId);
|
|
162
|
+
T.ctx.store.setSetting('activeModel', arg);
|
|
163
|
+
if (sessionId) T.ctx.store.touchSession(sessionId, { model: arg });
|
|
164
|
+
return send(T, chatId, `modelo: ${arg}`);
|
|
165
|
+
}
|
|
166
|
+
if (cmd === 'provider' || cmd === 'proveedor') {
|
|
167
|
+
if (!arg) return sendProviders(T, chatId);
|
|
168
|
+
T.ctx.store.setSetting('activeProvider', arg);
|
|
169
|
+
if (sessionId) T.ctx.store.touchSession(sessionId, { provider: arg });
|
|
170
|
+
return send(T, chatId, `proveedor: ${arg}`);
|
|
171
|
+
}
|
|
172
|
+
if (cmd === 'mode' || cmd === 'modo') {
|
|
173
|
+
if (!arg) return sendMenu(T, chatId);
|
|
174
|
+
const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
|
|
175
|
+
T.ctx.store.setSetting('activeMode', resolveMode(id).id);
|
|
176
|
+
return send(T, chatId, `modo: ${resolveMode(id).label}`);
|
|
177
|
+
}
|
|
178
|
+
if (cmd === 'stop' || cmd === 'parar') {
|
|
179
|
+
T.controllers.get(String(chatId))?.abort();
|
|
180
|
+
return send(T, chatId, 'Cancelando.');
|
|
181
|
+
}
|
|
182
|
+
if (cmd === 'estricto' || cmd === 'strict') {
|
|
183
|
+
const current = T.ctx.store.getSetting('strictScope') === true;
|
|
184
|
+
const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
|
|
185
|
+
T.ctx.store.setSetting('strictScope', value);
|
|
186
|
+
return send(T, chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
|
|
187
|
+
}
|
|
188
|
+
return send(T, chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function handleVoice(T, chatId, voice) {
|
|
192
|
+
await send(T, chatId, '?📝 Transcribiendo.');
|
|
193
|
+
try {
|
|
194
|
+
const buffer = await downloadFile(T.token, voice.file_id);
|
|
195
|
+
const text = await transcribe(T, buffer, voice.file_name || 'audio.ogg');
|
|
196
|
+
if (!text) return send(T, chatId, 'No pude transcribir el audio.');
|
|
197
|
+
await send(T, chatId, `?? ${text}`);
|
|
198
|
+
return deliver(T, chatId, text);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return send(T, chatId, `No pude procesar el audio: ${error.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
|
|
205
|
+
export async function deliver(T, chatId, input, images = []) {
|
|
206
|
+
if (!activeSessionId(T, chatId)) {
|
|
207
|
+
T.pendingInputs.set(String(chatId), { input, images });
|
|
208
|
+
return sendProjectPicker(T, chatId);
|
|
209
|
+
}
|
|
210
|
+
return run(T, chatId, input, images);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function handleMessage(T, message) {
|
|
214
|
+
const chatId = String(message.chat.id);
|
|
215
|
+
if (!T.authorized(chatId)) {
|
|
216
|
+
await send(T, chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const text = String(message.text ?? message.caption ?? '').trim();
|
|
220
|
+
if (text.startsWith('/')) {
|
|
221
|
+
await handleCommand(T, chatId, text);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (message.voice || message.audio) {
|
|
225
|
+
await handleVoice(T, chatId, message.voice ?? message.audio);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (message.photo?.length) {
|
|
229
|
+
try {
|
|
230
|
+
const largest = message.photo[message.photo.length - 1];
|
|
231
|
+
const buffer = await downloadFile(T.token, largest.file_id);
|
|
232
|
+
const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
|
|
233
|
+
await deliver(T, chatId, text || '(imagen)', [dataUrl]);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
await send(T, chatId, `No pude procesar la imagen: ${error.message}`);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (text) await deliver(T, chatId, text);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function handleCallback(T, query) {
|
|
243
|
+
const chatId = String(query.message?.chat.id ?? '');
|
|
244
|
+
const [kind, a, b] = String(query.data ?? '').split(':');
|
|
245
|
+
let toast = '';
|
|
246
|
+
|
|
247
|
+
if (kind === 'perm') {
|
|
248
|
+
const all = b === 'all';
|
|
249
|
+
T.ctx.approvals.decide(a, b === 'allow' || all);
|
|
250
|
+
const sessionId = T.askSessions.get(a);
|
|
251
|
+
if (sessionId) T.askSessions.delete(a);
|
|
252
|
+
if (all && sessionId) T.ctx.setAutoApprove?.(sessionId, true);
|
|
253
|
+
toast = all ? 'Permitidos todos' : b === 'allow' ? 'Permitido' : 'Denegado';
|
|
254
|
+
} else if (kind === 'proj') {
|
|
255
|
+
if (a === 'root') {
|
|
256
|
+
await selectProject(T, chatId, projectRoot(T));
|
|
257
|
+
toast = 'Proyecto elegido';
|
|
258
|
+
} else {
|
|
259
|
+
const choice = (T.projectChoices.get(chatId) ?? [])[Number(a)];
|
|
260
|
+
if (choice) {
|
|
261
|
+
await selectProject(T, chatId, choice.path);
|
|
262
|
+
toast = `Proyecto ${choice.name}`;
|
|
263
|
+
} else {
|
|
264
|
+
toast = 'Elegí de nuevo';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else if (kind === 'mode') {
|
|
268
|
+
T.ctx.store.setSetting('activeMode', resolveMode(a).id);
|
|
269
|
+
toast = `Modo ${resolveMode(a).label}`;
|
|
270
|
+
} else if (kind === 'provider') {
|
|
271
|
+
T.ctx.store.setSetting('activeProvider', a);
|
|
272
|
+
const sid = activeSessionId(T, chatId);
|
|
273
|
+
if (sid) T.ctx.store.touchSession(sid, { provider: a });
|
|
274
|
+
toast = `Proveedor ${a}`;
|
|
275
|
+
} else if (kind === 'model') {
|
|
276
|
+
const id = (T.modelChoices.get(chatId) ?? [])[Number(a)];
|
|
277
|
+
if (id) {
|
|
278
|
+
T.ctx.store.setSetting('activeModel', id);
|
|
279
|
+
const sid = activeSessionId(T, chatId);
|
|
280
|
+
if (sid) T.ctx.store.touchSession(sid, { model: id });
|
|
281
|
+
toast = `Modelo ${id}`;
|
|
282
|
+
}
|
|
283
|
+
} else if (kind === 'menu') {
|
|
284
|
+
if (a === 'models') await sendModels(T, chatId);
|
|
285
|
+
else if (a === 'providers') await sendProviders(T, chatId);
|
|
286
|
+
else if (a === 'home') await sendMenu(T, chatId);
|
|
287
|
+
else if (a === 'project') await sendProjectPicker(T, chatId, { text: 'Elegí el proyecto:' });
|
|
288
|
+
else if (a === 'new') {
|
|
289
|
+
T.ctx.store.setSetting(T.sessionKey(chatId), null);
|
|
290
|
+
T.pendingInputs.delete(String(chatId));
|
|
291
|
+
await sendProjectPicker(T, chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
292
|
+
} else if (a === 'stop') {
|
|
293
|
+
T.controllers.get(chatId)?.abort();
|
|
294
|
+
await send(T, chatId, 'Cancelando.');
|
|
295
|
+
}
|
|
296
|
+
toast = 'ok';
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await tg(T.token, 'answerCallbackQuery', { callback_query_id: query.id, text: toast }).catch(() => null);
|
|
300
|
+
// Solo los permisos cierran su tarjeta; el panel conserva los botones.
|
|
301
|
+
if (kind === 'perm' && query.message) {
|
|
302
|
+
await tg(T.token, 'editMessageReplyMarkup', {
|
|
303
|
+
chat_id: query.message.chat.id,
|
|
304
|
+
message_id: query.message.message_id,
|
|
305
|
+
}).catch(() => null);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export { HELP };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Entrega de multimedia a un chat: fotos (capturas del tool `screenshot`) y
|
|
2
|
+
// notas de voz (TTS). Un fallo del servicio no debe romper el run.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { sintetizar, MAX_TEXTO, screenshotsDir } from '../../core.js';
|
|
7
|
+
import { mp3AOgg } from '../../ffmpeg.js';
|
|
8
|
+
import { resolveTts } from '../../ttsSettings.js';
|
|
9
|
+
import { tgForm } from './api.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Nota de voz con un texto cualquiera. Con ffmpeg manda OGG/Opus
|
|
13
|
+
* (`sendVoice`, aparece como nota de voz); sin ffmpeg cae a audio MP3.
|
|
14
|
+
*/
|
|
15
|
+
export async function enviarNotaDeVoz(T, chatId, texto, { voice, rate, pitch } = {}) {
|
|
16
|
+
const tts = resolveTts(T.ctx.app);
|
|
17
|
+
const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
|
|
18
|
+
if (!frase) return;
|
|
19
|
+
try {
|
|
20
|
+
const mp3 = await sintetizar(frase, {
|
|
21
|
+
voice: voice || tts.voice,
|
|
22
|
+
rate: rate || tts.rate,
|
|
23
|
+
pitch: pitch || tts.pitch,
|
|
24
|
+
});
|
|
25
|
+
const ogg = await mp3AOgg(mp3);
|
|
26
|
+
const form = new FormData();
|
|
27
|
+
form.append('chat_id', chatId);
|
|
28
|
+
if (ogg) {
|
|
29
|
+
form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'resumen.ogg');
|
|
30
|
+
await tgForm(T.token, 'sendVoice', form);
|
|
31
|
+
} else {
|
|
32
|
+
form.append('audio', new Blob([mp3], { type: 'audio/mpeg' }), 'resumen.mp3');
|
|
33
|
+
await tgForm(T.token, 'sendAudio', form);
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
T.log(`telegram: nota de voz falló: ${error?.message ?? error}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Manda una imagen (una captura del tool `screenshot`). */
|
|
41
|
+
export async function enviarFoto(T, chatId, buffer, caption) {
|
|
42
|
+
try {
|
|
43
|
+
const form = new FormData();
|
|
44
|
+
form.append('chat_id', chatId);
|
|
45
|
+
if (caption) form.append('caption', String(caption).slice(0, 1000));
|
|
46
|
+
form.append('photo', new Blob([buffer], { type: 'image/png' }), 'captura.png');
|
|
47
|
+
await tgForm(T.token, 'sendPhoto', form);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
T.log(`telegram: foto falló: ${error?.message ?? error}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Entrega una captura guardada en la casa (por nombre, sin path traversal). */
|
|
54
|
+
export async function enviarCaptura(T, chatId, name, caption) {
|
|
55
|
+
const safe = String(name ?? '');
|
|
56
|
+
if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(safe)) return;
|
|
57
|
+
let buffer;
|
|
58
|
+
try {
|
|
59
|
+
buffer = await readFile(path.join(screenshotsDir(), safe));
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await enviarFoto(T, chatId, buffer, caption);
|
|
64
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Elegir proyecto: un chat de Telegram arranca eligiendo en qué carpeta de la
|
|
2
|
+
// raíz trabajar, y cada proyecto tiene su propia sesión del harness.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { resolveExistingDir, listSubdirs } from '../../workspace.js';
|
|
6
|
+
import { send } from './api.js';
|
|
7
|
+
|
|
8
|
+
export const MAX_PROJECTS = 24;
|
|
9
|
+
|
|
10
|
+
/** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
|
|
11
|
+
export function projectRoot(T) {
|
|
12
|
+
const stored = T.ctx.store.getSetting('defaultCwd');
|
|
13
|
+
const candidate = stored || T.root || T.ctx.cwd;
|
|
14
|
+
try {
|
|
15
|
+
return resolveExistingDir(candidate);
|
|
16
|
+
} catch {
|
|
17
|
+
return T.ctx.cwd;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function listProjects(T) {
|
|
22
|
+
try {
|
|
23
|
+
return listSubdirs(projectRoot(T)).map((dir) => ({ name: dir.name, path: dir.path }));
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function projectLabel(session) {
|
|
30
|
+
const cwd = session?.cwd || '';
|
|
31
|
+
return cwd ? path.basename(cwd) : 'sin carpeta';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
|
|
35
|
+
export function projectIsOpen(T, session) {
|
|
36
|
+
const ids = T.ctx.presence?.ids?.() ?? [];
|
|
37
|
+
if (!ids.length) return false;
|
|
38
|
+
const cwd = session?.cwd || '';
|
|
39
|
+
return ids.some((id) => {
|
|
40
|
+
const open = T.ctx.store.getSession(id);
|
|
41
|
+
return open && (open.cwd || '') === cwd;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function activeSessionId(T, chatId) {
|
|
46
|
+
const id = T.ctx.store.getSetting(T.sessionKey(chatId));
|
|
47
|
+
if (id && T.ctx.store.getSession(id)) return id;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSessionForProject(T, chatId, cwd) {
|
|
52
|
+
const sessionId = T.ctx.store.createSession({
|
|
53
|
+
title: path.basename(cwd) || `Telegram ${chatId}`,
|
|
54
|
+
provider: T.ctx.effectiveProviderId(),
|
|
55
|
+
model: T.ctx.effectiveModel(),
|
|
56
|
+
cwd,
|
|
57
|
+
meta: { telegramChatId: String(chatId) },
|
|
58
|
+
});
|
|
59
|
+
T.ctx.store.setSetting(T.sessionKey(chatId), sessionId);
|
|
60
|
+
T.ctx.store.setSetting(T.reverseKey(sessionId), String(chatId));
|
|
61
|
+
return sessionId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function resolveProjectArg(T, arg) {
|
|
65
|
+
const value = String(arg ?? '').trim();
|
|
66
|
+
if (!value) return null;
|
|
67
|
+
if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
|
|
68
|
+
const projects = listProjects(T);
|
|
69
|
+
const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
|
|
70
|
+
if (exact) return exact.path;
|
|
71
|
+
const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
|
|
72
|
+
return matches.length === 1 ? matches[0].path : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function sendProjectPicker(T, chatId, { text } = {}) {
|
|
76
|
+
const base = projectRoot(T);
|
|
77
|
+
const all = listProjects(T);
|
|
78
|
+
const shown = all.slice(0, MAX_PROJECTS);
|
|
79
|
+
T.projectChoices.set(String(chatId), shown);
|
|
80
|
+
const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
|
|
81
|
+
rows.push([{ text: `?? usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
|
|
82
|
+
const intro = text || '¿En qué proyecto vas a trabajar?';
|
|
83
|
+
await send(T, chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
|
|
84
|
+
if (all.length > shown.length) {
|
|
85
|
+
await send(T, chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function selectProject(T, chatId, projectPath) {
|
|
90
|
+
let cwd;
|
|
91
|
+
try {
|
|
92
|
+
cwd = resolveExistingDir(projectPath);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return send(T, chatId, `No pude usar ese proyecto: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
createSessionForProject(T, chatId, cwd);
|
|
97
|
+
const key = String(chatId);
|
|
98
|
+
const pending = T.pendingInputs.get(key);
|
|
99
|
+
T.pendingInputs.delete(key);
|
|
100
|
+
await send(T, chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
|
|
101
|
+
if (pending) T.run(T, chatId, pending.input, pending.images).catch((error) => T.log(`telegram: ${error?.message ?? error}`));
|
|
102
|
+
}
|