@7ots/cli 0.1.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 (97) hide show
  1. package/.env.example +140 -0
  2. package/LICENSE +21 -0
  3. package/README.md +655 -0
  4. package/brand/apple-touch-icon.png +0 -0
  5. package/brand/favicon.svg +4 -0
  6. package/brand/icon-512.png +0 -0
  7. package/brand/mark-mono.svg +3 -0
  8. package/brand/mark.svg +4 -0
  9. package/brand/og-es.png +0 -0
  10. package/brand/og-pt.png +0 -0
  11. package/brand/og.png +0 -0
  12. package/brand/ots.json +11 -0
  13. package/brand/tokens.css +59 -0
  14. package/brand/wordmark.svg +5 -0
  15. package/cli/7ots.mjs +400 -0
  16. package/cli/lib/brain.mjs +143 -0
  17. package/cli/lib/config.mjs +70 -0
  18. package/cli/lib/hooks.mjs +151 -0
  19. package/cli/lib/i18n.mjs +480 -0
  20. package/cli/lib/identity.mjs +51 -0
  21. package/cli/lib/install.mjs +79 -0
  22. package/cli/lib/lines.mjs +73 -0
  23. package/cli/lib/meet.mjs +166 -0
  24. package/cli/lib/open.mjs +19 -0
  25. package/cli/lib/orquesta.mjs +84 -0
  26. package/cli/lib/paths.mjs +49 -0
  27. package/cli/lib/pet-core.mjs +184 -0
  28. package/cli/lib/pet-server.mjs +296 -0
  29. package/cli/lib/prompts.mjs +75 -0
  30. package/cli/lib/terminal.mjs +145 -0
  31. package/cli/lib/ui.mjs +234 -0
  32. package/cli/lib/wizard.mjs +279 -0
  33. package/cli/pet/electron/main.cjs +86 -0
  34. package/cli/pet/pet.html +404 -0
  35. package/dist/7ots.esm.js +627 -0
  36. package/dist/7ots.esm.js.map +7 -0
  37. package/dist/7ots.iife.js +627 -0
  38. package/dist/7ots.iife.js.map +7 -0
  39. package/llms.txt +57 -0
  40. package/package.json +73 -0
  41. package/server/admin.mjs +213 -0
  42. package/server/apuchat-relay.mjs +170 -0
  43. package/server/channels/agent.mjs +136 -0
  44. package/server/channels/apuchat.mjs +364 -0
  45. package/server/channels/apumail.mjs +211 -0
  46. package/server/channels/index.mjs +69 -0
  47. package/server/contact.mjs +151 -0
  48. package/server/demo.mjs +154 -0
  49. package/server/i18n.mjs +54 -0
  50. package/server/identity.mjs +108 -0
  51. package/server/llm.mjs +413 -0
  52. package/server/platform/auth.mjs +290 -0
  53. package/server/platform/crypto.mjs +80 -0
  54. package/server/platform/db.mjs +130 -0
  55. package/server/platform/routes.mjs +388 -0
  56. package/server/platform/runtime.mjs +180 -0
  57. package/server/platform/store.mjs +331 -0
  58. package/server/sdk.mjs +125 -0
  59. package/server/server.mjs +456 -0
  60. package/server/settings.mjs +177 -0
  61. package/server/tts.mjs +186 -0
  62. package/skills/7ots/SKILL.md +59 -0
  63. package/src/actions/ActionRegistry.js +254 -0
  64. package/src/actions/PageTools.js +270 -0
  65. package/src/actions/builtins.js +565 -0
  66. package/src/auth/AuthManager.js +128 -0
  67. package/src/avatar/AvatarStage.js +473 -0
  68. package/src/character/AvatarEditor.js +829 -0
  69. package/src/character/Character.js +1191 -0
  70. package/src/character/motion.js +794 -0
  71. package/src/character/parts.js +559 -0
  72. package/src/context/ContextManager.js +472 -0
  73. package/src/core/AgentBrain.js +341 -0
  74. package/src/core/AgentWidget.js +1059 -0
  75. package/src/core/EventBus.js +61 -0
  76. package/src/core/Proactivity.js +363 -0
  77. package/src/core/storage.js +35 -0
  78. package/src/i18n/index.js +193 -0
  79. package/src/i18n/messages/character.js +543 -0
  80. package/src/i18n/messages/identity.js +141 -0
  81. package/src/i18n/messages/platform.js +114 -0
  82. package/src/i18n/messages/server.js +630 -0
  83. package/src/i18n/messages/widget.js +362 -0
  84. package/src/identity/ContactCard.js +351 -0
  85. package/src/identity/Face.js +148 -0
  86. package/src/identity/random.js +112 -0
  87. package/src/identity/schema.js +262 -0
  88. package/src/index.js +117 -0
  89. package/src/integrations/apuchat.js +212 -0
  90. package/src/integrations/apumail.js +88 -0
  91. package/src/llm/ProxyLLM.js +87 -0
  92. package/src/mcp/McpClient.js +153 -0
  93. package/src/ui/Companion.js +498 -0
  94. package/src/ui/UIManager.js +425 -0
  95. package/src/ui/VirtualPointer.js +746 -0
  96. package/src/ui/markdown.js +94 -0
  97. package/src/voice/VoiceEngine.js +227 -0
