@7ots/cli 0.1.1 → 0.1.4

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 (51) hide show
  1. package/.env.example +17 -1
  2. package/cli/7ots.mjs +144 -36
  3. package/cli/lib/assistant.mjs +347 -0
  4. package/cli/lib/brain.mjs +10 -7
  5. package/cli/lib/config.mjs +8 -1
  6. package/cli/lib/i18n.mjs +84 -11
  7. package/cli/lib/install.mjs +29 -1
  8. package/cli/lib/pet-core.mjs +3 -2
  9. package/cli/lib/pet-server.mjs +133 -13
  10. package/cli/lib/prompts.mjs +199 -7
  11. package/cli/lib/terminal.mjs +27 -6
  12. package/cli/pet/electron/main.cjs +96 -32
  13. package/cli/pet/pet.html +59 -6
  14. package/cli/pet/settings.html +430 -0
  15. package/dist/7ots.esm.js +370 -142
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +370 -142
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/llms.txt +198 -6
  20. package/package.json +1 -1
  21. package/server/channels/apuchat.mjs +8 -5
  22. package/server/channels/apumail.mjs +13 -3
  23. package/server/channels/index.mjs +5 -3
  24. package/server/platform/apuchat.mjs +266 -0
  25. package/server/platform/auth.mjs +39 -10
  26. package/server/platform/connect.mjs +347 -0
  27. package/server/platform/db.mjs +55 -0
  28. package/server/platform/look.mjs +20 -0
  29. package/server/platform/orquesta.mjs +499 -0
  30. package/server/platform/routes.mjs +57 -8
  31. package/server/platform/runtime.mjs +75 -15
  32. package/server/platform/store.mjs +130 -9
  33. package/server/server.mjs +14 -3
  34. package/src/avatar/AvatarStage.js +54 -9
  35. package/src/avatar/three.js +62 -0
  36. package/src/character/AvatarEditor.js +174 -50
  37. package/src/character/Character.js +164 -26
  38. package/src/character/motion.js +95 -34
  39. package/src/character/parts.js +43 -12
  40. package/src/character/skin.js +412 -0
  41. package/src/character/styles.js +1429 -0
  42. package/src/character3d/Character3D.js +1693 -0
  43. package/src/character3d/body.js +154 -0
  44. package/src/character3d/fx.js +323 -0
  45. package/src/character3d/index.js +281 -0
  46. package/src/character3d/materials.js +429 -0
  47. package/src/character3d/stage.js +187 -0
  48. package/src/i18n/messages/character.js +18 -42
  49. package/src/i18n/messages/platform.js +135 -0
  50. package/src/identity/schema.js +16 -8
  51. package/src/index.js +1 -0
@@ -7,7 +7,7 @@
7
7
  * POST /auth/email/confirm token=… gasta el enlace, abre sesión y lleva a /app/
8
8
  * GET /auth/notlogin → notlogin.com/authorize
9
9
  * GET /auth/notlogin/callback ?code&state → sesión y /app/
10
- * POST /auth/logout
10
+ * POST /auth/logout · /auth/logout-all (cierra todas las sesiones de la cuenta)
11
11
  *
12
12
  * Variables:
