@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.
- package/CHANGELOG.md +19 -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/browser/index.js +27 -13
- package/harness/packages/core/src/tts/edge.js +34 -4
- 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/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +4 -3
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -1893
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +5 -4
- 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 +22 -23
- package/harness/packages/server/public/settings.js +4 -0
- package/harness/packages/server/public/styles.css +20 -50
- 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 -933
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +1 -0
- package/harness/packages/server/src/routes/chat.js +1 -1
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/server.js +6 -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 +15 -1
- package/harness/packages/server/test/ops.test.js +9 -5
- package/harness/packages/server/test/settings.test.js +18 -1
- package/harness/scripts/canary.mjs +24 -0
- package/package.json +3 -2
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Formato puro de la consola: lo que se calcula de los datos del turno sin
|
|
2
|
+
// tocar el DOM. Vive aparte para poder testearlo (chat.js es todo DOM).
|
|
3
|
+
export const READ_ONLY_TOOLS = new Set([
|
|
4
|
+
'read',
|
|
5
|
+
'list',
|
|
6
|
+
'glob',
|
|
7
|
+
'grep',
|
|
8
|
+
'fetch_url',
|
|
9
|
+
'git_status',
|
|
10
|
+
'git_diff',
|
|
11
|
+
'git_log',
|
|
12
|
+
'task',
|
|
13
|
+
'spawn_agent',
|
|
14
|
+
]);
|
|
15
|
+
export function isReadOnlyTool(name) {
|
|
16
|
+
const value = String(name ?? '');
|
|
17
|
+
if (READ_ONLY_TOOLS.has(value)) return true;
|
|
18
|
+
return /^mcp__.+__(read|list|search|get|fetch|query)/i.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shortText(value, max = 80) {
|
|
22
|
+
const raw = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
23
|
+
return raw.length > max ? `${raw.slice(0, max - 1)}…` : raw;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function toolSummary(name, args) {
|
|
27
|
+
const a = args && typeof args === 'object' ? args : {};
|
|
28
|
+
switch (name) {
|
|
29
|
+
case 'read': {
|
|
30
|
+
const base = a.path ? shortText(a.path, 60) : '.';
|
|
31
|
+
const offset = Number(a.offset) || 0;
|
|
32
|
+
const limit = Number(a.limit) || 0;
|
|
33
|
+
if (offset && limit) return `${base}:${offset}-${offset + limit - 1}`;
|
|
34
|
+
if (offset) return `${base}:${offset}+`;
|
|
35
|
+
return base;
|
|
36
|
+
}
|
|
37
|
+
case 'write':
|
|
38
|
+
case 'edit':
|
|
39
|
+
return a.path ? shortText(a.path, 60) : 'archivo';
|
|
40
|
+
case 'list':
|
|
41
|
+
return a.path ? shortText(a.path, 60) : '.';
|
|
42
|
+
case 'glob':
|
|
43
|
+
return [a.pattern ? `"${shortText(a.pattern, 50)}"` : '', a.path ? `en ${shortText(a.path, 40)}` : '']
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join(' ');
|
|
46
|
+
case 'grep':
|
|
47
|
+
return [
|
|
48
|
+
a.pattern ? `"${shortText(a.pattern, 50)}"` : '',
|
|
49
|
+
a.include ? shortText(a.include, 30) : '',
|
|
50
|
+
a.path ? `en ${shortText(a.path, 40)}` : '',
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join(' ');
|
|
54
|
+
case 'bash':
|
|
55
|
+
return shortText(String(a.command ?? '').split('\n')[0], 90);
|
|
56
|
+
case 'fetch_url':
|
|
57
|
+
return shortText(a.url, 90);
|
|
58
|
+
case 'speak':
|
|
59
|
+
return shortText(a.texto, 90);
|
|
60
|
+
case 'screenshot':
|
|
61
|
+
case 'console_errors':
|
|
62
|
+
case 'preview_url':
|
|
63
|
+
return shortText(a.url, 90);
|
|
64
|
+
case 'git_diff':
|
|
65
|
+
return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join(' ');
|
|
68
|
+
case 'git_log':
|
|
69
|
+
return a.limit ? `${a.limit} commits` : '';
|
|
70
|
+
case 'git_commit':
|
|
71
|
+
return shortText(a.message, 70);
|
|
72
|
+
case 'task':
|
|
73
|
+
case 'spawn_agent':
|
|
74
|
+
return shortText(a.description || a.prompt, 80);
|
|
75
|
+
default:
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseToolArgs(call) {
|
|
81
|
+
const raw = call?.function?.arguments ?? call?.arguments;
|
|
82
|
+
if (raw && typeof raw === 'object') return raw;
|
|
83
|
+
if (typeof raw !== 'string') return {};
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(raw);
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function isFailedToolText(text) {
|
|
92
|
+
return /^Error|^Permiso denegado/i.test(String(text ?? ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Nombre de archivo de una captura, del meta o del texto del resultado. */
|
|
96
|
+
export function screenshotName(data, text) {
|
|
97
|
+
const fromMeta = data?.meta?.screenshot?.name;
|
|
98
|
+
if (fromMeta) return String(fromMeta);
|
|
99
|
+
const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
|
|
100
|
+
return match ? match[1] : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function previewText(value, maxLines = 24, maxChars = 4000) {
|
|
104
|
+
const content = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2) ?? '';
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
let preview = lines.slice(0, maxLines).join('\n');
|
|
107
|
+
let truncated = lines.length > maxLines;
|
|
108
|
+
if (preview.length > maxChars) {
|
|
109
|
+
preview = preview.slice(0, maxChars);
|
|
110
|
+
truncated = true;
|
|
111
|
+
}
|
|
112
|
+
return { preview, truncated };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function userText(content) {
|
|
116
|
+
if (typeof content === 'string') return content;
|
|
117
|
+
if (Array.isArray(content)) {
|
|
118
|
+
return content
|
|
119
|
+
.filter((part) => part?.type === 'text')
|
|
120
|
+
.map((part) => part.text)
|
|
121
|
+
.join('\n');
|
|
122
|
+
}
|
|
123
|
+
return content == null ? '' : JSON.stringify(content);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function userImages(content) {
|
|
127
|
+
if (!Array.isArray(content)) return [];
|
|
128
|
+
return content
|
|
129
|
+
.filter((part) => part?.type === 'image_url')
|
|
130
|
+
.map((part) => part.image_url?.url)
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const ADVISOR_KINDS = {
|
|
135
|
+
code: { label: 'Cuidado!', glyph: '⚠' },
|
|
136
|
+
explain: { label: 'Importante!', glyph: 'ℹ' },
|
|
137
|
+
advice: { label: 'Consejo', glyph: '✦' },
|
|
138
|
+
summary: { label: 'Resumen', glyph: '✎' },
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function advisorMeta(kind) {
|
|
142
|
+
return ADVISOR_KINDS[kind] || { label: 'Asesor', glyph: '✦' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Ultimo segmento de un path (sin separadores finales). */
|
|
146
|
+
export function basename(p) {
|
|
147
|
+
const text = String(p ?? '').replace(/[\\/]+$/, '');
|
|
148
|
+
return text.split(/[\\/]/).pop() || text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Duracion humana de un turno: 1.2s o 300ms, vacio si no hubo tiempo. */
|
|
152
|
+
export function formatDuration(ms) {
|
|
153
|
+
const value = Number(ms) || 0;
|
|
154
|
+
if (value <= 0) return '';
|
|
155
|
+
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${value}ms`;
|
|
156
|
+
}
|
|
@@ -43,23 +43,24 @@ export function setCwd(value) {
|
|
|
43
43
|
|
|
44
44
|
export const moreTab = $('#more-tab');
|
|
45
45
|
export const moreMenu = $('#more-menu');
|
|
46
|
-
export const moreTabLabel = $('#more-tab-label');
|
|
47
46
|
export const topbarSection = $('#topbar-section');
|
|
48
47
|
|
|
49
48
|
export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
50
49
|
|
|
51
50
|
export function hideMoreMenu() {
|
|
51
|
+
if (moreMenu.hidden) return;
|
|
52
|
+
const hadFocus = moreMenu.contains(document.activeElement);
|
|
52
53
|
moreMenu.hidden = true;
|
|
53
54
|
moreTab.setAttribute('aria-expanded', 'false');
|
|
55
|
+
if (hadFocus) moreTab.focus();
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export function activateTab(name) {
|
|
57
59
|
document
|
|
58
|
-
.querySelectorAll('
|
|
60
|
+
.querySelectorAll('#more-menu button[data-tab]')
|
|
59
61
|
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
60
|
-
if (moreTabLabel) moreTabLabel.textContent = TAB_LABELS[name] || name;
|
|
61
62
|
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
62
|
-
moreTab.classList.toggle('active', name
|
|
63
|
+
moreTab.classList.toggle('active', name !== 'chat');
|
|
63
64
|
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
64
65
|
hideMoreMenu();
|
|
65
66
|
if (name === 'runs') refreshRuns();
|
|
Binary file
|
|
Binary file
|
|
@@ -75,29 +75,7 @@
|
|
|
75
75
|
</head>
|
|
76
76
|
<body>
|
|
77
77
|
<header class="topbar">
|
|
78
|
-
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.
|
|
79
|
-
<nav class="tabs">
|
|
80
|
-
<button class="tab active" data-tab="chat">chat</button>
|
|
81
|
-
<button class="tab" data-tab="providers">proveedores</button>
|
|
82
|
-
<button class="tab" data-tab="ajustes">ajustes</button>
|
|
83
|
-
<div class="menu">
|
|
84
|
-
<button id="more-tab" class="tab" title="más" aria-haspopup="true" aria-expanded="false" aria-label="más">
|
|
85
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
86
|
-
<circle cx="12" cy="12" r="1" />
|
|
87
|
-
<circle cx="19" cy="12" r="1" />
|
|
88
|
-
<circle cx="5" cy="12" r="1" />
|
|
89
|
-
</svg>
|
|
90
|
-
<span id="more-tab-label">chat</span>
|
|
91
|
-
</button>
|
|
92
|
-
<div id="more-menu" class="menu-pop" hidden>
|
|
93
|
-
<button type="button" class="menu-primary" data-tab="chat">chat</button>
|
|
94
|
-
<button type="button" class="menu-primary" data-tab="providers">proveedores</button>
|
|
95
|
-
<button type="button" class="menu-primary" data-tab="ajustes">ajustes</button>
|
|
96
|
-
<button type="button" data-tab="eval">eval</button>
|
|
97
|
-
<button type="button" data-tab="runs">runs</button>
|
|
98
|
-
</div>
|
|
99
|
-
</div>
|
|
100
|
-
</nav>
|
|
78
|
+
<div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.23.0</span></div>
|
|
101
79
|
<div class="settings">
|
|
102
80
|
<button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
|
|
103
81
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
@@ -111,6 +89,21 @@
|
|
|
111
89
|
<path d="m21 21-4.3-4.3" />
|
|
112
90
|
</svg>
|
|
113
91
|
</button>
|
|
92
|
+
<div class="menu">
|
|
93
|
+
<button id="more-tab" type="button" class="btn icon" title="herramientas" aria-haspopup="menu" aria-controls="more-menu" aria-expanded="false" aria-label="herramientas">
|
|
94
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
95
|
+
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
|
96
|
+
</svg>
|
|
97
|
+
</button>
|
|
98
|
+
<div id="more-menu" class="menu-pop" role="menu" hidden>
|
|
99
|
+
<button type="button" class="active" data-tab="chat" role="menuitem">chat</button>
|
|
100
|
+
<button type="button" data-tab="providers" role="menuitem">proveedores</button>
|
|
101
|
+
<button type="button" data-tab="ajustes" role="menuitem">ajustes</button>
|
|
102
|
+
<div class="menu-divider" role="separator"></div>
|
|
103
|
+
<button type="button" data-tab="eval" role="menuitem">eval</button>
|
|
104
|
+
<button type="button" data-tab="runs" role="menuitem">runs</button>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
114
107
|
<button id="settings-toggle" type="button" class="btn icon" title="modelo, modo y auto" aria-label="ajustes del agente">
|
|
115
108
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
116
109
|
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
|
|
@@ -954,6 +947,12 @@
|
|
|
954
947
|
<label class="field"><span>presupuesto de tiempo en segundos (0 = sin límite)</span>
|
|
955
948
|
<input id="limits-duration" type="number" min="0" step="1" placeholder="0" />
|
|
956
949
|
</label>
|
|
950
|
+
<label class="field"><span>máximo de tokens de salida por respuesta (0 = default del proveedor)</span>
|
|
951
|
+
<input id="limits-output" type="number" min="0" step="256" placeholder="0" />
|
|
952
|
+
</label>
|
|
953
|
+
<label class="check" title="reusa el prompt procesado entre rondas: ahorra costo y latencia en conversaciones largas (según el proveedor)">
|
|
954
|
+
<input id="limits-caching" type="checkbox" /> prompt caching
|
|
955
|
+
</label>
|
|
957
956
|
<div class="row">
|
|
958
957
|
<button id="limits-save" class="btn primary" type="button">guardar</button>
|
|
959
958
|
<span id="limits-msg" class="summary"></span>
|
|
@@ -241,6 +241,8 @@ export function activateAjustesPanel(name) {
|
|
|
241
241
|
export const limitsRoundsEl = $('#limits-rounds');
|
|
242
242
|
export const limitsTokensEl = $('#limits-tokens');
|
|
243
243
|
export const limitsDurationEl = $('#limits-duration');
|
|
244
|
+
export const limitsOutputEl = $('#limits-output');
|
|
245
|
+
export const limitsCachingEl = $('#limits-caching');
|
|
244
246
|
|
|
245
247
|
export async function refreshLimits() {
|
|
246
248
|
const data = await api('/api/settings').catch(() => null);
|
|
@@ -249,6 +251,8 @@ export async function refreshLimits() {
|
|
|
249
251
|
if (limitsRoundsEl) limitsRoundsEl.value = settings.maxRounds ?? 0;
|
|
250
252
|
if (limitsTokensEl) limitsTokensEl.value = settings.maxTokens ?? 0;
|
|
251
253
|
if (limitsDurationEl) limitsDurationEl.value = Math.round((settings.maxDurationMs ?? 0) / 1000);
|
|
254
|
+
if (limitsOutputEl) limitsOutputEl.value = settings.maxOutputTokens ?? 0;
|
|
255
|
+
if (limitsCachingEl) limitsCachingEl.checked = settings.promptCaching === true;
|
|
252
256
|
}
|
|
253
257
|
|
|
254
258
|
export function setStatus(node, text, cls = '') {
|
|
@@ -525,7 +525,7 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
525
525
|
position: relative;
|
|
526
526
|
z-index: 40;
|
|
527
527
|
display: grid;
|
|
528
|
-
grid-template-columns: 1fr auto
|
|
528
|
+
grid-template-columns: 1fr auto;
|
|
529
529
|
align-items: center;
|
|
530
530
|
gap: 16px;
|
|
531
531
|
padding: 10px 16px;
|
|
@@ -555,28 +555,23 @@ pre, code, kbd, samp { font-family: var(--mono); }
|
|
|
555
555
|
flex: 0 0 auto;
|
|
556
556
|
}
|
|
557
557
|
.brand .logo img { width: 100%; height: 100%; display: block; object-fit: cover; }
|
|
558
|
+
/* El logo sigue el esquema del tema: el claro es el dibujo oscuro y viceversa
|
|
559
|
+
(`logo_dark`/`logo_light` nombran el tema donde se usan, no su color). */
|
|
560
|
+
.brand .logo img { content: url('/icons/logo_dark.png'); }
|
|
561
|
+
html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png'); }
|
|
558
562
|
.brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
|
|
559
|
-
.brand .topbar-section {
|
|
560
|
-
|
|
561
|
-
.tab {
|
|
562
|
-
background: transparent;
|
|
563
|
-
color: var(--muted);
|
|
564
|
-
border: 1px solid transparent;
|
|
563
|
+
.brand .topbar-section {
|
|
564
|
+
padding: 2px 8px;
|
|
565
565
|
border-radius: 999px;
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
566
|
+
border: 1px solid var(--border);
|
|
567
|
+
background: var(--accent-soft);
|
|
568
|
+
color: var(--accent);
|
|
569
|
+
font: 700 10px var(--sans);
|
|
570
|
+
text-transform: uppercase;
|
|
571
|
+
letter-spacing: 0.08em;
|
|
572
|
+
white-space: nowrap;
|
|
569
573
|
}
|
|
570
|
-
.tab:hover { color: var(--text); }
|
|
571
|
-
.tab.active { color: var(--text); background: var(--theirs); border-color: var(--border); }
|
|
572
|
-
|
|
573
574
|
.menu { position: relative; display: block; }
|
|
574
|
-
#more-tab { display: inline-flex; align-items: center; gap: 6px; }
|
|
575
|
-
#more-tab svg { width: 18px; height: 18px; display: block; }
|
|
576
|
-
#more-tab-label { display: none; font: 600 12.5px var(--sans); }
|
|
577
|
-
@media (min-width: 821px) {
|
|
578
|
-
#more-menu button.menu-primary { display: none; }
|
|
579
|
-
}
|
|
580
575
|
|
|
581
576
|
/* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */
|
|
582
577
|
#theme-toggle .ico-sun { display: none; }
|
|
@@ -586,9 +581,9 @@ html[data-scheme='dark'] #theme-toggle .ico-moon { display: none; }
|
|
|
586
581
|
.menu-pop {
|
|
587
582
|
position: absolute;
|
|
588
583
|
top: calc(100% + 4px);
|
|
589
|
-
|
|
584
|
+
right: 0;
|
|
590
585
|
z-index: 60;
|
|
591
|
-
min-width:
|
|
586
|
+
min-width: 160px;
|
|
592
587
|
display: flex;
|
|
593
588
|
flex-direction: column;
|
|
594
589
|
gap: 2px;
|
|
@@ -611,7 +606,9 @@ html[data-scheme='dark'] #theme-toggle .ico-moon { display: none; }
|
|
|
611
606
|
}
|
|
612
607
|
.menu-pop button:hover { background: var(--theirs); }
|
|
613
608
|
.menu-pop button.active { background: var(--theirs); color: var(--accent); }
|
|
614
|
-
.
|
|
609
|
+
.menu-pop .menu-divider { height: 1px; margin: 3px 4px; background: var(--border); }
|
|
610
|
+
.menu > .btn.icon.active { color: var(--accent); border-color: var(--accent); }
|
|
611
|
+
.settings { grid-column: 2; justify-self: end; display: flex; align-items: center; gap: 8px; }
|
|
615
612
|
.settings select { max-width: 220px; }
|
|
616
613
|
|
|
617
614
|
/* Nueva sesión en el topbar: texto en desktop, solo el icono en móvil, donde
|
|
@@ -2173,32 +2170,8 @@ dialog.file-modal.preview-maximized {
|
|
|
2173
2170
|
.brand .ver {
|
|
2174
2171
|
display: none;
|
|
2175
2172
|
}
|
|
2176
|
-
.brand .topbar-section {
|
|
2177
|
-
display: inline-block;
|
|
2178
|
-
padding: 2px 8px;
|
|
2179
|
-
border-radius: 999px;
|
|
2180
|
-
border: 1px solid var(--border);
|
|
2181
|
-
background: var(--accent-soft);
|
|
2182
|
-
color: var(--accent);
|
|
2183
|
-
font: 700 10px var(--sans);
|
|
2184
|
-
text-transform: uppercase;
|
|
2185
|
-
letter-spacing: 0.08em;
|
|
2186
|
-
white-space: nowrap;
|
|
2187
|
-
}
|
|
2188
|
-
.tabs {
|
|
2189
|
-
margin-left: auto;
|
|
2190
|
-
max-width: 100%;
|
|
2191
|
-
}
|
|
2192
|
-
.tabs > .tab[data-tab] {
|
|
2193
|
-
display: none;
|
|
2194
|
-
}
|
|
2195
|
-
#more-tab-label {
|
|
2196
|
-
display: none;
|
|
2197
|
-
}
|
|
2198
2173
|
.menu-pop {
|
|
2199
2174
|
top: calc(100% + 2px);
|
|
2200
|
-
left: auto;
|
|
2201
|
-
right: 0;
|
|
2202
2175
|
min-width: 200px;
|
|
2203
2176
|
padding: 6px;
|
|
2204
2177
|
}
|
|
@@ -2207,7 +2180,7 @@ dialog.file-modal.preview-maximized {
|
|
|
2207
2180
|
}
|
|
2208
2181
|
.settings {
|
|
2209
2182
|
grid-column: auto;
|
|
2210
|
-
margin-left:
|
|
2183
|
+
margin-left: auto;
|
|
2211
2184
|
/* Antes `1 1 100%`: eso ocupaba toda la fila y mandaba los botones a una
|
|
2212
2185
|
segunda linea. Ahora se quedan en la misma linea que TH. */
|
|
2213
2186
|
flex: 0 0 auto;
|
|
@@ -2353,9 +2326,6 @@ dialog.file-modal.preview-maximized {
|
|
|
2353
2326
|
.topbar {
|
|
2354
2327
|
gap: 8px 8px;
|
|
2355
2328
|
}
|
|
2356
|
-
#more-tab-label {
|
|
2357
|
-
display: none;
|
|
2358
|
-
}
|
|
2359
2329
|
.settings {
|
|
2360
2330
|
gap: 5px;
|
|
2361
2331
|
}
|
|
@@ -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 };
|