@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,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal apuchat: el agente tiene su propio @handle.
|
|
3
|
+
*
|
|
4
|
+
* - Mensajes: cualquiera le escribe por la app de apuchat y el agente contesta.
|
|
5
|
+
* - Videollamada: en meet.apuchat.com pulsas 📞 Ring y escribes su @handle → le llega un
|
|
6
|
+
* mensaje con "Channel id / Token / PIN" (o un enlace /call#…) y entra en la llamada con su
|
|
7
|
+
* propio avatar. Tú hablas; meet transcribe, el agente responde y tu navegador lo dice.
|
|
8
|
+
* - Llamada al teléfono: si le pides "llámame", crea una llamada y te manda el enlace por
|
|
9
|
+
* mensaje; eso hace SONAR la app de apuchat del móvil (push VoIP). No hay telefonía
|
|
10
|
+
* clásica (SIP/PSTN): la "llamada" es la de la app.
|
|
11
|
+
*
|
|
12
|
+
* APUCHAT_AGENT_IDENTITY_KEY clave de la identidad del agente (X-Identity-Key). Usa un
|
|
13
|
+
* handle permanente: los gratis caducan tras 24 h sin mensajes
|
|
14
|
+
* (escuchar cuenta como actividad, así que aguanta encendido).
|
|
15
|
+
* APUCHAT_AGENT_ALLOW opcional: @handles que pueden hablarle (coma). Vacío = todos.
|
|
16
|
+
* El avatar y la escena de meet salen de la identidad (look.meetAvatar / look.meetScene;
|
|
17
|
+
* por defecto APUCHAT_AGENT_AVATAR / APUCHAT_AGENT_SCENE) y se leen en cada llamada.
|
|
18
|
+
* AGENT_CALL_MAX_MINUTES (defecto 15) · AGENT_CALL_MAX_TURNS (40) · AGENT_MAX_CALLS (3)
|
|
19
|
+
*
|
|
20
|
+
* Contrato del hub (apuchat.com):
|
|
21
|
+
* GET /api/dm/wait?since&timeout≤300 X-Identity-Key → { callsign, messages:[{id,from,to,text,at}], next_since }
|
|
22
|
+
* POST /api/dm { to, text ≤4096 } X-Identity-Key → 202 siempre (0,5 msg/s por identidad)
|
|
23
|
+
* POST /api/video-call {} → { channel_id, channel_token, owner_password, call_url, … }
|
|
24
|
+
* Canal: /join {identity_key, owner_password?} → session_id · /wait · /send {to:'all', message, kind?} · /leave
|
|
25
|
+
* Tras entrar: ping kind:"status" (así meet lo trata como agente), luego "[avatar]<nombre>".
|
|
26
|
+
* Líneas de control "[…]": se ignoran todas salvo "[lang]<código>".
|
|
27
|
+
*
|
|
28
|
+
* Textos fijos para la persona (avisos, saludo de reserva): en el idioma de la llamada
|
|
29
|
+
* ("[lang]") o, si no, en el de la identidad / LOCALE. Los registros siguen en español.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { resolveLocale, translator } from '../../src/i18n/index.js';
|
|
33
|
+
import { i18nError, serverLocale } from '../i18n.mjs';
|
|
34
|
+
|
|
35
|
+
const env = process.env;
|
|
36
|
+
const DM_GAP_MS = 2100; // 0,5 mensajes/s por identidad
|
|
37
|
+
const DM_MAX = 4000;
|
|
38
|
+
const BACKLOG_MAX_AGE_MS = 10 * 60 * 1000;
|
|
39
|
+
const INVITE_MAX_AGE_MS = 3 * 60 * 1000; // una invitación vieja es una llamada que ya acabó
|
|
40
|
+
const PER_SENDER_PER_HOUR = 30;
|
|
41
|
+
/** Configuración del canal desde el entorno (el SDK puede pasar la suya). */
|
|
42
|
+
export function apuchatConfigFromEnv(e = env) {
|
|
43
|
+
return {
|
|
44
|
+
hub: e.APUCHAT_HUB || 'https://apuchat.com',
|
|
45
|
+
identityKey: e.APUCHAT_AGENT_IDENTITY_KEY || '',
|
|
46
|
+
allow: e.APUCHAT_AGENT_ALLOW || '',
|
|
47
|
+
maxCalls: Number(e.AGENT_MAX_CALLS || 3),
|
|
48
|
+
maxMinutes: Number(e.AGENT_CALL_MAX_MINUTES || 15),
|
|
49
|
+
maxTurns: Number(e.AGENT_CALL_MAX_TURNS || 40),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function apuchatConfigured(cfg = apuchatConfigFromEnv()) {
|
|
54
|
+
return !!cfg.identityKey;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, persona: Function, log: Function, config?: object }} deps
|
|
59
|
+
*/
|
|
60
|
+
export function createApuchatChannel({ agent, persona, log, config = apuchatConfigFromEnv() }) {
|
|
61
|
+
const cfg = { ...apuchatConfigFromEnv({}), ...config };
|
|
62
|
+
const key = cfg.identityKey;
|
|
63
|
+
const hub = String(cfg.hub).replace(/\/$/, '');
|
|
64
|
+
const hubFetch = (method, path, o) => hubRequest(hub, method, path, o);
|
|
65
|
+
const allow = (Array.isArray(cfg.allow) ? cfg.allow : String(cfg.allow || '').split(',')).map((s) => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean);
|
|
66
|
+
const { maxCalls, maxMinutes, maxTurns } = cfg;
|
|
67
|
+
|
|
68
|
+
const stats = { callsign: null, dmsIn: 0, dmsOut: 0, calls: 0, lastError: null };
|
|
69
|
+
const calls = new Map(); // channel_id → sesión de llamada
|
|
70
|
+
const rate = new Map(); // handle → { n, at }
|
|
71
|
+
let stopped = false;
|
|
72
|
+
|
|
73
|
+
// ── DMs: cola de salida para respetar el límite del hub ──
|
|
74
|
+
let queue = Promise.resolve();
|
|
75
|
+
function dm(to, text) {
|
|
76
|
+
const job = queue.then(async () => {
|
|
77
|
+
const res = await hubFetch('POST', '/api/dm', { headers: { 'X-Identity-Key': key }, body: { to, text: String(text).slice(0, DM_MAX) } });
|
|
78
|
+
if (!res.ok) throw new Error(`dm ${res.status}`);
|
|
79
|
+
stats.dmsOut++;
|
|
80
|
+
await sleep(DM_GAP_MS);
|
|
81
|
+
});
|
|
82
|
+
queue = job.catch(() => sleep(DM_GAP_MS));
|
|
83
|
+
return job;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const allowed = (from) => {
|
|
87
|
+
if (allow.length && !allow.includes(from)) return false;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const r = rate.get(from);
|
|
90
|
+
if (!r || now - r.at > 3600_000) {
|
|
91
|
+
rate.set(from, { n: 1, at: now });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return ++r.n <= PER_SENDER_PER_HOUR;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── herramienta que el agente puede usar en un chat: llamar a quien le escribe ──
|
|
98
|
+
const ringTool = {
|
|
99
|
+
name: 'llamar_por_apuchat',
|
|
100
|
+
description:
|
|
101
|
+
'Llama por voz a la persona con la que hablas: crea una videollamada de meet.apuchat.com con tu avatar y ' +
|
|
102
|
+
'le manda el enlace, lo que hace sonar su app de apuchat. Úsalo cuando pida que la llames o hablar por voz.',
|
|
103
|
+
parameters: { type: 'object', properties: { motivo: { type: 'string', description: 'De qué vais a hablar (breve)' } } },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Crea una videollamada de meet con el agente ya dentro y devuelve el enlace para entrar.
|
|
108
|
+
* El enlace (`call_url`) lleva el token y el PIN de ESA llamada: solo se entrega a la persona
|
|
109
|
+
* para la que se creó (por DM o a quien la pidió desde la web), nunca a terceros.
|
|
110
|
+
*/
|
|
111
|
+
async function openCall({ peer = null, reason = '' } = {}) {
|
|
112
|
+
if (calls.size >= maxCalls) throw i18nError(503, 'server.channels.tooManyCalls');
|
|
113
|
+
const res = await hubFetch('POST', '/api/video-call', { body: {} });
|
|
114
|
+
if (!res.ok || !res.data?.channel_id) throw i18nError(502, 'server.channels.callFailed', { status: res.status });
|
|
115
|
+
const c = res.data;
|
|
116
|
+
// Primero entra el agente (así ya está cuando la persona descuelga).
|
|
117
|
+
startCall({ channel_id: c.channel_id, token: c.channel_token, pin: c.owner_password, peer, reason }).catch((e) => log('apuchat call:', e.message));
|
|
118
|
+
return { call_url: c.call_url, channel_id: c.channel_id, expires_at: c.expires_at || null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function ring(handle, motivo = '') {
|
|
122
|
+
const c = await openCall({ peer: handle, reason: motivo });
|
|
123
|
+
const t = langT(persona().lang);
|
|
124
|
+
await dm(handle, motivo ? t('server.channels.ringAbout', { topic: motivo, url: c.call_url }) : t('server.channels.ring', { url: c.call_url })); // suena la app
|
|
125
|
+
return { ok: true, llamando_a: `@${handle}` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── bucle de mensajes ──
|
|
129
|
+
async function listen() {
|
|
130
|
+
let since = 0;
|
|
131
|
+
let first = true;
|
|
132
|
+
while (!stopped) {
|
|
133
|
+
try {
|
|
134
|
+
const res = await hubFetch('GET', `/api/dm/wait?timeout=60${since ? `&since=${since}` : ''}`, { headers: { 'X-Identity-Key': key }, timeoutMs: 75000 });
|
|
135
|
+
if (res.status === 401 || res.status === 403) {
|
|
136
|
+
stats.lastError = `wait ${res.status}`;
|
|
137
|
+
log(`apuchat: la identidad del agente no es válida (${res.status}); se deja de escuchar. ¿Caducó? Revisa APUCHAT_AGENT_IDENTITY_KEY.`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (!res.ok) throw new Error(`wait ${res.status}`);
|
|
141
|
+
if (!stats.callsign && res.data.callsign) {
|
|
142
|
+
stats.callsign = res.data.callsign;
|
|
143
|
+
log(`apuchat: el agente atiende como @${stats.callsign} (avatar ${persona().meetAvatar})`);
|
|
144
|
+
}
|
|
145
|
+
for (const m of res.data.messages || []) {
|
|
146
|
+
stats.dmsIn++;
|
|
147
|
+
onDm(m, { backlog: first }).catch((e) => log('apuchat dm:', e.message));
|
|
148
|
+
}
|
|
149
|
+
since = res.data.next_since ?? since;
|
|
150
|
+
first = false;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
stats.lastError = e.message;
|
|
153
|
+
await sleep(5000);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function onDm(m, { backlog }) {
|
|
159
|
+
const from = String(m.from || '').toLowerCase();
|
|
160
|
+
const text = String(m.text || '').trim();
|
|
161
|
+
if (!from || from === stats.callsign || !text) return;
|
|
162
|
+
const age = Date.now() - Number(m.at || 0);
|
|
163
|
+
const invite = parseInvite(text);
|
|
164
|
+
if (invite) {
|
|
165
|
+
if (age > INVITE_MAX_AGE_MS) return log(`apuchat: invitación antigua de @${from} ignorada`);
|
|
166
|
+
if (!allowed(from)) return;
|
|
167
|
+
log(`apuchat: @${from} llama al agente → entra en la llamada`);
|
|
168
|
+
return startCall({ ...invite, peer: from });
|
|
169
|
+
}
|
|
170
|
+
if (backlog && age > BACKLOG_MAX_AGE_MS) return;
|
|
171
|
+
if (!allowed(from)) return;
|
|
172
|
+
const reply = await agent.respond(`dm:${from}`, 'dm', text, {
|
|
173
|
+
tools: [ringTool],
|
|
174
|
+
run: async (name, args) => {
|
|
175
|
+
if (name !== 'llamar_por_apuchat') throw new Error(`Herramienta desconocida: ${name}`);
|
|
176
|
+
return ring(from, args.motivo);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
if (reply) await dm(from, reply);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── una llamada de meet ──
|
|
183
|
+
async function startCall({ channel_id, token, pin, peer, reason = '' }) {
|
|
184
|
+
if (calls.has(channel_id)) return;
|
|
185
|
+
if (calls.size >= maxCalls) {
|
|
186
|
+
if (peer) await dm(peer, langT(persona().lang)('server.channels.busyDm')).catch(() => {});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const p = persona();
|
|
190
|
+
const s = { ch: channel_id, token, sid: '', cs: '', since: 0, lang: p.lang, turns: 0, start: Date.now(), lastHuman: 0, greeted: false, done: false };
|
|
191
|
+
calls.set(channel_id, s);
|
|
192
|
+
const tc = (k, v) => langT(s.lang)(k, v); // s.lang cambia con "[lang]": se resuelve en cada uso
|
|
193
|
+
stats.calls++;
|
|
194
|
+
const path = (x) => `/api/channels/${encodeURIComponent(channel_id)}${x}`;
|
|
195
|
+
const headers = () => ({ Authorization: `Bearer ${token}`, ...(s.sid ? { 'X-Session-Id': s.sid } : {}) });
|
|
196
|
+
const send = (message, kind) => hubFetch('POST', path('/send'), { headers: headers(), body: { to: 'all', message: String(message).slice(0, 8000), ...(kind ? { kind } : {}) } });
|
|
197
|
+
const join = async () => {
|
|
198
|
+
const r = await hubFetch('POST', path('/join'), { headers: { Authorization: `Bearer ${token}` }, body: { identity_key: key, ...(pin ? { owner_password: pin } : {}) } });
|
|
199
|
+
if (!r.ok) throw new Error(`join ${r.status} ${r.data?.error || ''}`.trim());
|
|
200
|
+
s.sid = r.data.session_id;
|
|
201
|
+
s.cs = r.data.callsign || s.cs;
|
|
202
|
+
for (const m of r.data.history || []) if (m.id > s.since) s.since = m.id;
|
|
203
|
+
};
|
|
204
|
+
const leave = async (why) => {
|
|
205
|
+
if (s.done) return;
|
|
206
|
+
s.done = true;
|
|
207
|
+
calls.delete(channel_id);
|
|
208
|
+
agent.forget(`call:${channel_id}`);
|
|
209
|
+
log(`apuchat: sale de la llamada (${why}, ${s.turns} turnos)`);
|
|
210
|
+
await hubFetch('POST', path('/leave'), { headers: headers(), body: {} }).catch(() => {});
|
|
211
|
+
};
|
|
212
|
+
const say = async (text) => {
|
|
213
|
+
if (text) await send(text);
|
|
214
|
+
};
|
|
215
|
+
const greet = async () => {
|
|
216
|
+
if (s.greeted) return;
|
|
217
|
+
s.greeted = true;
|
|
218
|
+
s.lastHuman = Date.now();
|
|
219
|
+
const hello = await agent
|
|
220
|
+
.respond(`call:${channel_id}`, 'voice', `(La persona${peer ? ` @${peer}` : ''} acaba de entrar en la llamada${reason ? `; ibais a hablar de: ${reason}` : ''}. Salúdala en una frase y pregúntale en qué la ayudas.)`, { lang: s.lang })
|
|
221
|
+
.catch(() => '');
|
|
222
|
+
await say(hello || tc('server.channels.hello', { name: p.name }));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
await join();
|
|
227
|
+
await send(tc('server.channels.here', { name: p.name }), 'status'); // obligatorio: así meet le da avatar y voz
|
|
228
|
+
await send(`[avatar]${p.meetAvatar || 'vivi'}`);
|
|
229
|
+
if (p.meetScene) await send(`[scene]${p.meetScene}`);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
calls.delete(channel_id);
|
|
232
|
+
stats.lastError = e.message;
|
|
233
|
+
log(`apuchat: no pudo entrar en la llamada (${e.message})`);
|
|
234
|
+
if (peer) await dm(peer, tc('server.channels.joinFailedDm')).catch(() => {});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
while (!s.done && !stopped) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (!s.greeted && now - s.start > 120_000) return leave('nadie entró');
|
|
241
|
+
if (s.greeted && now - s.lastHuman > 180_000) {
|
|
242
|
+
await say(tc('server.channels.hangUp'));
|
|
243
|
+
return leave('silencio');
|
|
244
|
+
}
|
|
245
|
+
if (now - s.start > maxMinutes * 60_000 || s.turns >= maxTurns) {
|
|
246
|
+
await say(tc('server.channels.limit'));
|
|
247
|
+
return leave('límite');
|
|
248
|
+
}
|
|
249
|
+
let r;
|
|
250
|
+
try {
|
|
251
|
+
r = await hubFetch('GET', path(`/wait?timeout=25${s.since ? `&since=${s.since}` : ''}`), { headers: headers(), timeoutMs: 40000 });
|
|
252
|
+
} catch {
|
|
253
|
+
await sleep(2000);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (r.status === 400 || r.status === 410) {
|
|
257
|
+
try {
|
|
258
|
+
await join(); // not_joined / session_expired → volver a entrar, igual que el navegador
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return leave(`no pudo volver a entrar: ${e.message}`);
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (r.status === 401 || r.status === 403 || r.status === 404) return leave(`canal cerrado (${r.status})`);
|
|
265
|
+
if (!r.ok) {
|
|
266
|
+
await sleep(2000);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const lines = [];
|
|
270
|
+
for (const m of r.data.messages || []) {
|
|
271
|
+
if (m.id > s.since) s.since = m.id;
|
|
272
|
+
if (m.from === s.cs || m.kind === 'status' || !m.text) continue;
|
|
273
|
+
const t = String(m.text);
|
|
274
|
+
if (t.startsWith('[lang]')) {
|
|
275
|
+
const code = t.slice(6).trim().split(/\s/)[0].toLowerCase();
|
|
276
|
+
if (code && code !== 'auto') s.lang = code;
|
|
277
|
+
await greet();
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (/^\s*\[/.test(t)) continue; // [via] [rtc] [avatar] [scene]…: no son para el agente
|
|
281
|
+
lines.push(t.trim());
|
|
282
|
+
}
|
|
283
|
+
if (!lines.length) continue;
|
|
284
|
+
// Un /wait puede traer varias frases seguidas (STT por fragmentos): un solo turno.
|
|
285
|
+
const text = lines.join(' ').slice(0, 1500);
|
|
286
|
+
s.lastHuman = Date.now();
|
|
287
|
+
if (!s.greeted) s.greeted = true;
|
|
288
|
+
s.turns++;
|
|
289
|
+
await send(tc('server.channels.thinking'), 'status');
|
|
290
|
+
try {
|
|
291
|
+
await say(await agent.respond(`call:${channel_id}`, 'voice', text, { lang: s.lang }));
|
|
292
|
+
} catch (e) {
|
|
293
|
+
log('apuchat call llm:', e.message);
|
|
294
|
+
await say(tc('server.channels.repeat'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
await leave('parado');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
start() {
|
|
302
|
+
listen();
|
|
303
|
+
},
|
|
304
|
+
async stop() {
|
|
305
|
+
stopped = true;
|
|
306
|
+
await Promise.all(
|
|
307
|
+
[...calls.values()].map((s) =>
|
|
308
|
+
hubFetch('POST', `/api/channels/${encodeURIComponent(s.ch)}/leave`, { headers: { Authorization: `Bearer ${s.token}`, 'X-Session-Id': s.sid }, body: {} }).catch(() => {}),
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
ring,
|
|
313
|
+
openCall,
|
|
314
|
+
handle: () => stats.callsign,
|
|
315
|
+
status: () => ({ ...stats, activeCalls: calls.size, avatar: persona().meetAvatar }),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Invitación a una llamada dentro de un mensaje: las líneas "Channel id / Token / PIN" del
|
|
323
|
+
* botón Ring de meet, o un enlace …/call#c=…&t=…&p=… (también en la query).
|
|
324
|
+
*/
|
|
325
|
+
export function parseInvite(text) {
|
|
326
|
+
const s = String(text || '');
|
|
327
|
+
const line = (label) => s.match(new RegExp(`${label}\\s*:\\s*([^\\s]+)`, 'i'))?.[1];
|
|
328
|
+
const id = line('channel id');
|
|
329
|
+
const token = line('token');
|
|
330
|
+
if (id && token) return { channel_id: id, token, pin: line('pin') || null };
|
|
331
|
+
for (const url of s.match(/https?:\/\/[^\s<>"')]+/g) || []) {
|
|
332
|
+
let u;
|
|
333
|
+
try {
|
|
334
|
+
u = new URL(url);
|
|
335
|
+
} catch {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (!/\/call\b/.test(u.pathname)) continue;
|
|
339
|
+
const params = new URLSearchParams(u.hash.slice(1) || u.search.slice(1));
|
|
340
|
+
const c = params.get('c');
|
|
341
|
+
const t = params.get('t');
|
|
342
|
+
if (c && t) return { channel_id: c, token: t, pin: params.get('p') || null };
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function hubRequest(hub, method, path, { headers = {}, body, timeoutMs = 15000 } = {}) {
|
|
348
|
+
const res = await fetch(hub + path, {
|
|
349
|
+
method,
|
|
350
|
+
headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
|
|
351
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
352
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
353
|
+
});
|
|
354
|
+
let data = null;
|
|
355
|
+
try {
|
|
356
|
+
data = await res.json();
|
|
357
|
+
} catch {}
|
|
358
|
+
return { ok: res.ok, status: res.status, data };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Traductor para un idioma de persona o llamada (códigos sin catálogo → inglés; vacío → servidor). */
|
|
362
|
+
const langT = (lang) => translator(lang ? resolveLocale(lang) : serverLocale());
|
|
363
|
+
|
|
364
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canal apumail: el agente tiene su propio buzón y contesta los correos que le llegan.
|
|
3
|
+
*
|
|
4
|
+
* APUMAIL_AGENT_INBOX dirección del buzón del agente (p. ej. nube@tudominio.com)
|
|
5
|
+
* APUMAIL_AGENT_TOKEN token del buzón (o PAT de la cuenta dueña)
|
|
6
|
+
* APUMAIL_AGENT_WEBHOOK_SECRET opcional: si está, se espera el webhook de apumail en
|
|
7
|
+
* POST /api/agent/channels/apumail (URL pública) y se verifica
|
|
8
|
+
* X-Apumail-Signature. Si no, se usa long-poll (/wait).
|
|
9
|
+
* APUMAIL_AGENT_DAILY máximo de respuestas al día (defecto 20)
|
|
10
|
+
*
|
|
11
|
+
* Contrato de apumail usado:
|
|
12
|
+
* GET /api/v1/inbox/:address/wait?since=<id>&timeout≤300 Bearer token
|
|
13
|
+
* → { messages: StoredMail[], next_since, timed_out }
|
|
14
|
+
* POST /api/v1/inbox/:address/send { to, subject, text, reply_to_mail_id } (se enhebra solo)
|
|
15
|
+
* Webhook: { event:'mail.received', inbox, mail, ts } + X-Apumail-Signature: sha256=<hex HMAC del cuerpo>
|
|
16
|
+
* StoredMail: { id, to, from, subject, text, html, received_at, … }
|
|
17
|
+
*
|
|
18
|
+
* Usa un buzón permanente (dominio propio o handle de pago): los gratis caducan y solo
|
|
19
|
+
* envían 5 destinatarios al día.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
import { i18nError, reqT, teamT } from '../i18n.mjs';
|
|
24
|
+
|
|
25
|
+
const env = process.env;
|
|
26
|
+
const BACKLOG_MAX_AGE_MS = 30 * 60 * 1000; // al arrancar, solo se contestan correos recientes
|
|
27
|
+
const PER_SENDER_PER_DAY = 5;
|
|
28
|
+
const MAX_BODY = 256 * 1024;
|
|
29
|
+
|
|
30
|
+
/** Configuración del canal desde el entorno (el SDK puede pasar la suya). */
|
|
31
|
+
export function apumailConfigFromEnv(e = env) {
|
|
32
|
+
return {
|
|
33
|
+
api: e.APUMAIL_API || 'https://api.apumail.com',
|
|
34
|
+
inbox: e.APUMAIL_AGENT_INBOX || '',
|
|
35
|
+
token: e.APUMAIL_AGENT_TOKEN || '',
|
|
36
|
+
webhookSecret: e.APUMAIL_AGENT_WEBHOOK_SECRET || '',
|
|
37
|
+
daily: Number(e.APUMAIL_AGENT_DAILY || 20),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function apumailConfigured(cfg = apumailConfigFromEnv()) {
|
|
42
|
+
return !!(cfg.inbox && cfg.token);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, log: Function, config?: object }} deps
|
|
47
|
+
*/
|
|
48
|
+
export function createApumailChannel({ agent, log, config = apumailConfigFromEnv() }) {
|
|
49
|
+
const cfg = { ...apumailConfigFromEnv({}), ...config };
|
|
50
|
+
const inbox = String(cfg.inbox || '').toLowerCase();
|
|
51
|
+
const token = cfg.token;
|
|
52
|
+
const secret = cfg.webhookSecret || '';
|
|
53
|
+
const dailyCap = Number(cfg.daily || 20);
|
|
54
|
+
const base = `${String(cfg.api).replace(/\/$/, '')}/api/v1/inbox/${encodeURIComponent(inbox)}`;
|
|
55
|
+
const auth = { Authorization: `Bearer ${token}` };
|
|
56
|
+
|
|
57
|
+
const seen = new Set(); // ids ya tratados (los webhooks se reintentan)
|
|
58
|
+
const stats = { mode: secret ? 'webhook' : 'long-poll', received: 0, replied: 0, skipped: 0, lastError: null };
|
|
59
|
+
let day = '';
|
|
60
|
+
let sentToday = 0;
|
|
61
|
+
const perSender = new Map();
|
|
62
|
+
let stopped = false;
|
|
63
|
+
|
|
64
|
+
const budget = (from) => {
|
|
65
|
+
const d = new Date().toISOString().slice(0, 10);
|
|
66
|
+
if (d !== day) {
|
|
67
|
+
day = d;
|
|
68
|
+
sentToday = 0;
|
|
69
|
+
perSender.clear();
|
|
70
|
+
}
|
|
71
|
+
if (sentToday >= dailyCap) return 'límite diario';
|
|
72
|
+
if ((perSender.get(from) || 0) >= PER_SENDER_PER_DAY) return 'límite por remitente';
|
|
73
|
+
return null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
async function handleMail(mail, { backlog = false } = {}) {
|
|
77
|
+
if (!mail?.id || seen.has(mail.id)) return;
|
|
78
|
+
seen.add(mail.id);
|
|
79
|
+
if (seen.size > 5000) seen.clear();
|
|
80
|
+
stats.received++;
|
|
81
|
+
const from = emailOf(mail.from);
|
|
82
|
+
const why = skipReason(mail, from, inbox) || (backlog && Date.now() - Number(mail.received_at || 0) > BACKLOG_MAX_AGE_MS ? 'antiguo' : null) || budget(from);
|
|
83
|
+
if (why) {
|
|
84
|
+
stats.skipped++;
|
|
85
|
+
log(`apumail: no se contesta el correo ${mail.id} (${why})`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const subject = String(mail.subject || '').slice(0, 200);
|
|
89
|
+
const body = (mail.text || stripHtml(mail.html) || '').split('\n').filter((l) => !l.startsWith('>')).join('\n').trim().slice(0, 6000);
|
|
90
|
+
const reply = await agent.respond(`mail:${from}`, 'email', `Asunto: ${subject || '(sin asunto)'}\n\n${body || '(vacío)'}`);
|
|
91
|
+
if (!reply) return;
|
|
92
|
+
const res = await fetch(`${base}/send`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { 'Content-Type': 'application/json', ...auth },
|
|
95
|
+
body: JSON.stringify({ to: from, subject: /^re:/i.test(subject) ? subject : `Re: ${subject || teamT()('server.channels.replySubject')}`, text: reply, reply_to_mail_id: mail.id }),
|
|
96
|
+
signal: AbortSignal.timeout(15000),
|
|
97
|
+
});
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
const out = await res.json().catch(() => ({}));
|
|
100
|
+
stats.lastError = `send ${res.status} ${out.code || ''}`.trim();
|
|
101
|
+
log(`apumail: no se pudo responder (${stats.lastError})`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
sentToday++;
|
|
105
|
+
perSender.set(from, (perSender.get(from) || 0) + 1);
|
|
106
|
+
stats.replied++;
|
|
107
|
+
log(`apumail: respondido el correo ${mail.id}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Long-poll: sin URL pública. El primer lote es el atrasado (solo se contestan los recientes). */
|
|
111
|
+
async function poll() {
|
|
112
|
+
let since = 0;
|
|
113
|
+
let first = true;
|
|
114
|
+
while (!stopped) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetch(`${base}/wait?timeout=60${since ? `&since=${since}` : ''}`, { headers: auth, signal: AbortSignal.timeout(75000) });
|
|
117
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
118
|
+
stats.lastError = `wait ${res.status}`;
|
|
119
|
+
log(`apumail: el buzón del agente no responde (${res.status}); se deja de escuchar. Revisa APUMAIL_AGENT_INBOX / APUMAIL_AGENT_TOKEN.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!res.ok) throw new Error(`wait ${res.status}`);
|
|
123
|
+
const out = await res.json();
|
|
124
|
+
for (const mail of out.messages || []) await handleMail(mail, { backlog: first }).catch((e) => log('apumail:', e.message));
|
|
125
|
+
since = out.next_since ?? since;
|
|
126
|
+
first = false;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
stats.lastError = e.message;
|
|
129
|
+
await sleep(5000);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** POST /api/agent/channels/apumail — webhook firmado. */
|
|
135
|
+
async function webhook(req, res) {
|
|
136
|
+
const raw = await readRaw(req);
|
|
137
|
+
const sig = String(req.headers['x-apumail-signature'] || '');
|
|
138
|
+
const want = `sha256=${createHmac('sha256', secret).update(raw).digest('hex')}`;
|
|
139
|
+
if (!secret || sig.length !== want.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(want))) {
|
|
140
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
141
|
+
return res.end(JSON.stringify({ error: reqT(req)('server.channels.badSignature') }));
|
|
142
|
+
}
|
|
143
|
+
let evt;
|
|
144
|
+
try {
|
|
145
|
+
evt = JSON.parse(raw.toString('utf8'));
|
|
146
|
+
} catch {
|
|
147
|
+
res.writeHead(400);
|
|
148
|
+
return res.end();
|
|
149
|
+
}
|
|
150
|
+
// Se responde ya (apumail reintenta si tardamos); el correo se trata en segundo plano.
|
|
151
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
152
|
+
res.end('{"ok":true}');
|
|
153
|
+
if (evt.event === 'mail.received' && String(evt.inbox || '').toLowerCase() === inbox) {
|
|
154
|
+
handleMail(evt.mail).catch((e) => log('apumail:', e.message));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
start() {
|
|
160
|
+
log(`apumail: el agente atiende ${inbox} (${stats.mode})`);
|
|
161
|
+
if (!secret) poll();
|
|
162
|
+
},
|
|
163
|
+
stop() {
|
|
164
|
+
stopped = true;
|
|
165
|
+
},
|
|
166
|
+
webhook,
|
|
167
|
+
status: () => ({ inbox, ...stats, sentToday }),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ───────────────────────────── helpers ─────────────────────────────
|
|
172
|
+
|
|
173
|
+
export function emailOf(from) {
|
|
174
|
+
const s = String(from || '');
|
|
175
|
+
return (s.match(/<([^>]+)>/)?.[1] || s).trim().toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Correos que nunca se contestan: propios, automáticos, rebotes, listas. */
|
|
179
|
+
export function skipReason(mail, from, inbox) {
|
|
180
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(from)) return 'remitente inválido';
|
|
181
|
+
if (from === inbox) return 'propio';
|
|
182
|
+
if (/^(no-?reply|mailer-daemon|postmaster|bounces?|notifications?)([+@.-])/i.test(from)) return 'automático';
|
|
183
|
+
if (/^(auto(matic)? ?reply|respuesta autom|out of office|fuera de la oficina|undeliverable|delivery status|returned mail)/i.test(String(mail.subject || ''))) return 'automático';
|
|
184
|
+
const h = mail.headers || {};
|
|
185
|
+
if (/auto-(replied|generated)/i.test(h['auto-submitted'] || '') || h['list-id'] || /bulk|list|junk/i.test(h.precedence || '')) return 'automático';
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stripHtml(html) {
|
|
190
|
+
return String(html || '')
|
|
191
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '')
|
|
192
|
+
.replace(/<br\s*\/?>|<\/p>/gi, '\n')
|
|
193
|
+
.replace(/<[^>]+>/g, '')
|
|
194
|
+
.replace(/ /g, ' ')
|
|
195
|
+
.replace(/&/g, '&')
|
|
196
|
+
.replace(/</g, '<')
|
|
197
|
+
.replace(/>/g, '>');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function readRaw(req) {
|
|
201
|
+
const chunks = [];
|
|
202
|
+
let size = 0;
|
|
203
|
+
for await (const c of req) {
|
|
204
|
+
size += c.length;
|
|
205
|
+
if (size > MAX_BODY) throw i18nError(413, 'server.http.bodyTooLarge');
|
|
206
|
+
chunks.push(c);
|
|
207
|
+
}
|
|
208
|
+
return Buffer.concat(chunks);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canales para hablar con el agente fuera de la web: apumail (correo) y apuchat
|
|
3
|
+
* (mensajes, videollamadas de meet.apuchat.com y llamadas a la app).
|
|
4
|
+
* Cada canal se activa solo si tiene su configuración (por defecto, variables de entorno).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { getIdentity } from '../identity.mjs';
|
|
8
|
+
import { i18nError, reqT } from '../i18n.mjs';
|
|
9
|
+
import { createServerAgent, persona as personaOf } from './agent.mjs';
|
|
10
|
+
import { apumailConfigFromEnv, apumailConfigured, createApumailChannel } from './apumail.mjs';
|
|
11
|
+
import { apuchatConfigFromEnv, apuchatConfigured, createApuchatChannel } from './apuchat.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} deps
|
|
15
|
+
* @param {{step: Function}} [deps.llm] LLM del proxy
|
|
16
|
+
* @param {Function} [deps.brain] cerebro propio (ver channels/agent.mjs); sustituye al LLM
|
|
17
|
+
* @param {() => object} [deps.identity] identidad vigente (defecto: server/identity.mjs)
|
|
18
|
+
* @param {() => string} [deps.instructions] instrucciones fijas del servidor (defecto: SERVER_INSTRUCTIONS)
|
|
19
|
+
* @param {{ apumail?: object|false, apuchat?: object|false }} [deps.config] por defecto, el entorno
|
|
20
|
+
* @param {Function} [deps.log]
|
|
21
|
+
*/
|
|
22
|
+
export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a) }) {
|
|
23
|
+
const agent = createServerAgent({ llm, brain, identity, instructions });
|
|
24
|
+
const persona = () => personaOf(identity);
|
|
25
|
+
const mailCfg = config.apumail === false ? null : { ...apumailConfigFromEnv(), ...(config.apumail || {}) };
|
|
26
|
+
const chatCfg = config.apuchat === false ? null : { ...apuchatConfigFromEnv(), ...(config.apuchat || {}) };
|
|
27
|
+
const apumail = mailCfg && apumailConfigured(mailCfg) ? createApumailChannel({ agent, log, config: mailCfg }) : null;
|
|
28
|
+
const apuchat = chatCfg && apuchatConfigured(chatCfg) ? createApuchatChannel({ agent, persona, log, config: chatCfg }) : null;
|
|
29
|
+
apumail?.start();
|
|
30
|
+
apuchat?.start();
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
agent,
|
|
34
|
+
/** Rutas públicas de los canales (van antes del site key: las llama apumail, no el widget). */
|
|
35
|
+
async handle(req, res, path) {
|
|
36
|
+
if (path === '/channels/apumail' && req.method === 'POST') {
|
|
37
|
+
if (!apumail) {
|
|
38
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
39
|
+
res.end(JSON.stringify({ error: reqT(req)('server.channels.noApumail') }));
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
await apumail.webhook(req, res);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
},
|
|
47
|
+
/** Qué canales funcionan ahora mismo (para la tarjeta pública de identidad). */
|
|
48
|
+
live: () => ({
|
|
49
|
+
email: !!apumail,
|
|
50
|
+
apuchat: apuchat?.handle() || null,
|
|
51
|
+
meet: !!apuchat,
|
|
52
|
+
}),
|
|
53
|
+
/** Videollamada de meet con el agente dentro; devuelve el enlace para entrar. */
|
|
54
|
+
openCall: (o) => {
|
|
55
|
+
if (!apuchat) throw i18nError(501, 'server.channels.noApuchat');
|
|
56
|
+
return apuchat.openCall(o);
|
|
57
|
+
},
|
|
58
|
+
/** Hace sonar la app de apuchat de @handle con una llamada del agente. */
|
|
59
|
+
ring: (handle, motivo) => {
|
|
60
|
+
if (!apuchat) throw i18nError(501, 'server.channels.noApuchat');
|
|
61
|
+
return apuchat.ring(String(handle).replace(/^@/, '').toLowerCase(), motivo);
|
|
62
|
+
},
|
|
63
|
+
status: () => ({ apumail: apumail?.status() || false, apuchat: apuchat?.status() || false }),
|
|
64
|
+
async stop() {
|
|
65
|
+
apumail?.stop();
|
|
66
|
+
await apuchat?.stop();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|