13
13
  * PLATFORM_URL origen público (https://7ots.com); los enlaces nunca salen de la cabecera Host
@@ -22,7 +22,7 @@
22
22
  import { createHash, createPublicKey, randomBytes, verify as cryptoVerify } from 'node:crypto';
23
23
  import { reqT, teamT } from '../i18n.mjs';
24
24
  import { signValue, unsignValue } from './crypto.mjs';
25
- import { SESSION_TTL, consumeLoginToken, createLoginToken, createSession, deleteSession, recentLoginTokens, sessionAccount, upsertAccount } from './store.mjs';
25
+ import { SESSION_TTL, accountExists, consumeLoginToken, createLoginToken, createSession, deleteAccountSessions, deleteSession, recentLoginTokens, sessionAccount, upsertAccount } from './store.mjs';
26
26
 
27
27
  const penv = process.env;
28
28
  export const SESSION_COOKIE = 'ots_session';
@@ -56,6 +56,22 @@ export function createAuth({ send, readJson, originOf, clientIp }) {
56
56
  const base = (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, '');
57
57
  const secure = (req) => base(req).startsWith('https:');
58
58
  const ipHits = new Map();
59
+ const newAccounts = new Map(); // ip → timestamps of accounts created in the last day
60
+ const MAX_NEW_ACCOUNTS = () => Number(penv.PLATFORM_NEW_ACCOUNTS_PER_IP_DAY || 3);
61
+
62
+ /** New accounts per IP and day (each one brings a free quota). Returns the account, or null when over the cap. */
63
+ function login(req, who) {
64
+ if (accountExists(who)) return upsertAccount(who);
65
+ const ip = clientIp(req);
66
+ const now = Date.now();
67
+ const recent = (newAccounts.get(ip) || []).filter((t) => now - t < 86400_000);
68
+ if (recent.length >= MAX_NEW_ACCOUNTS()) return null;
69
+ const account = upsertAccount(who);
70
+ recent.push(now);
71
+ newAccounts.set(ip, recent);
72
+ if (newAccounts.size > 5000) newAccounts.clear();
73
+ return account;
74
+ }
59
75
 
60
76
  function cookie(req, name, value, { path = '/', maxAge }) {
61
77
  return `${name}=${encodeURIComponent(value)}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(maxAge / 1000)}${secure(req) ? '; Secure' : ''}`;
@@ -152,12 +168,13 @@ export function createAuth({ send, readJson, originOf, clientIp }) {
152
168
  const j = await r.json().catch(() => ({}));
153
169
  if (!r.ok || !j.id_token) throw new Error(`token ${r.status}`);
154
170
  const claims = await verifyIdToken(j.id_token, st.n);
155
- const account = upsertAccount({ notloginSub: claims.sub, email: claims.email_verified ? claims.email : null });
171
+ const account = login(req, { notloginSub: claims.sub, email: claims.email_verified ? claims.email : null });
172
+ if (!account) return fail(res, 'platform.api.tooManyAccounts');
156
173
  startSession(req, res, account, 'notlogin');
157
174
  redirect(res, '/app/');
158
175
  } catch (e) {
159
176
  console.warn('[7ots] notlogin:', e.message);
160
- fail(res, 'platform.api.notloginFailed');
177
+ fail(res, e.i18n?.key || 'platform.api.notloginFailed');
161
178
  }
162
179
  }
163
180
 
@@ -206,7 +223,9 @@ export function createAuth({ send, readJson, originOf, clientIp }) {
206
223
  const form = await readForm(req);
207
224
  const email = consumeLoginToken(form.get('token'));
208
225
  if (!email) return fail(res, 'platform.api.linkInvalid'), true;
209
- startSession(req, res, upsertAccount({ email }), 'email');
226
+ const account = login(req, { email });
227
+ if (!account) return fail(res, 'platform.api.tooManyAccounts'), true;
228
+ startSession(req, res, account, 'email');
210
229
  redirect(res, '/app/');
211
230
  return true;
212
231
  }
@@ -214,7 +233,13 @@ export function createAuth({ send, readJson, originOf, clientIp }) {
214
233
  if (p === '/auth/notlogin' && req.method === 'GET') return notloginStart(req, res), true;
215
234
  if (p === '/auth/notlogin/callback' && req.method === 'GET') return await notloginCallback(req, res, url), true;
216
235
 
217
- if (p === '/auth/logout' && write) {
236
+ if ((p === '/auth/logout' || p === '/auth/logout-all') && write) {
237
+ if (p === '/auth/logout-all') {
238
+ // Same-origin only (the dashboard always sends Origin), so no other site can sign the user out.
239
+ const account = currentAccount(req);
240
+ if (!origin) return send(res, 403, { error: t('server.admin.sameOrigin') }), true;
241
+ if (account) deleteAccountSessions(account.id);
242
+ }
218
243
  deleteSession(parseCookies(req.headers.cookie)[SESSION_COOKIE]);
219
244
  res.setHeader('Set-Cookie', cookie(req, SESSION_COOKIE, '', { maxAge: 0 }));
220
245
  send(res, 200, { ok: true });
@@ -240,10 +265,14 @@ async function readForm(req) {
240
265
  function confirmPage(t, token) {
241
266
  const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
242
267
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>7ots.com</title>
243
- <style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 system-ui,sans-serif;background:#f8f7fc;color:#16152a}
244
- main{text-align:center;padding:32px}button{font:600 16px system-ui,sans-serif;background:#5b47f0;color:#fff;border:0;border-radius:8px;padding:12px 28px;cursor:pointer}
245
- @media (prefers-color-scheme:dark){body{background:#0b0a14;color:#f0eff7}button{background:#8b7cff;color:#0b0a14}}</style></head>
246
- <body><main><p>${esc(t('platform.mail.confirm'))}</p><form method="post" action="/auth/email/confirm"><input type="hidden" name="token" value="${esc(token)}"><button type="submit">${esc(t('platform.mail.button'))}</button></form></main></body></html>`;
268
+ <link rel="icon" type="image/svg+xml" href="/brand/favicon.svg">
269
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;font:16px/1.5 Inter,system-ui,sans-serif;color:#fff;
270
+ background:radial-gradient(60% 50% at 20% 10%,rgb(168 85 247/.35),transparent 70%),radial-gradient(50% 40% at 85% 80%,rgb(34 211 238/.22),transparent 70%),#120d33}
271
+ main{text-align:center;padding:40px 32px;margin:16px;max-width:380px;border-radius:24px;background:rgb(255 255 255/.06);border:1px solid rgb(255 255 255/.14);backdrop-filter:blur(12px)}
272
+ img{width:56px;height:56px;margin-bottom:8px}p{margin:0 0 22px;color:rgb(255 255 255/.85)}
273
+ button{font:800 18px/1 system-ui,sans-serif;background:linear-gradient(180deg,#fef08a,#fde047 45%,#facc15);color:#1e1850;border:0;border-radius:999px;padding:15px 34px;cursor:pointer;box-shadow:0 6px 0 #ca8a04,0 14px 34px rgb(250 204 21/.25)}
274
+ button:active{transform:translateY(5px);box-shadow:0 1px 0 #ca8a04}</style></head>
275
+ <body><main><img src="/brand/mark.svg" alt="7ots"><p>${esc(t('platform.mail.confirm'))}</p><form method="post" action="/auth/email/confirm"><input type="hidden" name="token" value="${esc(token)}"><button type="submit">${esc(t('platform.mail.button'))}</button></form></main></body></html>`;
247
276
  }
248
277
 
249
278
  // ───────────────────────────── id_token de notlogin (EdDSA) ─────────────────────────────
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Dashboard routes of the platform's integrations: an apuchat identity per ot and Orquesta tasks.
3
+ * Mounted by routes.mjs (same session, same-origin and X-7ots-Admin rules as the rest of /api/platform).
4
+ *
5
+ * Account:
6
+ * DELETE /api/platform/apuchat/link forget the linked apuchat account
7
+ * GET /api/platform/orquesta/projects projects of the connected Orquesta account
8
+ * POST /api/platform/orquesta/key { key } connect with an oak_ key · DELETE /api/platform/orquesta
9
+ * One ot:
10
+ * GET …/ots/:id/apuchat its identity, the account's apuchat state, open checkouts
11
+ * POST …/ots/:id/apuchat/identity new free identity · DELETE (detach; deletes free ones)
12
+ * POST …/ots/:id/apuchat/key { key } use an existing identity key
13
+ * GET …/ots/:id/apuchat/quote?callsign= price of a permanent handle
14
+ * POST …/ots/:id/apuchat/checkout { callsign } → { url } (card payment on apuchat; back to /app/?apuchat_ots=<id>)
15
+ * GET …/ots/:id/apuchat/checkout/:cid result; a newly revealed key is stored in the ot
16
+ * POST …/ots/:id/apuchat/claim move the ot's identity into the linked apuchat account
17
+ * GET|PUT …/ots/:id/orquesta project, tasks on/off, allowed senders, daily cap; recent tasks
18
+ * POST …/ots/:id/orquesta/ask { message, history } the owner talks to the ot (only place with tools + owner)
19
+ * GET …/ots/:id/orquesta/tasks/:tid · POST …/cancel
20
+ * Browser redirects (state in a signed cookie, bound to the session's account):
21
+ * GET /auth/apuchat?ots= → apuchat /link → GET /auth/apuchat/callback?state&code
22
+ * GET /auth/orquesta?ots= → Orquesta OAuth (PKCE) → GET /auth/orquesta/callback?code&state
23
+ * Both end in /app/?notice=<key>#ots/<id>/<tab>.
24
+ */
25
+
26
+ import { randomBytes } from 'node:crypto';
27
+ import { reqT, requestLocale } from '../i18n.mjs';
28
+ import { currentAccount, parseCookies } from './auth.mjs';
29
+ import { signValue, unsignValue } from './crypto.mjs';
30
+ import { getOwnOts, listTasks, tasksLastDay } from './store.mjs';
31
+ import { otsChannels, otsStep } from './runtime.mjs';
32
+ import {
33
+ CALLSIGN_RE, apuchatAccountView, apuchatHub, callsignOf, checkout, checkoutStatus, claimIdentity, createFreeIdentity, defaultOwner, deleteIdentity,
34
+ exchangeLink, linkUrl, pendingCheckouts, quote, unlink,
35
+ } from './apuchat.mjs';
36
+ import {
37
+ askOt, authorizeUrl, cancelTask, disconnect, exchangeCode, listProjects, orquestaAccountView, orquestaUrl, otsOrquesta, parseAllow, projectUrl, saveKey, taskStatus,
38
+ } from './orquesta.mjs';
39
+
40
+ const penv = process.env;
41
+ const STATE_TTL = 10 * 60_000;
42
+ const COOKIES = { apuchat: 'ots_apuchat_link', orquesta: 'ots_orquesta_oauth' };
43
+ const IDENTITY_KEYS = ['APUCHAT_AGENT_CALLSIGN', 'APUCHAT_AGENT_FREE', 'APUCHAT_AGENT_OWNER'];
44
+
45
+ /**
46
+ * @param {{ send: Function, readJson: Function, originOf: Function, save: (ots, patch) => Promise<object> }} deps
47
+ */
48
+ export function createConnect({ send, readJson, originOf, save }) {
49
+ const base = (req) => (penv.PLATFORM_URL || originOf(req)).replace(/\/+$/, '');
50
+
51
+ function cookie(req, name, value, path, maxAge) {
52
+ return `${name}=${encodeURIComponent(value)}; Path=${path}; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(maxAge / 1000)}${base(req).startsWith('https:') ? '; Secure' : ''}`;
53
+ }
54
+ const putState = (req, res, kind, st) => res.setHeader('Set-Cookie', cookie(req, COOKIES[kind], signValue(Buffer.from(JSON.stringify(st)).toString('base64url')), `/auth/${kind}`, STATE_TTL));
55
+ function takeState(req, res, kind) {
56
+ res.setHeader('Set-Cookie', cookie(req, COOKIES[kind], '', `/auth/${kind}`, 0));
57
+ const raw = unsignValue(parseCookies(req.headers.cookie)[COOKIES[kind]]);
58
+ try {
59
+ const st = raw && JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
60
+ return st && st.e > Date.now() ? st : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+ function redirect(res, to) {
66
+ res.writeHead(303, { Location: to, 'Cache-Control': 'no-store' });
67
+ res.end();
68
+ }
69
+ const back = (res, notice, otsId, tab) => redirect(res, `/app/?notice=${encodeURIComponent(notice)}${otsId ? `#ots/${otsId}/${tab}` : ''}`);
70
+
71
+ // ───────────────────────────── apuchat identity of an ot ─────────────────────────────
72
+
73
+ const hasIdentity = (ots) => !!ots.secrets.APUCHAT_AGENT_IDENTITY_KEY;
74
+ const isFree = (ots) => hasIdentity(ots) && ots.settings.APUCHAT_AGENT_FREE === '1';
75
+ // A paid handle held by the platform's anonymous account would be lost if the ot let go of its key.
76
+ const wouldLose = (ots) => hasIdentity(ots) && !isFree(ots) && ots.settings.APUCHAT_AGENT_OWNER === 'managed';
77
+
78
+ function apuchatView(account, ots) {
79
+ const st = otsChannels(ots).status().apuchat || null;
80
+ const has = hasIdentity(ots);
81
+ return {
82
+ hub: apuchatHub(),
83
+ mintUrl: `${apuchatHub()}/account/mint`,
84
+ identity: has ? { callsign: ots.settings.APUCHAT_AGENT_CALLSIGN || st?.callsign || null, free: isFree(ots), owner: ots.settings.APUCHAT_AGENT_OWNER || 'own' } : null,
85
+ paused: ots.status !== 'on',
86
+ listening: !!st && !st.lastError,
87
+ error: st?.lastError || null,
88
+ account: apuchatAccountView(account.id),
89
+ checkouts: pendingCheckouts(account.id, ots.id),
90
+ };
91
+ }
92
+
93
+ /** Puts an identity in the ot (its key is a channel setting: the apuchat channel restarts with it). */
94
+ function setIdentity(ots, { key, callsign, free, owner }) {
95
+ const settings = { ...ots.settings };
96
+ for (const k of IDENTITY_KEYS) delete settings[k];
97
+ const secrets = { ...ots.secrets };
98
+ if (key) {
99
+ secrets.APUCHAT_AGENT_IDENTITY_KEY = key;
100
+ if (callsign) settings.APUCHAT_AGENT_CALLSIGN = callsign;
101
+ if (free) settings.APUCHAT_AGENT_FREE = '1';
102
+ if (owner) settings.APUCHAT_AGENT_OWNER = owner;
103
+ } else delete secrets.APUCHAT_AGENT_IDENTITY_KEY;
104
+ return save(ots, { settings, secrets });
105
+ }
106
+
107
+ /** New free identity for an ot that has none (also used when creating an ot with the box ticked). */
108
+ async function freeIdentity(account, ots) {
109
+ const id = await createFreeIdentity(account.id);
110
+ return setIdentity(ots, { key: id.identityKey, callsign: id.callsign, free: id.free, owner: id.owner });
111
+ }
112
+
113
+ async function apuchatOts(req, res, account, ots, sub) {
114
+ const t = reqT(req);
115
+ const url = new URL(req.url, 'http://x');
116
+ if (sub === '/apuchat' && req.method === 'GET') return send(res, 200, apuchatView(account, ots));
117
+
118
+ if (sub === '/apuchat/identity' && req.method === 'POST') {
119
+ // One live free identity per ot; a paid or pasted one has to be detached first.
120
+ if (isFree(ots)) return send(res, 409, { error: t('platform.apuchat.alreadyFree') });
121
+ if (hasIdentity(ots)) return send(res, 409, { error: t('platform.apuchat.hasIdentity') });
122
+ const next = await freeIdentity(account, ots);
123
+ return send(res, 201, apuchatView(account, next));
124
+ }
125
+ if (sub === '/apuchat/identity' && req.method === 'DELETE') {
126
+ if (!hasIdentity(ots)) return send(res, 200, apuchatView(account, ots));
127
+ if (wouldLose(ots)) return send(res, 409, { error: t('platform.apuchat.claimFirst') });
128
+ const { APUCHAT_AGENT_CALLSIGN: cs, APUCHAT_AGENT_OWNER: owner } = ots.settings;
129
+ const free = isFree(ots);
130
+ const next = await setIdentity(ots, {});
131
+ if (free && cs) await deleteIdentity(account.id, owner, cs);
132
+ return send(res, 200, apuchatView(account, next));
133
+ }
134
+ if (sub === '/apuchat/key' && req.method === 'POST') {
135
+ const { key } = await readJson(req, 8 * 1024);
136
+ const k = String(key || '').trim();
137
+ if (!k || k.length > 400 || /\s/.test(k)) return send(res, 400, { error: t('platform.apuchat.badKey') });
138
+ if (wouldLose(ots)) return send(res, 409, { error: t('platform.apuchat.claimFirst') });
139
+ const callsign = await callsignOf(k);
140
+ if (!callsign) return send(res, 400, { error: t('platform.apuchat.keyRefused') });
141
+ const next = await setIdentity(ots, { key: k, callsign, owner: 'own' });
142
+ return send(res, 200, apuchatView(account, next));
143
+ }
144
+ if (sub === '/apuchat/quote' && req.method === 'GET') {
145
+ const callsign = String(url.searchParams.get('callsign') || '').trim().replace(/^@/, '').toLowerCase();
146
+ if (!CALLSIGN_RE.test(callsign)) return send(res, 400, { error: t('platform.apuchat.badCallsign') });
147
+ const upgrade = isFree(ots) && ots.settings.APUCHAT_AGENT_CALLSIGN === callsign;
148
+ const q = await quote(account.id, upgrade ? ots.settings.APUCHAT_AGENT_OWNER : defaultOwner(account.id, 'identities:mint'), callsign);
149
+ return send(res, 200, { ...q, upgrade, available: upgrade || (q.valid && !q.taken) });
150
+ }
151
+ if (sub === '/apuchat/checkout' && req.method === 'POST') {
152
+ const { callsign: raw } = await readJson(req, 4 * 1024);
153
+ const callsign = String(raw || '').trim().replace(/^@/, '').toLowerCase();
154
+ if (!CALLSIGN_RE.test(callsign)) return send(res, 400, { error: t('platform.apuchat.badCallsign') });
155
+ // Same callsign as the ot's free identity → in-place upgrade, made by the account that owns it.
156
+ const upgrade = isFree(ots) && ots.settings.APUCHAT_AGENT_CALLSIGN === callsign;
157
+ if (!upgrade && wouldLose(ots)) return send(res, 409, { error: t('platform.apuchat.claimFirst') });
158
+ const owner = upgrade ? ots.settings.APUCHAT_AGENT_OWNER : defaultOwner(account.id, 'identities:mint');
159
+ // No #fragment: apuchat appends ?apuchat_checkout=<id>&status=… to it; the dashboard opens the ot's tab from apuchat_ots.
160
+ const out = await checkout(account.id, owner, { otsId: ots.id, callsign, returnUrl: `${base(req)}/app/?apuchat_ots=${ots.id}` });
161
+ return send(res, 200, { url: out.url, checkoutId: out.checkoutId, priceUsd: out.priceUsd, tier: out.tier });
162
+ }
163
+ const ck = sub.match(/^\/apuchat\/checkout\/([A-Za-z0-9_-]{4,100})$/);
164
+ if (ck && req.method === 'GET') {
165
+ const st = await checkoutStatus(account.id, ots.id, ck[1]);
166
+ if (!st) return send(res, 404, { error: t('server.http.notFound') });
167
+ let next = ots;
168
+ if (st.status === 'fulfilled') {
169
+ if (st.identityKey) {
170
+ const { APUCHAT_AGENT_CALLSIGN: old, APUCHAT_AGENT_OWNER: oldOwner } = ots.settings;
171
+ const wasFree = isFree(ots);
172
+ next = await setIdentity(ots, { key: st.identityKey, callsign: st.callsign, owner: st.owner });
173
+ // The free handle it replaces goes back to apuchat (best effort).
174
+ if (wasFree && old && old !== st.callsign) await deleteIdentity(account.id, oldOwner, old).catch(() => {});
175
+ } else if (st.upgraded && ots.settings.APUCHAT_AGENT_CALLSIGN === st.callsign) {
176
+ const settings = { ...ots.settings };
177
+ delete settings.APUCHAT_AGENT_FREE;
178
+ next = await save(ots, { settings });
179
+ }
180
+ }
181
+ return send(res, 200, { status: st.status, callsign: st.callsign, upgraded: st.upgraded, applied: next !== ots, apuchat: apuchatView(account, next) });
182
+ }
183
+ if (sub === '/apuchat/claim' && req.method === 'POST') {
184
+ if (!hasIdentity(ots)) return send(res, 409, { error: t('platform.apuchat.noIdentity') });
185
+ await claimIdentity(account.id, ots.secrets.APUCHAT_AGENT_IDENTITY_KEY);
186
+ const next = await save(ots, { settings: { ...ots.settings, APUCHAT_AGENT_OWNER: 'linked' } });
187
+ return send(res, 200, apuchatView(account, next));
188
+ }
189
+ return false;
190
+ }
191
+
192
+ // ───────────────────────────── Orquesta panel of an ot ─────────────────────────────
193
+
194
+ function orquestaView(account, ots) {
195
+ const cfg = otsOrquesta(ots);
196
+ return {
197
+ url: orquestaUrl(),
198
+ account: orquestaAccountView(account.id),
199
+ ...cfg,
200
+ projectUrl: cfg.project ? projectUrl(cfg.project) : null,
201
+ usedToday: tasksLastDay(ots.id),
202
+ recent: listTasks(ots.id, 20).map((x) => ({ id: x.id, who: x.who, channel: x.channel, task: x.task.slice(0, 300), status: x.status, result: x.result.slice(0, 2000), error: x.error, createdAt: x.createdAt, updatedAt: x.updatedAt })),
203
+ };
204
+ }
205
+
206
+ async function orquestaOts(req, res, account, ots, sub) {
207
+ const t = reqT(req);
208
+ if (sub === '/orquesta' && req.method === 'GET') return send(res, 200, orquestaView(account, ots));
209
+ if (sub === '/orquesta' && req.method === 'PUT') {
210
+ const body = await readJson(req, 16 * 1024);
211
+ const settings = { ...ots.settings };
212
+ if (body.project !== undefined) {
213
+ const id = String(body.project || '').trim();
214
+ if (!id) {
215
+ delete settings.ORQUESTA_PROJECT;
216
+ delete settings.ORQUESTA_PROJECT_NAME;
217
+ } else {
218
+ const p = (await listProjects(account.id)).find((x) => x.id === id);
219
+ if (!p) return send(res, 400, { error: t('platform.orquesta.noProject') });
220
+ settings.ORQUESTA_PROJECT = p.id;
221
+ settings.ORQUESTA_PROJECT_NAME = p.name;
222
+ }
223
+ }
224
+ if (body.tasks !== undefined) {
225
+ if (body.tasks) settings.ORQUESTA_TASKS = '1';
226
+ else delete settings.ORQUESTA_TASKS;
227
+ }
228
+ if (body.allow !== undefined) {
229
+ const { list, bad } = parseAllow(body.allow);
230
+ if (bad.length) return send(res, 400, { error: t('platform.orquesta.badAllow', { list: bad.slice(0, 5).join(', ') }) });
231
+ if (list.length) settings.ORQUESTA_ALLOW = list.join(', ');
232
+ else delete settings.ORQUESTA_ALLOW;
233
+ }
234
+ if (body.daily !== undefined) settings.ORQUESTA_DAILY = String(Math.min(200, Math.max(1, Math.floor(Number(body.daily)) || 20)));
235
+ if (settings.ORQUESTA_TASKS && !settings.ORQUESTA_PROJECT) return send(res, 400, { error: t('platform.orquesta.pickProject') });
236
+ return send(res, 200, orquestaView(account, await save(ots, { settings })));
237
+ }
238
+ if (sub === '/orquesta/ask' && req.method === 'POST') {
239
+ const { message, history } = await readJson(req, 128 * 1024);
240
+ const msg = String(message || '').trim();
241
+ if (!msg) return send(res, 400, { error: t('server.http.badRequest') });
242
+ if (ots.status !== 'on') return send(res, 409, { error: t('platform.api.paused') });
243
+ const out = await askOt(ots, { message: msg, history, ownerEmail: account.email || account.id, locale: requestLocale(req) }, (a) => otsStep(ots, a));
244
+ return send(res, 200, out);
245
+ }
246
+ const tm = sub.match(/^\/orquesta\/tasks\/([A-Za-z0-9_-]{4,100})(\/cancel)?$/);
247
+ if (tm && !tm[2] && req.method === 'GET') {
248
+ const out = await taskStatus(ots, tm[1]);
249
+ return out.ok ? send(res, 200, out) : send(res, 404, { error: out.error });
250
+ }
251
+ if (tm && tm[2] && req.method === 'POST') {
252
+ const row = await cancelTask(ots, tm[1]);
253
+ return row ? send(res, 200, { id: row.id, status: row.status }) : send(res, 404, { error: t('server.http.notFound') });
254
+ }
255
+ return false;
256
+ }
257
+
258
+ // ───────────────────────────── account-level routes ─────────────────────────────
259
+
260
+ async function accountApi(req, res, account, path) {
261
+ if (path === '/apuchat/link' && req.method === 'DELETE') {
262
+ unlink(account.id);
263
+ return send(res, 200, { apuchat: apuchatAccountView(account.id) });
264
+ }
265
+ if (path === '/orquesta/projects' && req.method === 'GET') return send(res, 200, { projects: await listProjects(account.id) });
266
+ if (path === '/orquesta/key' && req.method === 'POST') {
267
+ const { key } = await readJson(req, 4 * 1024);
268
+ await saveKey(account.id, key);
269
+ return send(res, 200, { orquesta: orquestaAccountView(account.id) });
270
+ }
271
+ if (path === '/orquesta' && req.method === 'DELETE') {
272
+ disconnect(account.id);
273
+ return send(res, 200, { orquesta: orquestaAccountView(account.id) });
274
+ }
275
+ return false;
276
+ }
277
+
278
+ // ───────────────────────────── browser redirects ─────────────────────────────
279
+
280
+ async function authRoutes(req, res, url) {
281
+ const p = url.pathname;
282
+ if (!/^\/auth\/(apuchat|orquesta)(\/callback)?$/.test(p) || req.method !== 'GET') return false;
283
+ const account = currentAccount(req);
284
+ if (!account) {
285
+ redirect(res, '/app/');
286
+ return true;
287
+ }
288
+ const kind = p.split('/')[2];
289
+ const tab = kind;
290
+
291
+ if (!p.endsWith('/callback')) {
292
+ const ots = getOwnOts(account.id, url.searchParams.get('ots'));
293
+ const st = { s: randomBytes(16).toString('base64url'), acc: account.id, ots: ots?.id || null, e: Date.now() + STATE_TTL };
294
+ if (kind === 'apuchat') {
295
+ putState(req, res, kind, st);
296
+ redirect(res, linkUrl(`${base(req)}/auth/apuchat/callback?state=${st.s}`));
297
+ return true;
298
+ }
299
+ try {
300
+ const { url: to, verifier } = await authorizeUrl({ redirectUri: `${base(req)}/auth/orquesta/callback`, state: st.s });
301
+ putState(req, res, kind, { ...st, v: verifier });
302
+ redirect(res, to);
303
+ } catch (e) {
304
+ console.warn('[7ots] orquesta connect:', e.message);
305
+ back(res, 'orquestaFailed', st.ots, tab);
306
+ }
307
+ return true;
308
+ }
309
+
310
+ const st = takeState(req, res, kind);
311
+ const code = url.searchParams.get('code');
312
+ // The flow must come back to the same browser AND the same 7ots account that started it.
313
+ const ok = st && st.acc === account.id && code && url.searchParams.get('state') === st.s;
314
+ const otsId = st?.ots && getOwnOts(account.id, st.ots) ? st.ots : null;
315
+ if (!ok) {
316
+ back(res, url.searchParams.get('error') === 'access_denied' ? `${kind}Denied` : `${kind}Failed`, otsId, tab);
317
+ return true;
318
+ }
319
+ try {
320
+ if (kind === 'apuchat') await exchangeLink(account.id, code);
321
+ else await exchangeCode(account.id, { code, verifier: st.v, redirectUri: `${base(req)}/auth/orquesta/callback` });
322
+ back(res, kind === 'apuchat' ? 'apuchatLinked' : 'orquestaConnected', otsId, tab);
323
+ } catch (e) {
324
+ console.warn(`[7ots] ${kind} callback:`, e.message);
325
+ back(res, `${kind}Failed`, otsId, tab);
326
+ }
327
+ return true;
328
+ }
329
+
330
+ return {
331
+ /** /api/platform/<path> for the account; false if not one of these routes. */
332
+ account: accountApi,
333
+ /** /api/platform/ots/:id<sub>; false if not one of these routes. */
334
+ async ots(req, res, account, ots, sub) {
335
+ if (sub.startsWith('/apuchat')) return (await apuchatOts(req, res, account, ots, sub)) !== false;
336
+ if (sub.startsWith('/orquesta')) return (await orquestaOts(req, res, account, ots, sub)) !== false;
337
+ return false;
338
+ },
339
+ auth: authRoutes,
340
+ freeIdentity,
341
+ integrations: (account) => ({ apuchat: apuchatAccountView(account.id), orquesta: { ...orquestaAccountView(account.id), url: orquestaUrl() } }),
342
+ /** When an ot is deleted: give its free identity back to apuchat (best effort). */
343
+ async release(account, ots) {
344
+ if (isFree(ots) && ots.settings.APUCHAT_AGENT_CALLSIGN) await deleteIdentity(account.id, ots.settings.APUCHAT_AGENT_OWNER, ots.settings.APUCHAT_AGENT_CALLSIGN).catch(() => {});
345
+ },
346
+ };
347
+ }
@@ -11,8 +11,12 @@
11
11
  * conversations una por visitante y sesión del widget (o contacto de un canal)
12
12
  * messages lo que dijo cada uno (texto, sin el `raw` del proveedor)
13
13
  * usage contadores por ots, mes y tipo
14
+ * integrations cuentas conectadas de cada cuenta (apuchat, Orquesta), cifradas
15
+ * platform_state valores de la plataforma (p. ej. el client_id de OAuth registrado en Orquesta)
16
+ * ots_tasks tareas que un ots mandó a Orquesta: quién la pidió, por dónde y cómo acabó
14
17
  */
15
18
 
19
+ import { lookPrint } from './look.mjs';
16
20
  import { DatabaseSync } from 'node:sqlite';
17
21
  import { mkdirSync } from 'node:fs';
18
22
  import { dirname, join, resolve } from 'node:path';
@@ -87,6 +91,33 @@ CREATE TABLE IF NOT EXISTS usage (
87
91
  PRIMARY KEY (ots_id, month, kind)
88
92
  );
89
93
  CREATE INDEX IF NOT EXISTS usage_account ON usage(account_id, month);
94
+ CREATE TABLE IF NOT EXISTS integrations (
95
+ account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
96
+ kind TEXT NOT NULL,
97
+ data TEXT NOT NULL DEFAULT '',
98
+ updated_at INTEGER NOT NULL,
99
+ PRIMARY KEY (account_id, kind)
100
+ );
101
+ CREATE TABLE IF NOT EXISTS platform_state (
102
+ key TEXT PRIMARY KEY,
103
+ value TEXT NOT NULL,
104
+ updated_at INTEGER NOT NULL
105
+ );
106
+ CREATE TABLE IF NOT EXISTS ots_tasks (
107
+ id TEXT PRIMARY KEY,
108
+ ots_id TEXT NOT NULL REFERENCES ots(id) ON DELETE CASCADE,
109
+ account_id TEXT NOT NULL,
110
+ project_id TEXT NOT NULL DEFAULT '',
111
+ who TEXT NOT NULL DEFAULT '',
112
+ channel TEXT NOT NULL DEFAULT '',
113
+ task TEXT NOT NULL DEFAULT '',
114
+ status TEXT NOT NULL DEFAULT 'pending',
115
+ result TEXT NOT NULL DEFAULT '',
116
+ error TEXT NOT NULL DEFAULT '',
117
+ created_at INTEGER NOT NULL,
118
+ updated_at INTEGER NOT NULL
119
+ );
120
+ CREATE INDEX IF NOT EXISTS ots_tasks_ots ON ots_tasks(ots_id, created_at DESC);
90
121
  `;
91
122
 
92
123
  let db = null;
@@ -99,6 +130,7 @@ export function openDb(path = process.env.PLATFORM_DB) {
99
130
  db = new DatabaseSync(file);
100
131
  db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 3000;');
101
132
  db.exec(SCHEMA);
133
+ migrateLookPrints(db);
102
134
  return db;
103
135
  }
104
136
 
@@ -106,6 +138,29 @@ export function getDb() {
106
138
  return db || openDb();
107
139
  }
108
140
 
141
+ /**
142
+ * Cada ot es único: huella del aspecto (ver look.mjs) con índice UNIQUE. Las bases viejas ganan la
143
+ * columna y se rellenan; si ya había repetidos, el más antiguo se queda la huella y los demás quedan
144
+ * sin ella (y se avisa) hasta que se les cambie la cara: guardarlos de nuevo exige una libre.
145
+ */
146
+ function migrateLookPrints(d) {
147
+ if (!d.prepare('PRAGMA table_info(ots)').all().some((c) => c.name === 'look_print')) d.exec('ALTER TABLE ots ADD COLUMN look_print TEXT');
148
+ d.exec('CREATE UNIQUE INDEX IF NOT EXISTS ots_look ON ots(look_print)');
149
+ const seen = new Set(d.prepare('SELECT look_print p FROM ots WHERE look_print IS NOT NULL').all().map((r) => r.p));
150
+ const set = d.prepare('UPDATE ots SET look_print = ? WHERE id = ?');
151
+ for (const row of d.prepare('SELECT id, identity FROM ots WHERE look_print IS NULL ORDER BY created_at').all()) {
152
+ let p = null;
153
+ try {
154
+ p = lookPrint(JSON.parse(row.identity));
155
+ } catch {
156
+ /* identidad rota: sin huella */
157
+ }
158
+ if (!p) continue;
159
+ if (seen.has(p)) console.warn(`[7ots] plataforma: ${row.id} repite la cara de otro ot; queda sin huella hasta que se le cambie`);
160
+ else set.run(p, row.id), seen.add(p);
161
+ }
162
+ }
163
+
109
164
  /** Cierra la base de datos (tests). */
110
165
  export function closeDb() {
111
166
  db?.close();
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Huella del aspecto de un ot: cada ot de 7ots.com es único, no puede haber dos con la misma cara.
3
+ *
4
+ * La huella sale del personaje ya normalizado (identity.look.character, el mismo que dibujan el 2D y
5
+ * el 3D), con las claves ordenadas para que el orden no cuente. El estilo artístico (filtro) no la
6
+ * cambia: el mismo ot en acuarela sigue siendo el mismo ot. Un ot con foto propia usa la URL.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+
10
+ const stable = (v) =>
11
+ Array.isArray(v) ? `[${v.map(stable).join(',')}]` : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}` : JSON.stringify(typeof v === 'string' ? v.toLowerCase() : v);
12
+
13
+ /** identity (normalizada por defineIdentity) → huella hex de 32, o null si no tiene aspecto propio. */
14
+ export function lookPrint(identity) {
15
+ const look = identity?.look || {};
16
+ if (look.kind === 'image' && look.image) return createHash('sha256').update(`image:${look.image}`).digest('hex').slice(0, 32);
17
+ const { style, ...character } = look.character || {};
18
+ if (!Object.keys(character).length) return null;
19
+ return createHash('sha256').update(`character:${stable(character)}`).digest('hex').slice(0, 32);
20
+ }