@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.
- package/.env.example +140 -0
- package/LICENSE +21 -0
- package/README.md +655 -0
- package/brand/apple-touch-icon.png +0 -0
- package/brand/favicon.svg +4 -0
- package/brand/icon-512.png +0 -0
- package/brand/mark-mono.svg +3 -0
- package/brand/mark.svg +4 -0
- package/brand/og-es.png +0 -0
- package/brand/og-pt.png +0 -0
- package/brand/og.png +0 -0
- package/brand/ots.json +11 -0
- package/brand/tokens.css +59 -0
- package/brand/wordmark.svg +5 -0
- package/cli/7ots.mjs +400 -0
- package/cli/lib/brain.mjs +143 -0
- package/cli/lib/config.mjs +70 -0
- package/cli/lib/hooks.mjs +151 -0
- package/cli/lib/i18n.mjs +480 -0
- package/cli/lib/identity.mjs +51 -0
- package/cli/lib/install.mjs +79 -0
- package/cli/lib/lines.mjs +73 -0
- package/cli/lib/meet.mjs +166 -0
- package/cli/lib/open.mjs +19 -0
- package/cli/lib/orquesta.mjs +84 -0
- package/cli/lib/paths.mjs +49 -0
- package/cli/lib/pet-core.mjs +184 -0
- package/cli/lib/pet-server.mjs +296 -0
- package/cli/lib/prompts.mjs +75 -0
- package/cli/lib/terminal.mjs +145 -0
- package/cli/lib/ui.mjs +234 -0
- package/cli/lib/wizard.mjs +279 -0
- package/cli/pet/electron/main.cjs +86 -0
- package/cli/pet/pet.html +404 -0
- package/dist/7ots.esm.js +627 -0
- package/dist/7ots.esm.js.map +7 -0
- package/dist/7ots.iife.js +627 -0
- package/dist/7ots.iife.js.map +7 -0
- package/llms.txt +57 -0
- package/package.json +73 -0
- package/server/admin.mjs +213 -0
- package/server/apuchat-relay.mjs +170 -0
- package/server/channels/agent.mjs +136 -0
- package/server/channels/apuchat.mjs +364 -0
- package/server/channels/apumail.mjs +211 -0
- package/server/channels/index.mjs +69 -0
- package/server/contact.mjs +151 -0
- package/server/demo.mjs +154 -0
- package/server/i18n.mjs +54 -0
- package/server/identity.mjs +108 -0
- package/server/llm.mjs +413 -0
- package/server/platform/auth.mjs +290 -0
- package/server/platform/crypto.mjs +80 -0
- package/server/platform/db.mjs +130 -0
- package/server/platform/routes.mjs +388 -0
- package/server/platform/runtime.mjs +180 -0
- package/server/platform/store.mjs +331 -0
- package/server/sdk.mjs +125 -0
- package/server/server.mjs +456 -0
- package/server/settings.mjs +177 -0
- package/server/tts.mjs +186 -0
- package/skills/7ots/SKILL.md +59 -0
- package/src/actions/ActionRegistry.js +254 -0
- package/src/actions/PageTools.js +270 -0
- package/src/actions/builtins.js +565 -0
- package/src/auth/AuthManager.js +128 -0
- package/src/avatar/AvatarStage.js +473 -0
- package/src/character/AvatarEditor.js +829 -0
- package/src/character/Character.js +1191 -0
- package/src/character/motion.js +794 -0
- package/src/character/parts.js +559 -0
- package/src/context/ContextManager.js +472 -0
- package/src/core/AgentBrain.js +341 -0
- package/src/core/AgentWidget.js +1059 -0
- package/src/core/EventBus.js +61 -0
- package/src/core/Proactivity.js +363 -0
- package/src/core/storage.js +35 -0
- package/src/i18n/index.js +193 -0
- package/src/i18n/messages/character.js +543 -0
- package/src/i18n/messages/identity.js +141 -0
- package/src/i18n/messages/platform.js +114 -0
- package/src/i18n/messages/server.js +630 -0
- package/src/i18n/messages/widget.js +362 -0
- package/src/identity/ContactCard.js +351 -0
- package/src/identity/Face.js +148 -0
- package/src/identity/random.js +112 -0
- package/src/identity/schema.js +262 -0
- package/src/index.js +117 -0
- package/src/integrations/apuchat.js +212 -0
- package/src/integrations/apumail.js +88 -0
- package/src/llm/ProxyLLM.js +87 -0
- package/src/mcp/McpClient.js +153 -0
- package/src/ui/Companion.js +498 -0
- package/src/ui/UIManager.js +425 -0
- package/src/ui/VirtualPointer.js +746 -0
- package/src/ui/markdown.js +94 -0
- package/src/voice/VoiceEngine.js +227 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contactabilidad: Apumail (correo/tickets) y Apuchat (derivar a una persona).
|
|
3
|
+
*
|
|
4
|
+
* Todo lo sensible se queda aquí: API keys, buzón de destino, identidad del notificador
|
|
5
|
+
* y el enlace de operador de la videollamada. Al navegador solo vuelve lo mínimo.
|
|
6
|
+
*
|
|
7
|
+
* Idiomas: los errores llevan clave (se traducen al idioma del visitante al responder); el correo
|
|
8
|
+
* y el DM al equipo salen en el idioma del servidor (LOCALE, ver server/i18n.mjs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { openHandoff } from './apuchat-relay.mjs';
|
|
12
|
+
import { i18nError, teamT } from './i18n.mjs';
|
|
13
|
+
|
|
14
|
+
const HUB = (env) => (env.APUCHAT_HUB || 'https://apuchat.com').replace(/\/$/, '');
|
|
15
|
+
|
|
16
|
+
// ───────────────────────────── Apumail ─────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Envía el ticket como un correo normal desde un buzón de apumail.com que es tuyo.
|
|
20
|
+
* (apumail no tiene endpoint de "tickets": se usa la API de envío del buzón.)
|
|
21
|
+
*
|
|
22
|
+
* POST {APUMAIL_API}/api/v1/inbox/{APUMAIL_INBOX}/send
|
|
23
|
+
* Authorization: Bearer {APUMAIL_INBOX_TOKEN} (token del buzón o PAT acct_… de la cuenta dueña)
|
|
24
|
+
* { to, subject (≤256), text (≤100k), reply_to } → { ok, provider_id, message_id }
|
|
25
|
+
*
|
|
26
|
+
* El remitente siempre es el propio buzón. Usa un buzón permanente o de dominio propio:
|
|
27
|
+
* los buzones gratis caducan a las 24 h sin actividad y solo envían 5 destinatarios/día.
|
|
28
|
+
*/
|
|
29
|
+
export async function sendApumail(body, env = process.env) {
|
|
30
|
+
if (!env.APUMAIL_INBOX || !env.APUMAIL_INBOX_TOKEN || !env.APUMAIL_TO) {
|
|
31
|
+
throw i18nError(501, 'server.contact.mailNotConfigured');
|
|
32
|
+
}
|
|
33
|
+
const subject = clip(body.subject, 160);
|
|
34
|
+
const message = clip(body.message, 5000);
|
|
35
|
+
const replyTo = clip(body.replyTo, 200);
|
|
36
|
+
// apumail no valida reply_to: se valida aquí.
|
|
37
|
+
if (!subject || !message || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(replyTo)) throw i18nError(400, 'server.contact.mailMissing');
|
|
38
|
+
|
|
39
|
+
const api = (env.APUMAIL_API || 'https://api.apumail.com').replace(/\/$/, '');
|
|
40
|
+
const res = await fetch(`${api}/api/v1/inbox/${encodeURIComponent(env.APUMAIL_INBOX.toLowerCase())}/send`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.APUMAIL_INBOX_TOKEN}` },
|
|
43
|
+
body: JSON.stringify(buildApumailPayload({ ...body, subject, message, replyTo, to: env.APUMAIL_TO })),
|
|
44
|
+
signal: AbortSignal.timeout(15000),
|
|
45
|
+
});
|
|
46
|
+
const out = await res.json().catch(() => ({}));
|
|
47
|
+
if (res.status === 429) {
|
|
48
|
+
const wait = out.retry_after_seconds || Number(res.headers.get('Retry-After')) || null;
|
|
49
|
+
throw wait ? i18nError(429, 'server.contact.mailRateLimitedWait', { minutes: Math.ceil(wait / 60) }) : i18nError(429, 'server.contact.mailRateLimited');
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
console.error('[7ots] apumail', res.status, out.code || '', out.error || '');
|
|
53
|
+
throw i18nError(502, 'server.contact.mailFailed');
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, ticketId: out.message_id || out.provider_id || null };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Cuerpo para /inbox/:address/send. Solo texto: el contenido viene del usuario y del LLM. */
|
|
59
|
+
export function buildApumailPayload({ to, subject, message, replyTo, category, user, page, transcript = [] }) {
|
|
60
|
+
const t = teamT(); // lo lee el equipo: idioma del servidor
|
|
61
|
+
const cat = category || t('server.contact.mail.otherCategory');
|
|
62
|
+
const convo = transcript
|
|
63
|
+
.slice(-40)
|
|
64
|
+
.map((m) => `${m.from === 'user' ? t('server.contact.mail.user') : m.from === 'agent' ? t('server.contact.mail.agent') : m.name || t('server.contact.mail.team')}: ${clip(m.text, 1000)}`)
|
|
65
|
+
.join('\n');
|
|
66
|
+
const text = [
|
|
67
|
+
message,
|
|
68
|
+
'',
|
|
69
|
+
t('server.contact.mail.category', { category: cat }),
|
|
70
|
+
t('server.contact.mail.page', { url: clip(page?.url, 500) }),
|
|
71
|
+
user ? t('server.contact.mail.userLine', { user: clip(JSON.stringify(user), 500) }) : t('server.contact.mail.anonymous'),
|
|
72
|
+
'',
|
|
73
|
+
convo ? `${t('server.contact.mail.conversation')}\n${convo}` : '',
|
|
74
|
+
].join('\n');
|
|
75
|
+
return {
|
|
76
|
+
to, // fijo en el servidor (APUMAIL_TO): el LLM nunca elige el destinatario
|
|
77
|
+
subject: clip(`[${cat}] ${subject}`, 256),
|
|
78
|
+
text: text.slice(0, 100000),
|
|
79
|
+
reply_to: replyTo, // responder al correo llega directamente al visitante
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ───────────────────────────── Apuchat ─────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Crea una videollamada efímera en el hub, avisa al operador por DM y abre el relé
|
|
87
|
+
* (server/apuchat-relay.mjs) con la identidad "agent" de la llamada.
|
|
88
|
+
*
|
|
89
|
+
* POST {hub}/api/video-call {transcribe} → {channel_id, channel_token, call_url, call_url_public, agent, phone}
|
|
90
|
+
* POST {hub}/api/dm x-identity-key {to, text} (handle SIN "@", ≤ 4096; 202 aunque el handle no exista)
|
|
91
|
+
*
|
|
92
|
+
* El operador entra con `call_url` (lleva claves y PIN: solo viaja en el DM).
|
|
93
|
+
* Al navegador vuelven un id/token opacos del relé y `call_url_public` (sin secretos: quien
|
|
94
|
+
* lo abre "llama a la puerta" y alguien de dentro le deja pasar).
|
|
95
|
+
*
|
|
96
|
+
* Ojo: el hub limita /api/video-call a 15/min por IP, y todas las peticiones salen de este
|
|
97
|
+
* servidor, así que ese límite es compartido por todos los visitantes.
|
|
98
|
+
*/
|
|
99
|
+
export async function startApuchat(body, env = process.env) {
|
|
100
|
+
const hub = HUB(env);
|
|
101
|
+
const res = await fetch(`${hub}/api/video-call`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
// Solo `false` explícito desactiva la transcripción en el hub.
|
|
105
|
+
body: JSON.stringify({ transcribe: env.APUCHAT_TRANSCRIBE === 'true' }),
|
|
106
|
+
signal: AbortSignal.timeout(15000),
|
|
107
|
+
});
|
|
108
|
+
if (res.status === 429) throw i18nError(429, 'server.contact.chatBusy');
|
|
109
|
+
if (!res.ok) throw i18nError(502, 'server.contact.chatHubError', { status: res.status });
|
|
110
|
+
const call = await res.json();
|
|
111
|
+
|
|
112
|
+
const relay = await openHandoff({
|
|
113
|
+
hub,
|
|
114
|
+
channel_id: call.channel_id,
|
|
115
|
+
channel_token: call.channel_token,
|
|
116
|
+
identity_key: call.agent?.identity_key,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let operatorNotified = false;
|
|
120
|
+
const operator = String(env.APUCHAT_OPERATOR_HANDLE || '').trim().replace(/^@/, '').toLowerCase();
|
|
121
|
+
if (env.APUCHAT_NOTIFIER_IDENTITY_KEY && operator) {
|
|
122
|
+
const t = teamT(); // aviso para el operador: idioma del servidor
|
|
123
|
+
const head = [
|
|
124
|
+
t('server.contact.dm.head', { urgency: clip(body.urgency, 10) || t('server.contact.dm.normal'), reason: clip(body.reason, 200) }),
|
|
125
|
+
body.user?.name ? t('server.contact.dm.user', { name: clip(body.user.name, 80) }) : t('server.contact.dm.visitor'),
|
|
126
|
+
t('server.contact.dm.page', { url: clip(body.page?.url, 300) }),
|
|
127
|
+
].join('\n');
|
|
128
|
+
const link = t('server.contact.dm.link', { url: call.call_url });
|
|
129
|
+
// El enlace va al final y nunca se recorta: se recorta el resumen.
|
|
130
|
+
const room = 4096 - head.length - link.length - 20;
|
|
131
|
+
const text = `${head}\n\n${t('server.contact.dm.summary', { summary: clip(body.summary, Math.max(0, room)) })}\n\n${link}`;
|
|
132
|
+
const dm = await fetch(`${hub}/api/dm`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json', 'x-identity-key': env.APUCHAT_NOTIFIER_IDENTITY_KEY },
|
|
135
|
+
body: JSON.stringify({ to: operator, text }),
|
|
136
|
+
signal: AbortSignal.timeout(15000),
|
|
137
|
+
}).catch(() => null);
|
|
138
|
+
operatorNotified = !!dm && dm.status < 300;
|
|
139
|
+
if (!operatorNotified) console.error('[7ots] apuchat: el DM al operador falló', dm?.status ?? 'sin respuesta');
|
|
140
|
+
} else {
|
|
141
|
+
console.warn('[7ots] apuchat: falta APUCHAT_NOTIFIER_IDENTITY_KEY/APUCHAT_OPERATOR_HANDLE; nadie recibe aviso de la derivación.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { ...relay, call_url_public: call.call_url_public || null, operatorNotified };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
148
|
+
|
|
149
|
+
function clip(s, n) {
|
|
150
|
+
return String(s ?? '').slice(0, n).trim();
|
|
151
|
+
}
|
package/server/demo.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend de DEMOSTRACIÓN del sitio anfitrión ("Nimbus", un SaaS ficticio).
|
|
3
|
+
* No es parte del framework: simula lo que ya tendría tu sitio para probar
|
|
4
|
+
* la integración de extremo a extremo:
|
|
5
|
+
*
|
|
6
|
+
* POST /api/demo/login {email} → { token } JWT HS256 de 1 h (sin contraseña: es demo)
|
|
7
|
+
* GET /api/demo/balance Bearer JWT → saldo del usuario (acción personalizada del ejemplo)
|
|
8
|
+
* POST /mcp Bearer JWT → servidor MCP (Streamable HTTP, JSON) con tools de la cuenta
|
|
9
|
+
*
|
|
10
|
+
* En producción, tu backend verifica el JWT con su propia clave/JWKS; aquí el secreto
|
|
11
|
+
* es DEMO_JWT_SECRET o uno aleatorio por arranque.
|
|
12
|
+
* Los textos (errores, títulos de las tools MCP) salen en el idioma de Accept-Language.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
16
|
+
import { reqT } from './i18n.mjs';
|
|
17
|
+
|
|
18
|
+
const SECRET = process.env.DEMO_JWT_SECRET || randomBytes(32).toString('hex');
|
|
19
|
+
const sessions = new Set();
|
|
20
|
+
const accounts = new Map(); // email → { plan, balance, orders }
|
|
21
|
+
|
|
22
|
+
const b64u = (b) => Buffer.from(b).toString('base64url');
|
|
23
|
+
|
|
24
|
+
export function signJwt(payload, ttlSec = 3600) {
|
|
25
|
+
const now = Math.floor(Date.now() / 1000);
|
|
26
|
+
const head = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
27
|
+
const body = b64u(JSON.stringify({ iat: now, exp: now + ttlSec, ...payload }));
|
|
28
|
+
const sig = createHmac('sha256', SECRET).update(`${head}.${body}`).digest('base64url');
|
|
29
|
+
return `${head}.${body}.${sig}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyJwt(token) {
|
|
33
|
+
const [head, body, sig] = String(token || '').split('.');
|
|
34
|
+
if (!sig) return null;
|
|
35
|
+
const expected = createHmac('sha256', SECRET).update(`${head}.${body}`).digest();
|
|
36
|
+
const got = Buffer.from(sig, 'base64url');
|
|
37
|
+
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
|
|
38
|
+
const claims = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
39
|
+
if (claims.exp && claims.exp * 1000 < Date.now()) return null;
|
|
40
|
+
return claims;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function account(email) {
|
|
44
|
+
if (!accounts.has(email)) {
|
|
45
|
+
accounts.set(email, {
|
|
46
|
+
plan: 'Starter',
|
|
47
|
+
balance: 42.5,
|
|
48
|
+
orders: [
|
|
49
|
+
{ id: 'NB-1042', fecha: '2026-09-02', total: 19, estado: 'pagado' },
|
|
50
|
+
{ id: 'NB-1077', fecha: '2026-09-21', total: 49, estado: 'pendiente' },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return accounts.get(email);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bearer = (req) => verifyJwt((req.headers.authorization || '').replace(/^Bearer\s+/i, ''));
|
|
58
|
+
|
|
59
|
+
/** @returns {boolean} true si la ruta era de la demo */
|
|
60
|
+
export async function handleDemo(req, res, url, { readJson, send }) {
|
|
61
|
+
const t = reqT(req);
|
|
62
|
+
if (url.pathname === '/api/demo/login' && req.method === 'POST') {
|
|
63
|
+
const { email } = await readJson(req, 4096);
|
|
64
|
+
const clean = String(email || '').trim().toLowerCase();
|
|
65
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(clean)) return send(res, 400, { error: t('server.demo.badEmail') }), true;
|
|
66
|
+
const name = clean.split('@')[0].replace(/[._-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
67
|
+
return send(res, 200, { token: signJwt({ sub: clean, email: clean, name, plan: account(clean).plan }) }), true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (url.pathname === '/api/demo/balance' && req.method === 'GET') {
|
|
71
|
+
const user = bearer(req);
|
|
72
|
+
if (!user) return send(res, 401, { error: t('server.demo.unauthorized') }), true;
|
|
73
|
+
const a = account(user.email);
|
|
74
|
+
return send(res, 200, { email: user.email, plan: a.plan, saldo: a.balance, moneda: 'EUR', proximaFactura: '2026-10-01' }), true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (url.pathname === '/mcp') {
|
|
78
|
+
await handleMcp(req, res, { readJson, send, t });
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ───────────────────────────── MCP de demo ─────────────────────────────
|
|
85
|
+
|
|
86
|
+
const mcpTools = (t) => [
|
|
87
|
+
{
|
|
88
|
+
name: 'listar_pedidos',
|
|
89
|
+
title: t('server.demo.tools.listTitle'),
|
|
90
|
+
description: t('server.demo.tools.listDescription'),
|
|
91
|
+
inputSchema: { type: 'object', properties: { estado: { type: 'string', enum: ['pagado', 'pendiente', 'todos'] } } },
|
|
92
|
+
annotations: { readOnlyHint: true },
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'cambiar_plan',
|
|
96
|
+
title: t('server.demo.tools.planTitle'),
|
|
97
|
+
description: t('server.demo.tools.planDescription'),
|
|
98
|
+
inputSchema: { type: 'object', properties: { plan: { type: 'string', enum: ['Starter', 'Pro', 'Business'] } }, required: ['plan'] },
|
|
99
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
async function handleMcp(req, res, { readJson, send, t }) {
|
|
104
|
+
if (req.method === 'DELETE') {
|
|
105
|
+
sessions.delete(req.headers['mcp-session-id']);
|
|
106
|
+
return send(res, 204);
|
|
107
|
+
}
|
|
108
|
+
if (req.method !== 'POST') return send(res, 405, { error: t('server.demo.postOnly') });
|
|
109
|
+
const user = bearer(req);
|
|
110
|
+
if (!user) {
|
|
111
|
+
res.setHeader('WWW-Authenticate', 'Bearer');
|
|
112
|
+
return send(res, 401, { error: t('server.demo.unauthorized') });
|
|
113
|
+
}
|
|
114
|
+
const msg = await readJson(req, 64 * 1024);
|
|
115
|
+
const sid = req.headers['mcp-session-id'];
|
|
116
|
+
// Como un servidor real: una sesión desconocida (p. ej. tras reiniciar) → 404 y el cliente re-inicializa.
|
|
117
|
+
if (msg.method !== 'initialize' && sid && !sessions.has(sid)) return send(res, 404, { error: t('server.demo.unknownSession') });
|
|
118
|
+
const reply = (result) => send(res, 200, { jsonrpc: '2.0', id: msg.id, result });
|
|
119
|
+
const fail = (code, message) => send(res, 200, { jsonrpc: '2.0', id: msg.id, error: { code, message } });
|
|
120
|
+
|
|
121
|
+
switch (msg.method) {
|
|
122
|
+
case 'initialize': {
|
|
123
|
+
const sid = randomUUID();
|
|
124
|
+
sessions.add(sid);
|
|
125
|
+
res.setHeader('Mcp-Session-Id', sid);
|
|
126
|
+
return reply({
|
|
127
|
+
protocolVersion: msg.params?.protocolVersion || '2025-06-18',
|
|
128
|
+
capabilities: { tools: {} },
|
|
129
|
+
serverInfo: { name: 'nimbus-demo-mcp', version: '1.0.0' },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
case 'notifications/initialized':
|
|
133
|
+
return send(res, 202);
|
|
134
|
+
case 'tools/list':
|
|
135
|
+
return reply({ tools: mcpTools(t) });
|
|
136
|
+
case 'tools/call': {
|
|
137
|
+
const a = account(user.email);
|
|
138
|
+
const { name, arguments: args = {} } = msg.params || {};
|
|
139
|
+
if (name === 'listar_pedidos') {
|
|
140
|
+
const list = !args.estado || args.estado === 'todos' ? a.orders : a.orders.filter((o) => o.estado === args.estado);
|
|
141
|
+
return reply({ content: [{ type: 'text', text: JSON.stringify(list) }], structuredContent: { pedidos: list } });
|
|
142
|
+
}
|
|
143
|
+
if (name === 'cambiar_plan') {
|
|
144
|
+
a.plan = args.plan;
|
|
145
|
+
return reply({ content: [{ type: 'text', text: t('server.demo.planChanged', { plan: args.plan }) }], structuredContent: { plan: a.plan } });
|
|
146
|
+
}
|
|
147
|
+
return reply({ content: [{ type: 'text', text: t('server.demo.unknownTool', { name }) }], isError: true });
|
|
148
|
+
}
|
|
149
|
+
case 'ping':
|
|
150
|
+
return reply({});
|
|
151
|
+
default:
|
|
152
|
+
return fail(-32601, t('server.demo.unsupported', { method: msg.method }));
|
|
153
|
+
}
|
|
154
|
+
}
|
package/server/i18n.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n del proxy: qué idioma usar en cada respuesta.
|
|
3
|
+
*
|
|
4
|
+
* - Visitante y backoffice → la cabecera Accept-Language de la petición (el widget manda su
|
|
5
|
+
* idioma y el backoffice el que tenga elegido). Sin cabecera: español.
|
|
6
|
+
* - Equipo (correo a APUMAIL_TO, DM al operador, avisos del relé) y consola → idioma del
|
|
7
|
+
* servidor: LOCALE (backoffice o entorno); si no hay, el idioma de la identidad; si no, español.
|
|
8
|
+
*
|
|
9
|
+
* Los errores que salen de módulos sin acceso a la petición se crean con `i18nError()`: llevan
|
|
10
|
+
* la clave y se traducen al responder (server.mjs), así el log sigue en español.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveLocale, translator } from '../src/i18n/index.js';
|
|
14
|
+
import '../src/i18n/messages/server.js';
|
|
15
|
+
import { getIdentity } from './identity.mjs';
|
|
16
|
+
|
|
17
|
+
/** Traductor fijo en español (mensajes de Error y registros). */
|
|
18
|
+
export const tEs = translator('es');
|
|
19
|
+
|
|
20
|
+
/** Idioma pedido por el cliente (Accept-Language). */
|
|
21
|
+
export function requestLocale(req) {
|
|
22
|
+
return resolveLocale(req?.headers?.['accept-language']);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Traductor para responder a esta petición. */
|
|
26
|
+
export function reqT(req) {
|
|
27
|
+
return translator(requestLocale(req));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Idioma del servidor: LOCALE → idioma de la identidad → español. Se lee en cada uso (sin reinicio). */
|
|
31
|
+
export function serverLocale() {
|
|
32
|
+
let lang;
|
|
33
|
+
try {
|
|
34
|
+
lang = getIdentity().language;
|
|
35
|
+
} catch {
|
|
36
|
+
/* sin identidad legible: se queda el de LOCALE o español */
|
|
37
|
+
}
|
|
38
|
+
return resolveLocale(process.env.LOCALE, lang);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Traductor para lo que lee el equipo (correos, avisos) y la consola. */
|
|
42
|
+
export function teamT() {
|
|
43
|
+
return translator(serverLocale());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Error HTTP con texto traducible: `message` en español y la clave en `i18n`. */
|
|
47
|
+
export function i18nError(status, key, vars) {
|
|
48
|
+
return Object.assign(new Error(tEs(key, vars)), { status, i18n: { key, vars } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Texto de un error en el idioma de `t` (si trae clave) o su mensaje tal cual. */
|
|
52
|
+
export function errorText(err, t) {
|
|
53
|
+
return err?.i18n ? t(err.i18n.key, err.i18n.vars) : String(err?.message || err);
|
|
54
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identidad del agente: quién es, cómo se ve, cómo suena y cómo se le contacta.
|
|
3
|
+
*
|
|
4
|
+
* Una sola ficha declarativa que usan todas las piezas de 7ots:
|
|
5
|
+
* - el widget web (nombre, colores, avatar 3D/2D, voz),
|
|
6
|
+
* - los canales (persona en correo/chat/llamada, avatar de meet, firma),
|
|
7
|
+
* - el TTS del proxy (proveedor, voz y estilo),
|
|
8
|
+
* - la tarjeta pública (GET /api/agent/identity, vCard, /.well-known/7ots-agent.json).
|
|
9
|
+
*
|
|
10
|
+
* Orden de carga (lo de la derecha gana): valores por defecto ← variables de entorno ← data/identity.json
|
|
11
|
+
* El backoffice escribe data/identity.json; IDENTITY_FILE cambia la ruta.
|
|
12
|
+
*
|
|
13
|
+
* La identidad NUNCA contiene secretos: las claves de apumail/apuchat/TTS siguen en el entorno.
|
|
14
|
+
* Aun así, publicIdentity() construye la tarjeta con una lista blanca de campos.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
18
|
+
import { dirname, join, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { defineIdentity } from '../src/identity/schema.js';
|
|
21
|
+
|
|
22
|
+
export * from '../src/identity/schema.js';
|
|
23
|
+
export * from '../src/identity/random.js';
|
|
24
|
+
|
|
25
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
|
|
27
|
+
/** Identidad a partir de las variables de entorno de siempre (AGENT_*, APUCHAT_AGENT_*, TTS). */
|
|
28
|
+
export function identityFromEnv(env = process.env) {
|
|
29
|
+
const provider = (env.TTS_PROVIDER || '').toLowerCase();
|
|
30
|
+
return defineIdentity({
|
|
31
|
+
name: env.AGENT_NAME || undefined,
|
|
32
|
+
role: env.AGENT_ROLE || undefined,
|
|
33
|
+
language: env.AGENT_LANG || undefined,
|
|
34
|
+
personality: { instructions: env.AGENT_INSTRUCTIONS || '' },
|
|
35
|
+
look: { meetAvatar: env.APUCHAT_AGENT_AVATAR || undefined, meetScene: env.APUCHAT_AGENT_SCENE || '' },
|
|
36
|
+
voice: {
|
|
37
|
+
voiceId:
|
|
38
|
+
{
|
|
39
|
+
apuchat: env.APUCHAT_VOICE_ID,
|
|
40
|
+
elevenlabs: env.ELEVENLABS_VOICE_ID,
|
|
41
|
+
grok: env.XAI_TTS_VOICE,
|
|
42
|
+
fish: env.FISH_VOICE_ID,
|
|
43
|
+
openai: env.OPENAI_TTS_VOICE,
|
|
44
|
+
}[provider] || '',
|
|
45
|
+
},
|
|
46
|
+
contact: { site: env.AGENT_SITE_URL || '', email: env.APUMAIL_AGENT_INBOX || '' },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ───────────────────────────── almacén en disco ─────────────────────────────
|
|
51
|
+
|
|
52
|
+
let current = null;
|
|
53
|
+
const listeners = new Set();
|
|
54
|
+
|
|
55
|
+
export function identityFile(env = process.env) {
|
|
56
|
+
return resolve(ROOT, env.IDENTITY_FILE || join('data', 'identity.json'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Identidad vigente (entorno + fichero). Se cachea; setIdentity() la actualiza en caliente. */
|
|
60
|
+
export function getIdentity() {
|
|
61
|
+
if (current) return current;
|
|
62
|
+
const base = identityFromEnv();
|
|
63
|
+
let file = {};
|
|
64
|
+
const path = identityFile();
|
|
65
|
+
if (existsSync(path)) {
|
|
66
|
+
try {
|
|
67
|
+
file = JSON.parse(readFileSync(path, 'utf8'));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn('[7ots] identidad: no se pudo leer', path, e.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
current = defineIdentity(file, base);
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mezcla un cambio (parcial o completo) con la identidad vigente, lo guarda en
|
|
78
|
+
* data/identity.json y lo aplica ya (los canales y el TTS leen siempre la vigente).
|
|
79
|
+
*/
|
|
80
|
+
export function setIdentity(patch) {
|
|
81
|
+
const next = defineIdentity(patch, getIdentity());
|
|
82
|
+
const path = identityFile();
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
const tmp = `${path}.tmp`;
|
|
85
|
+
writeFileSync(tmp, JSON.stringify(next, null, 2));
|
|
86
|
+
renameSync(tmp, path); // escritura atómica
|
|
87
|
+
current = next;
|
|
88
|
+
for (const fn of listeners) fn(next);
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Usa esta identidad en memoria (SDK sin fichero). */
|
|
93
|
+
export function useIdentity(identity) {
|
|
94
|
+
current = Object.defineProperty(defineIdentity(identity), '__custom', { value: true });
|
|
95
|
+
for (const fn of listeners) fn(current);
|
|
96
|
+
return current;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** ¿Hay identidad propia (fichero o AGENT_NAME)? Si no, el proxy no toca el prompt del widget. */
|
|
100
|
+
export function identityConfigured(env = process.env) {
|
|
101
|
+
return !!current?.__custom || existsSync(identityFile(env)) || !!env.AGENT_NAME;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function onIdentityChange(fn) {
|
|
105
|
+
listeners.add(fn);
|
|
106
|
+
return () => listeners.delete(fn);
|
|
107
|
+
}
|
|
108
|
+
|