@@ -0,0 +1,456 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Proxy de 7ots — el único sitio donde viven las claves.
4
+ *
5
+ * POST /api/agent/chat { system, messages, tools } → { text, toolCalls, ... }
6
+ * POST /api/agent/tts { text, lang } → audio/mpeg
7
+ * POST /api/agent/contact/apumail ticket por correo
8
+ * POST /api/agent/contact/apuchat crea canal apuchat + avisa al operador
9
+ * POST /api/agent/channels/apumail webhook de apumail (el agente contesta correos)
10
+ * GET /api/agent/identity tarjeta pública del agente (identidad visual, voz, contacto)
11
+ * GET /api/agent/identity.vcf la misma tarjeta como vCard
12
+ * GET /.well-known/7ots-agent.json descubrimiento del agente (igual que /identity)
13
+ * POST /api/agent/identity/call videollamada de meet con el agente → { call_url }
14
+ * GET /api/agent/config configuración pública del widget (la del backoffice)
15
+ * GET /api/agent/embed.js snippet de una línea: carga el widget con esa configuración
16
+ * GET /api/agent/7ots.js · 7ots.mjs el widget (IIFE / ESM) desde dist/
17
+ * * /api/agent/admin/* API del backoffice (ver server/admin.mjs)
18
+ * GET /backoffice/ backoffice de 7ots.com (siempre, aunque SERVE_STATIC=false)
19
+ * GET /api/agent/health
20
+ *
21
+ * En modo desarrollo también sirve el repo como estáticos (site/, brand/, examples/, src/, dist/)
22
+ * y el backend de demo (server/demo.mjs). Cero frameworks: Node 22 + fetch nativo.
23
+ *
24
+ * npm run dev → http://localhost:8787/ (web) · /examples/ (demo)
25
+ *
26
+ * Seguridad:
27
+ * - CORS restringido a ALLOWED_ORIGINS (además del propio origen).
28
+ * - SITE_KEYS opcional: el widget manda X-Site-Key; útil si un proxy sirve a varios sitios.
29
+ * - Límite de peticiones por IP y tamaño máximo de cuerpo.
30
+ * - Nota: el `system` lo envía el widget. Para que nadie use tu proxy como LLM genérico,
31
+ * combina ALLOWED_ORIGINS + SITE_KEYS + rate limit, y si lo necesitas fija el prompt
32
+ * en el servidor con SERVER_INSTRUCTIONS (se antepone siempre).
33
+ */
34
+
35
+ import { createServer } from 'node:http';
36
+ import { readFile, stat } from 'node:fs/promises';
37
+ import { existsSync, readFileSync } from 'node:fs';
38
+ import { dirname, extname, join, normalize, resolve } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { llmsTxt } from '../cli/lib/prompts.mjs';
41
+
42
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
43
+ loadDotEnv(join(ROOT, '.env'));
44
+ const { loadSettings, widgetConfig } = await import('./settings.mjs');
45
+ loadSettings(); // lo guardado en el backoffice (data/config.json + data/secrets.json) manda sobre .env
46
+
47
+ const { createLLM } = await import('./llm.mjs');
48
+ const { synthesize, ttsProvider } = await import('./tts.mjs');
49
+ const { sendApumail, startApuchat } = await import('./contact.mjs');
50
+ const relay = await import('./apuchat-relay.mjs');
51
+ const { handleDemo } = await import('./demo.mjs');
52
+ const { startAgentChannels } = await import('./channels/index.mjs');
53
+ const { getIdentity, identityConfigured, identityPrompt, publicIdentity, toVCard } = await import('./identity.mjs');
54
+ const { createAdmin } = await import('./admin.mjs');
55
+ // Idioma: respuestas al cliente según Accept-Language; consola y equipo según LOCALE (ver server/i18n.mjs).
56
+ const { reqT, requestLocale, teamT, i18nError, errorText } = await import('./i18n.mjs');
57
+
58
+ const env = process.env;
59
+ const PORT = Number(env.PORT || 8787);
60
+ const PREFIX = '/api/agent';
61
+ const SERVE_STATIC = env.SERVE_STATIC !== 'false';
62
+ const DEMO = env.DEMO !== 'false';
63
+ // Se leen en cada petición: el backoffice los cambia en caliente.
64
+ const csv = (v) => (v || '').split(',').map((s) => s.trim()).filter(Boolean);
65
+ const ALLOWED = () => csv(env.ALLOWED_ORIGINS);
66
+ const SITE_KEYS = () => csv(env.SITE_KEYS);
67
+ const RATE = () => Number(env.RATE_LIMIT_PER_MIN || 40);
68
+
69
+ let llm = createLLM();
70
+ // Los canales ven siempre el LLM vigente (el backoffice puede cambiar de proveedor sin reiniciarlos).
71
+ const liveLlm = { get name() { return llm.name; }, get model() { return llm.model; }, step: (a) => llm.step(a) };
72
+ // El agente también atiende por apumail/apuchat/meet si hay variables APUMAIL_AGENT_* / APUCHAT_AGENT_*.
73
+ let channels = startAgentChannels({ llm: liveLlm });
74
+
75
+ /** Reaplica la configuración del servidor tras guardar en el backoffice. */
76
+ async function reload({ channels: restartChannels = false } = {}) {
77
+ llm = createLLM();
78
+ if (restartChannels) {
79
+ await Promise.race([channels.stop(), new Promise((r) => setTimeout(r, 3000))]);
80
+ channels = startAgentChannels({ llm: liveLlm });
81
+ }
82
+ const t = teamT();
83
+ console.log(`[7ots] ${t('server.log.applied', { llm: `${llm.name}:${llm.model}`, tts: ttsProvider() })}${restartChannels ? t('server.log.channelsRestarted') : ''}`);
84
+ }
85
+
86
+ const admin = createAdmin({ llm: () => llm, channels: () => channels, reload, synthesize, ttsProvider, readJson, send });
87
+
88
+ // Plataforma multi-cuenta (7ots.com): cuentas, varios ots por cuenta y dashboard en /app/. Ver server/platform/.
89
+ const PLATFORM = env.PLATFORM === 'true';
90
+ let platform = null;
91
+ let auth = null;
92
+ let platformRuntime = null;
93
+ if (PLATFORM) {
94
+ const { assertSecret } = await import('./platform/crypto.mjs');
95
+ assertSecret();
96
+ const { openDb } = await import('./platform/db.mjs');
97
+ openDb();
98
+ const { createPlatform } = await import('./platform/routes.mjs');
99
+ const { createAuth } = await import('./platform/auth.mjs');
100
+ platformRuntime = await import('./platform/runtime.mjs');
101
+ platform = createPlatform({ send, readJson, rateLimit, callLimit, originOf, embedScript, bundleVersion, relay, sendApumail, startApuchat });
102
+ auth = createAuth({ send, readJson, originOf, clientIp });
103
+ platformRuntime.startAllChannels();
104
+ console.log(`[7ots] ${teamT()('platform.log.ready', { db: env.PLATFORM_DB || 'data/platform.db' })}`);
105
+ }
106
+
107
+ // ───────────────────────────── servidor ─────────────────────────────
108
+
109
+ const server = createServer(async (req, res) => {
110
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
111
+ const t = reqT(req);
112
+ try {
113
+ if (platform && ((await platform(req, res, url)) || (await auth(req, res, url)))) return;
114
+ if (PLATFORM && (url.pathname === '/app' || url.pathname.startsWith('/app/'))) return await serveApp(req, res, url.pathname);
115
+ if (url.pathname === '/llms.txt' && (req.method === 'GET' || req.method === 'HEAD')) {
116
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=600' });
117
+ return res.end(llmsTxt(PLATFORM ? 'https://7ots.com' : originOf(req)));
118
+ }
119
+ if (publicIdentityRoute(req, res, url)) return; // tarjeta pública: cualquier origen puede leerla
120
+ if (await publicWidgetRoute(req, res, url)) return; // config pública y embed.js
121
+ if (url.pathname.startsWith(`${PREFIX}/admin/`)) {
122
+ if (await admin(req, res, url.pathname.slice(PREFIX.length))) return;
123
+ }
124
+ if (url.pathname === '/backoffice' || url.pathname.startsWith('/backoffice/')) return await serveBackoffice(req, res, url.pathname);
125
+ if (!cors(req, res)) return send(res, 403, { error: t('server.http.originNotAllowed') });
126
+ if (req.method === 'OPTIONS') return send(res, 204);
127
+
128
+ if (url.pathname.startsWith(PREFIX)) return await api(req, res, url.pathname.slice(PREFIX.length));
129
+ if (DEMO && (await handleDemo(req, res, url, { readJson, send }))) return;
130
+ if (SERVE_STATIC && req.method === 'GET') return await serveStatic(req, res, url.pathname);
131
+ send(res, 404, { error: t('server.http.notFound') });
132
+ } catch (err) {
133
+ const status = err.status || 500;
134
+ if (status >= 500 && status !== 501) console.error('[7ots]', req.method, url.pathname, err);
135
+ if (!res.headersSent) send(res, status, { error: status >= 500 && status !== 501 ? t('server.http.internal') : errorText(err, t) });
136
+ }
137
+ });
138
+
139
+ async function api(req, res, path) {
140
+ const t = reqT(req);
141
+ if (path === '/health') {
142
+ return send(res, 200, { ok: true, llm: `${llm.name}:${llm.model}`, tts: ttsProvider(), apumail: !!(env.APUMAIL_INBOX && env.APUMAIL_INBOX_TOKEN && env.APUMAIL_TO), apuchat: true, channels: channels.status() });
143
+ }
144
+ if (await channels.handle(req, res, path)) return;
145
+ if (req.method !== 'POST') return send(res, 405, { error: t('server.http.methodNotAllowed') });
146
+ if (SITE_KEYS().length && !SITE_KEYS().includes(req.headers['x-site-key'])) return send(res, 401, { error: t('server.http.badSiteKey') });
147
+ if (!rateLimit(req)) return send(res, 429, { error: t('server.http.rateLimited') });
148
+
149
+ if (path === '/chat') {
150
+ const body = await readJson(req, 1024 * 1024);
151
+ const { system, messages, tools = [] } = body;
152
+ if (typeof system !== 'string' || !Array.isArray(messages) || !messages.length || !Array.isArray(tools)) {
153
+ return send(res, 400, { error: t('server.http.badRequest') });
154
+ }
155
+ if (messages.length > 300 || tools.length > 128 || system.length > 30000) return send(res, 413, { error: t('server.http.tooLarge') });
156
+ // La personalidad de la identidad (con sus instrucciones privadas) la pone el servidor.
157
+ const pre = [env.SERVER_INSTRUCTIONS, identityConfigured() ? identityPrompt(getIdentity()) : ''].filter(Boolean).join('\n\n');
158
+ const sys = pre ? `${pre}\n\n${system}` : system;
159
+ const t0 = Date.now();
160
+ // `locale`: idioma de los textos fijos del proveedor (rechazo, modo mock); el modelo contesta en el del prompt.
161
+ const out = await llm.step({ system: sys, messages, tools, locale: requestLocale(req) });
162
+ if (env.LOG_LEVEL === 'debug') console.log(`[7ots] chat ${Date.now() - t0}ms ${out.finishReason} tools=${out.toolCalls.map((c) => c.name).join(',')}`);
163
+ return send(res, 200, out);
164
+ }
165
+
166
+ if (path === '/tts') {
167
+ const { text } = await readJson(req, 16 * 1024);
168
+ const { audio, contentType } = await synthesize({ text, voice: getIdentity().voice });
169
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': audio.length, 'Cache-Control': 'no-store' });
170
+ return res.end(audio);
171
+ }
172
+
173
+ if (path === '/identity/call') {
174
+ if (!callLimit(req)) return send(res, 429, { error: t('server.http.callLimited') });
175
+ const { motivo } = await readJson(req, 4 * 1024);
176
+ const call = await channels.openCall({ reason: String(motivo || '').slice(0, 200) });
177
+ return send(res, 200, { call_url: call.call_url, expires_at: call.expires_at });
178
+ }
179
+
180
+ if (path === '/contact/apumail') return send(res, 200, await sendApumail(await readJson(req, 128 * 1024)));
181
+ if (path === '/contact/apuchat') return send(res, 200, await startApuchat(await readJson(req, 128 * 1024)));
182
+ // Relé del chat con el equipo (el hub no admite CORS de terceros): {id, token, ...}
183
+ if (path === '/contact/apuchat/send') return send(res, 200, await relay.relaySend(await readJson(req, 16 * 1024)));
184
+ if (path === '/contact/apuchat/wait') return send(res, 200, await relay.relayWait(await readJson(req, 4 * 1024)));
185
+ if (path === '/contact/apuchat/end') return send(res, 200, await relay.relayEnd(await readJson(req, 4 * 1024)));
186
+
187
+ send(res, 404, { error: t('server.http.unknownRoute') });
188
+ }
189
+
190
+ // ───────────────────────────── identidad pública ─────────────────────────────
191
+
192
+ function publicIdentityRoute(req, res, url) {
193
+ const p = url.pathname;
194
+ const json = p === `${PREFIX}/identity` || p === '/.well-known/7ots-agent.json';
195
+ const vcf = p === `${PREFIX}/identity.vcf`;
196
+ if (!json && !vcf) return false;
197
+ res.setHeader('Access-Control-Allow-Origin', '*');
198
+ if (req.method === 'OPTIONS') {
199
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
200
+ send(res, 204);
201
+ return true;
202
+ }
203
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
204
+ send(res, 405, { error: reqT(req)('server.http.methodNotAllowed') });
205
+ return true;
206
+ }
207
+ const card = publicIdentity(getIdentity(), channels.live());
208
+ if (vcf) {
209
+ const body = toVCard(card, { baseUrl: `${originOf(req)}${PREFIX}/identity.vcf` });
210
+ res.writeHead(200, { 'Content-Type': 'text/vcard; charset=utf-8', 'Content-Disposition': `inline; filename="${card.id}.vcf"`, 'Cache-Control': 'no-cache' });
211
+ res.end(body);
212
+ return true;
213
+ }
214
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
215
+ res.end(JSON.stringify(card));
216
+ return true;
217
+ }
218
+
219
+ function originOf(req) {
220
+ const proto = env.TRUST_PROXY === 'true' && req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
221
+ return `${proto}://${req.headers.host}`;
222
+ }
223
+
224
+ // ───────────────────────────── widget: config pública, embed y bundle ─────────────────────────────
225
+
226
+ async function publicWidgetRoute(req, res, url) {
227
+ const p = url.pathname;
228
+ const routes = [`${PREFIX}/config`, `${PREFIX}/embed.js`, `${PREFIX}/7ots.js`, `${PREFIX}/7ots.mjs`];
229
+ if (!routes.includes(p)) return false;
230
+ res.setHeader('Access-Control-Allow-Origin', '*');
231
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
232
+ send(res, 405, { error: reqT(req)('server.http.methodNotAllowed') });
233
+ return true;
234
+ }
235
+ if (p === `${PREFIX}/config`) {
236
+ send(res, 200, widgetConfig());
237
+ return true;
238
+ }
239
+ if (p === `${PREFIX}/embed.js`) {
240
+ const v = await bundleVersion();
241
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
242
+ res.end(embedScript(widgetConfig(), v));
243
+ return true;
244
+ }
245
+ const file = join(ROOT, 'dist', p.endsWith('.mjs') ? '7ots.esm.js' : '7ots.iife.js');
246
+ try {
247
+ const data = await readFile(file);
248
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': url.searchParams.has('v') ? 'public, max-age=86400' : 'no-cache' });
249
+ res.end(data);
250
+ } catch {
251
+ send(res, 503, { error: reqT(req)('server.http.noDist') });
252
+ }
253
+ return true;
254
+ }
255
+
256
+ async function bundleVersion() {
257
+ try {
258
+ return String(Math.floor((await stat(join(ROOT, 'dist', '7ots.iife.js'))).mtimeMs));
259
+ } catch {
260
+ return '0';
261
+ }
262
+ }
263
+
264
+ /**
265
+ * <script src="https://tu-proxy/api/agent/embed.js" data-site-key="pk_…" async></script>
266
+ * Carga el widget con la configuración del backoffice. Lo que la página ponga en
267
+ * window.SevenOtsConfig (acciones, auth, plantillas…) se mezcla encima. Al arrancar emite
268
+ * el evento `7ots:ready` en window con { agent }.
269
+ * `endpoint`: ruta de la API del agente (en la plataforma, la de cada ots: /api/o/<id>).
270
+ */
271
+ function embedScript(config, v, endpoint = PREFIX) {
272
+ return `/* 7ots.com — widget con la configuración del backoffice */
273
+ (function () {
274
+ var s = document.currentScript;
275
+ if (!s || window.__7otsEmbed) return;
276
+ window.__7otsEmbed = true;
277
+ var origin = new URL(s.src).origin;
278
+ var base = ${JSON.stringify(config).replace(/</g, '\\u003c')};
279
+ base.endpoint = origin + '${endpoint}';
280
+ base.identity = true;
281
+ if (s.dataset.siteKey) base.siteKey = s.dataset.siteKey;
282
+ function merge(a, b) {
283
+ var o = {}, k;
284
+ for (k in a) o[k] = a[k];
285
+ for (k in b) {
286
+ var x = b[k], y = a[k];
287
+ o[k] = x && y && typeof x === 'object' && typeof y === 'object' && !Array.isArray(x) && !Array.isArray(y) ? merge(y, x) : x;
288
+ }
289
+ return o;
290
+ }
291
+ function start() {
292
+ var agent = window.SevenOts.init(merge(base, window.SevenOtsConfig || {}));
293
+ window.dispatchEvent(new CustomEvent('7ots:ready', { detail: { agent: agent } }));
294
+ }
295
+ var js = document.createElement('script');
296
+ js.src = origin + '${PREFIX}/7ots.js?v=${v}';
297
+ js.onload = function () { document.body ? start() : document.addEventListener('DOMContentLoaded', start); };
298
+ document.head.appendChild(js);
299
+ })();
300
+ `;
301
+ }
302
+
303
+ /** Dashboard de la plataforma (/app/): mismas cabeceras que el backoffice. */
304
+ function serveApp(req, res, pathname) {
305
+ return serveBackoffice(req, res, pathname, 'app');
306
+ }
307
+
308
+ async function serveBackoffice(req, res, pathname, dir = 'backoffice') {
309
+ if (pathname === `/${dir}`) {
310
+ res.writeHead(302, { Location: `/${dir}/` });
311
+ return res.end();
312
+ }
313
+ const rel = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, '');
314
+ let file = join(ROOT, rel);
315
+ if (!file.startsWith(join(ROOT, dir))) return send(res, 403, { error: reqT(req)('server.http.forbidden') });
316
+ try {
317
+ if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
318
+ const data = await readFile(file);
319
+ const headers = { 'Content-Type': MIME[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer' };
320
+ // El panel no se puede enmarcar (clickjacking); la vista previa sí, desde el propio panel.
321
+ headers['Content-Security-Policy'] = file.endsWith('preview.html') ? "frame-ancestors 'self'" : "frame-ancestors 'none'";
322
+ res.writeHead(200, headers);
323
+ res.end(data);
324
+ } catch {
325
+ send(res, 404, { error: reqT(req)('server.http.notFound') });
326
+ }
327
+ }
328
+
329
+ // Cada videollamada ocupa al agente y gasta cupo de /api/video-call del hub (15/min por IP).
330
+ const callBuckets = new Map();
331
+ function callLimit(req) {
332
+ const ip = clientIp(req);
333
+ const now = Date.now();
334
+ const recent = (callBuckets.get(ip) || []).filter((t) => now - t < 10 * 60_000);
335
+ if (recent.length >= 3) return false;
336
+ recent.push(now);
337
+ callBuckets.set(ip, recent);
338
+ if (callBuckets.size > 5000) callBuckets.clear();
339
+ return true;
340
+ }
341
+
342
+ // ───────────────────────────── helpers HTTP ─────────────────────────────
343
+
344
+ function cors(req, res) {
345
+ const origin = req.headers.origin;
346
+ if (!origin) return true; // misma página / curl
347
+ const self = `http://${req.headers.host}`;
348
+ const selfS = `https://${req.headers.host}`;
349
+ const allowed = ALLOWED();
350
+ const ok = origin === self || origin === selfS || allowed.includes(origin) || allowed.includes('*');
351
+ if (!ok) return false;
352
+ res.setHeader('Access-Control-Allow-Origin', origin);
353
+ res.setHeader('Vary', 'Origin');
354
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
355
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Site-Key, Mcp-Session-Id, Mcp-Protocol-Version, Accept, Accept-Language');
356
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
357
+ res.setHeader('Access-Control-Max-Age', '600');
358
+ return true;
359
+ }
360
+
361
+ function send(res, status, body) {
362
+ if (body === undefined) {
363
+ res.writeHead(status);
364
+ return res.end();
365
+ }
366
+ const json = JSON.stringify(body);
367
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
368
+ res.end(json);
369
+ }
370
+
371
+ async function readJson(req, limit) {
372
+ let size = 0;
373
+ const chunks = [];
374
+ for await (const c of req) {
375
+ size += c.length;
376
+ if (size > limit) throw i18nError(413, 'server.http.bodyTooLarge');
377
+ chunks.push(c);
378
+ }
379
+ if (!size) return {};
380
+ try {
381
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
382
+ } catch {
383
+ throw i18nError(400, 'server.http.badJson');
384
+ }
385
+ }
386
+
387
+ const buckets = new Map();
388
+ function clientIp(req) {
389
+ return (env.TRUST_PROXY === 'true' && String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()) || req.socket.remoteAddress;
390
+ }
391
+
392
+ function rateLimit(req) {
393
+ const ip = clientIp(req);
394
+ const now = Date.now();
395
+ const rate = RATE();
396
+ const b = buckets.get(ip) || { tokens: rate, at: now };
397
+ b.tokens = Math.min(rate, b.tokens + ((now - b.at) / 60000) * rate);
398
+ b.at = now;
399
+ if (b.tokens < 1) return false;
400
+ b.tokens -= 1;
401
+ buckets.set(ip, b);
402
+ if (buckets.size > 10000) buckets.clear();
403
+ return true;
404
+ }
405
+
406
+ const MIME = {
407
+ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
408
+ '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
409
+ '.jpg': 'image/jpeg', '.glb': 'model/gltf-binary', '.md': 'text/markdown; charset=utf-8', '.ico': 'image/x-icon',
410
+ };
411
+ const STATIC_DIRS = ['site', 'brand', 'examples', 'src', 'dist'];
412
+
413
+ async function serveStatic(req, res, pathname) {
414
+ if (pathname === '/') {
415
+ res.writeHead(302, { Location: '/site/' });
416
+ return res.end();
417
+ }
418
+ let rel = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, '');
419
+ if (!STATIC_DIRS.some((d) => rel === d || rel.startsWith(`${d}/`))) return send(res, 404, { error: reqT(req)('server.http.notFound') });
420
+ let file = join(ROOT, rel);
421
+ if (!file.startsWith(ROOT)) return send(res, 403, { error: reqT(req)('server.http.forbidden') });
422
+ try {
423
+ if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
424
+ const data = await readFile(file);
425
+ res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' });
426
+ res.end(data);
427
+ } catch {
428
+ send(res, 404, { error: reqT(req)('server.http.notFound') });
429
+ }
430
+ }
431
+
432
+ /** Carga .env sin dependencias (no pisa variables ya definidas). */
433
+ function loadDotEnv(path) {
434
+ if (!existsSync(path)) return;
435
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
436
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/i);
437
+ if (!m || line.trim().startsWith('#')) continue;
438
+ const v = m[2].replace(/^(['"])(.*)\1$/, '$2');
439
+ if (process.env[m[1]] === undefined) process.env[m[1]] = v;
440
+ }
441
+ }
442
+
443
+ for (const sig of ['SIGINT', 'SIGTERM']) {
444
+ process.once(sig, async () => {
445
+ await Promise.race([Promise.all([channels.stop(), platformRuntime?.stopAllChannels()]), new Promise((r) => setTimeout(r, 3000))]); // cuelga las llamadas abiertas
446
+ process.exit(0);
447
+ });
448
+ }
449
+
450
+ // El banner de arranque sale en el idioma del servidor (LOCALE → idioma de la identidad → español).
451
+ server.listen(PORT, () => {
452
+ const t = teamT();
453
+ console.log(`[7ots] ${t('server.log.listening', { port: PORT, llm: `${llm.name}:${llm.model}`, tts: ttsProvider() })}`);
454
+ if (SERVE_STATIC) console.log(`[7ots] ${t('server.log.demo', { url: `http://localhost:${PORT}/examples/` })}`);
455
+ console.log(`[7ots] ${t('server.log.backoffice', { url: `http://localhost:${PORT}/backoffice/` })}${env.ADMIN_PASSWORD ? '' : t('server.log.localOnly')}`);
456
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Configuración editable desde el backoffice de 7ots.
3
+ *
4
+ * data/config.json { widget: {...}, server: { CLAVE: valor } } — sin secretos
5
+ * data/secrets.json { CLAVE: valor } — permisos 0600, nunca sale por la API
6
+ *
7
+ * `server` y los secretos se aplican encima de process.env: lo guardado en el backoffice
8
+ * manda sobre .env y sobre el entorno. Quitar un valor devuelve el del entorno.
9
+ * Las rutas cambian con CONFIG_FILE / SECRETS_FILE.
10
+ *
11
+ * El widget recibe `widget` tal cual (GET /api/agent/config y /api/agent/embed.js): no
12
+ * pongas ahí nada privado.
13
+ */
14
+
15
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { i18nError } from './i18n.mjs';
19
+
20
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
21
+ const env = process.env;
22
+
23
+ /**
24
+ * Variables del servidor que el backoffice puede cambiar.
25
+ * secret: solo escritura. restart: no se aplica hasta reiniciar el proceso.
26
+ * channels: al cambiar, se reinician los canales del agente (apumail/apuchat).
27
+ */
28
+ export const SERVER_KEYS = {
29
+ // LLM
30
+ LLM_PROVIDER: {}, LLM_MODEL: {}, LLM_BASE_URL: {}, LLM_EFFORT: {}, LLM_MAX_TOKENS: {}, LLM_FALLBACKS: {},
31
+ ANTHROPIC_API_KEY: { secret: true }, OPENAI_API_KEY: { secret: true },
32
+ SERVER_INSTRUCTIONS: {},
33
+ // Voz
34
+ TTS_PROVIDER: {}, OPENAI_TTS_MODEL: {}, OPENAI_TTS_VOICE: {},
35
+ ELEVENLABS_API_KEY: { secret: true }, ELEVENLABS_VOICE_ID: {}, ELEVENLABS_MODEL: {},
36
+ APUCHAT_VOICE_URL: {}, APUCHAT_VOICE_TOKEN: { secret: true }, APUCHAT_VOICE_ID: {}, APUCHAT_VOICE_PROVIDER: {},
37
+ XAI_API_KEY: { secret: true }, XAI_TTS_VOICE: {},
38
+ FISH_API_KEY: { secret: true }, FISH_VOICE_ID: {}, FISH_MODEL: {},
39
+ // Contacto desde el widget
40
+ APUMAIL_API: {}, APUMAIL_INBOX: {}, APUMAIL_INBOX_TOKEN: { secret: true }, APUMAIL_TO: {},
41
+ APUCHAT_HUB: { channels: true }, APUCHAT_NOTIFIER_IDENTITY_KEY: { secret: true }, APUCHAT_OPERATOR_HANDLE: {}, APUCHAT_TRANSCRIBE: {},
42
+ // El agente fuera de la web
43
+ APUMAIL_AGENT_INBOX: { channels: true }, APUMAIL_AGENT_TOKEN: { secret: true, channels: true },
44
+ APUMAIL_AGENT_WEBHOOK_SECRET: { secret: true, channels: true }, APUMAIL_AGENT_DAILY: { channels: true },
45
+ APUCHAT_AGENT_IDENTITY_KEY: { secret: true, channels: true }, APUCHAT_AGENT_ALLOW: { channels: true },
46
+ AGENT_MAX_CALLS: { channels: true }, AGENT_CALL_MAX_MINUTES: { channels: true }, AGENT_CALL_MAX_TURNS: { channels: true },
47
+ // Seguridad
48
+ ALLOWED_ORIGINS: {}, SITE_KEYS: {}, RATE_LIMIT_PER_MIN: {}, TRUST_PROXY: { restart: true },
49
+ // Proceso
50
+ PORT: { restart: true }, SERVE_STATIC: { restart: true }, DEMO: { restart: true }, LOG_LEVEL: {},
51
+ // Idioma del servidor (es/en/pt): avisos al equipo y consola. Vacío = el de la identidad.
52
+ LOCALE: {},
53
+ };
54
+
55
+ const fileOf = (name, def) => resolve(ROOT, env[name] || join('data', def));
56
+ export const configFile = () => fileOf('CONFIG_FILE', 'config.json');
57
+ export const secretsFile = () => fileOf('SECRETS_FILE', 'secrets.json');
58
+
59
+ let config = { widget: {}, server: {} };
60
+ let secrets = {};
61
+ /** Valor que tenía cada clave en el entorno antes de aplicar el backoffice. */
62
+ const original = {};
63
+
64
+ function readJsonFile(path, fallback) {
65
+ if (!existsSync(path)) return fallback;
66
+ try {
67
+ return JSON.parse(readFileSync(path, 'utf8'));
68
+ } catch (e) {
69
+ console.warn('[7ots] no se pudo leer', path, e.message);
70
+ return fallback;
71
+ }
72
+ }
73
+
74
+ function writeJsonFile(path, data, mode) {
75
+ mkdirSync(dirname(path), { recursive: true });
76
+ const tmp = `${path}.tmp`;
77
+ writeFileSync(tmp, JSON.stringify(data, null, 2), mode ? { mode } : undefined);
78
+ if (mode) chmodSync(tmp, mode);
79
+ renameSync(tmp, path);
80
+ }
81
+
82
+ /** Lee data/config.json y data/secrets.json y los aplica sobre process.env. Llamar al arrancar. */
83
+ export function loadSettings() {
84
+ for (const k of Object.keys(SERVER_KEYS)) if (!(k in original)) original[k] = env[k];
85
+ const c = readJsonFile(configFile(), {});
86
+ config = { widget: isObj(c.widget) ? c.widget : {}, server: isObj(c.server) ? clean(c.server, false) : {} };
87
+ secrets = clean(readJsonFile(secretsFile(), {}), true);
88
+ for (const k of Object.keys(SERVER_KEYS)) apply(k);
89
+ }
90
+
91
+ function clean(obj, secret) {
92
+ const out = {};
93
+ for (const [k, v] of Object.entries(obj || {})) {
94
+ if (SERVER_KEYS[k] && !!SERVER_KEYS[k].secret === secret && v != null) out[k] = String(v);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ function apply(k) {
100
+ const v = SERVER_KEYS[k].secret ? secrets[k] : config.server[k];
101
+ if (v !== undefined) env[k] = v;
102
+ else if (original[k] === undefined) delete env[k];
103
+ else env[k] = original[k];
104
+ }
105
+
106
+ // ───────────────────────────── widget ─────────────────────────────
107
+
108
+ /** Configuración pública del widget (la que se inyecta en la página). */
109
+ export function widgetConfig() {
110
+ return structuredClone(config.widget);
111
+ }
112
+
113
+ export function saveWidget(widget) {
114
+ if (!isObj(widget)) throw i18nError(400, 'server.admin.invalidWidget');
115
+ config.widget = sanitizeWidget(widget);
116
+ writeJsonFile(configFile(), config);
117
+ return widgetConfig();
118
+ }
119
+
120
+ /** Solo datos: fuera funciones, claves internas y lo que no puede venir del servidor. */
121
+ function sanitizeWidget(w) {
122
+ const out = JSON.parse(JSON.stringify(w)); // descarta funciones/undefined
123
+ for (const k of ['endpoint', 'llm', 'auth', 'templates', 'actions', 'identity']) delete out[k];
124
+ return out;
125
+ }
126
+
127
+ // ───────────────────────────── servidor ─────────────────────────────
128
+
129
+ /** Vista para el backoffice: valores (no secretos) y de dónde salen; de los secretos, solo si están. */
130
+ export function serverView() {
131
+ const values = {};
132
+ const secretState = {};
133
+ for (const [k, meta] of Object.entries(SERVER_KEYS)) {
134
+ const source = (meta.secret ? secrets[k] : config.server[k]) !== undefined ? 'backoffice' : original[k] !== undefined && original[k] !== '' ? 'entorno' : null;
135
+ if (meta.secret) secretState[k] = { set: !!env[k], source, restart: !!meta.restart };
136
+ else values[k] = { value: env[k] ?? '', source, restart: !!meta.restart };
137
+ }
138
+ return { values, secrets: secretState };
139
+ }
140
+
141
+ /**
142
+ * Cambia variables del servidor.
143
+ * @param {Record<string, string|null>} values null = quitar del backoffice (vuelve la del entorno)
144
+ * @param {Record<string, string|null>} secretValues '' se ignora (no cambia); null la borra
145
+ * @returns {{ changed: string[], channels: boolean, restart: string[] }}
146
+ */
147
+ export function saveServer(values = {}, secretValues = {}) {
148
+ const changed = [];
149
+ for (const [k, v] of Object.entries(values || {})) {
150
+ const meta = SERVER_KEYS[k];
151
+ if (!meta || meta.secret) continue;
152
+ if (v === null) delete config.server[k];
153
+ else config.server[k] = String(v).slice(0, 20000);
154
+ changed.push(k);
155
+ }
156
+ for (const [k, v] of Object.entries(secretValues || {})) {
157
+ const meta = SERVER_KEYS[k];
158
+ if (!meta?.secret || v === '') continue;
159
+ if (v === null) delete secrets[k];
160
+ else secrets[k] = String(v).trim().slice(0, 4000);
161
+ changed.push(k);
162
+ }
163
+ const before = Object.fromEntries(changed.map((k) => [k, env[k]]));
164
+ writeJsonFile(configFile(), config);
165
+ writeJsonFile(secretsFile(), secrets, 0o600);
166
+ for (const k of changed) apply(k);
167
+ const real = changed.filter((k) => before[k] !== env[k]);
168
+ return {
169
+ changed: real,
170
+ channels: real.some((k) => SERVER_KEYS[k].channels),
171
+ restart: real.filter((k) => SERVER_KEYS[k].restart),
172
+ };
173
+ }
174
+
175
+ function isObj(v) {
176
+ return !!v && typeof v === 'object' && !Array.isArray(v);
177
+ }