@7ots/cli 0.1.6 → 0.1.8
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/README.md +3 -3
- package/cli/7ots.mjs +13 -1
- package/cli/lib/account.mjs +152 -2
- package/cli/lib/assistant.mjs +107 -38
- package/cli/lib/brain.mjs +13 -2
- package/cli/lib/config.mjs +5 -1
- package/cli/lib/inferences.mjs +30 -6
- package/cli/lib/integrations.mjs +69 -5
- package/cli/lib/orquesta.mjs +44 -1
- package/cli/lib/pet-server.mjs +352 -11
- package/cli/lib/prompts.mjs +31 -17
- package/cli/lib/prowl.mjs +110 -0
- package/cli/pet/electron/director.cjs +37 -5
- package/cli/pet/electron/main.cjs +92 -10
- package/cli/pet/pet.html +149 -10
- package/cli/pet/settings.html +549 -43
- package/dist/7ots.esm.js +216 -137
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +216 -137
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +1 -1
- package/package.json +17 -14
- package/server/channels/agent.mjs +7 -1
- package/server/channels/index.mjs +3 -2
- package/server/platform/connect.mjs +4 -1
- package/server/platform/db.mjs +14 -1
- package/server/platform/device.mjs +101 -4
- package/server/platform/mods.mjs +222 -0
- package/server/platform/orquesta.mjs +78 -14
- package/server/platform/profile.mjs +161 -0
- package/server/platform/routes.mjs +39 -33
- package/server/platform/runtime.mjs +5 -3
- package/server/platform/store.mjs +1 -1
- package/server/platform/vm.mjs +688 -17
- package/server/server.mjs +12 -2
- package/server/tts.mjs +17 -4
- package/src/avatar/three.js +3 -2
- package/src/character/AvatarEditor.js +95 -7
- package/src/character/Character.js +116 -17
- package/src/character/TradingCard.js +226 -0
- package/src/character/mods/index.js +4 -0
- package/src/character/mods/orc.js +64 -0
- package/src/character/mods.js +251 -0
- package/src/character/more/acc-head.js +213 -0
- package/src/character/more/acc-rest.js +342 -0
- package/src/character/more/brows.js +39 -0
- package/src/character/more/cheeks.js +78 -0
- package/src/character/more/eyes.js +288 -0
- package/src/character/more/mouths.js +259 -0
- package/src/character/more/patterns.js +120 -0
- package/src/character/parts.js +100 -0
- package/src/character/skin.js +166 -1
- package/src/character/styles.js +1285 -9
- package/src/character3d/Character3D.js +253 -37
- package/src/character3d/index.js +7 -1
- package/src/character3d/model.js +367 -0
- package/src/i18n/messages/character.js +37 -22
- package/src/i18n/messages/platform.js +33 -0
- package/src/identity/random.js +17 -3
- package/src/identity/schema.js +10 -0
- package/src/index.js +1 -0
package/llms.txt
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# 7ots
|
|
2
2
|
|
|
3
3
|
> An ot is the user's personal AI companion with its own face, personality and voice. It lives on their desktop as a pet
|
|
4
|
-
> (`npx @7ots/cli pet`), watches over their coding agent (Claude Code /
|
|
4
|
+
> (`npx @7ots/cli pet`), watches over their coding agent (Claude Code hooks + bash/zsh hooks), sets reminders, watches prices and
|
|
5
5
|
> processes, can see the screen if allowed, joins apuchat video calls and can work as an Orquesta assistant inside a project.
|
|
6
6
|
> Every ot's identity (face, personality, voice, meet avatar) is reproducible from a short seed. 7ots integrates apuchat,
|
|
7
7
|
> apumail and voice providers; it does not host audio or mail.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7ots/cli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "7ots.com —
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "7ots.com — your AI companion with a face of its own: a desktop pet that watches over your coding agent (Claude Code and shell hooks), a personal assistant (reminders, alerts, notes, opens sites, sees your screen if allowed) and a reproducible identity (face, personality, voice) for any agent — in apuchat meet calls, Orquesta projects or your own server.",
|
|
5
5
|
"homepage": "https://7ots.com",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -45,22 +45,25 @@
|
|
|
45
45
|
"node": ">=20"
|
|
46
46
|
},
|
|
47
47
|
"keywords": [
|
|
48
|
+
"ai-companion",
|
|
49
|
+
"desktop-pet",
|
|
50
|
+
"tamagotchi",
|
|
51
|
+
"personal-assistant",
|
|
52
|
+
"agent-identity",
|
|
48
53
|
"ai-agent",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
54
|
+
"claude-code",
|
|
55
|
+
"coding-agent",
|
|
56
|
+
"hooks",
|
|
57
|
+
"reminders",
|
|
58
|
+
"avatar",
|
|
59
|
+
"voice",
|
|
60
|
+
"meet",
|
|
61
|
+
"orquesta",
|
|
62
|
+
"prowl",
|
|
53
63
|
"mcp",
|
|
54
64
|
"claude",
|
|
55
65
|
"openai",
|
|
56
|
-
"
|
|
57
|
-
"avatar",
|
|
58
|
-
"i18n",
|
|
59
|
-
"proactive",
|
|
60
|
-
"cli",
|
|
61
|
-
"pet",
|
|
62
|
-
"tamagotchi",
|
|
63
|
-
"claude-code"
|
|
66
|
+
"cli"
|
|
64
67
|
],
|
|
65
68
|
"dependencies": {
|
|
66
69
|
"@anthropic-ai/sdk": "^0.129.0",
|
|
@@ -65,7 +65,7 @@ function systemFor(channel, { lang, identity = getIdentity, instructions = () =>
|
|
|
65
65
|
* @param {{ llm?: {step: Function}, brain?: Function, identity?: () => object, instructions?: () => string }} deps
|
|
66
66
|
* instructions: instrucciones fijas del servidor (defecto SERVER_INSTRUCTIONS)
|
|
67
67
|
*/
|
|
68
|
-
export function createServerAgent({ llm, brain = null, identity = getIdentity, instructions }) {
|
|
68
|
+
export function createServerAgent({ llm, brain = null, identity = getIdentity, instructions, onTurn = null }) {
|
|
69
69
|
if (!llm && !brain) throw new Error('createServerAgent necesita llm o brain');
|
|
70
70
|
/** clave de contacto → { messages: Neutral[], at } */
|
|
71
71
|
const memory = new Map();
|
|
@@ -121,6 +121,12 @@ export function createServerAgent({ llm, brain = null, identity = getIdentity, i
|
|
|
121
121
|
final = clean(final);
|
|
122
122
|
// Se recuerda solo lo conversacional (sin pasos de herramientas ni razonamiento).
|
|
123
123
|
remember(key, [...history, turn[0], ...(final ? [{ role: 'assistant', content: final }] : [])]);
|
|
124
|
+
// correo y DMs quedan apuntados (las llamadas no: sus turnos son voz y avisos internos)
|
|
125
|
+
if (onTurn && channel !== 'voice') {
|
|
126
|
+
try {
|
|
127
|
+
onTurn({ key, channel, user: text, reply: final });
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
124
130
|
return final;
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -20,9 +20,10 @@ import { apuchatConfigFromEnv, apuchatConfigured, createApuchatChannel } from '.
|
|
|
20
20
|
* @param {Function} [deps.log]
|
|
21
21
|
* @param {(ctx: { channel: 'dm'|'email', from: string, verified?: boolean }) => ({ tools: object[], run: Function }|null)} [deps.tools]
|
|
22
22
|
* extra server tools for one conversation (DMs and mails, never voice calls), decided per sender
|
|
23
|
+
* @param {(t: { key: string, channel: 'email'|'dm', user: string, reply: string }) => void} [deps.onTurn] each answered mail / DM
|
|
23
24
|
*/
|
|
24
|
-
export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null }) {
|
|
25
|
-
const agent = createServerAgent({ llm, brain, identity, instructions });
|
|
25
|
+
export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null, onTurn = null }) {
|
|
26
|
+
const agent = createServerAgent({ llm, brain, identity, instructions, onTurn });
|
|
26
27
|
const persona = () => personaOf(identity);
|
|
27
28
|
const mailCfg = config.apumail === false ? null : { ...apumailConfigFromEnv(), ...(config.apumail || {}) };
|
|
28
29
|
const chatCfg = config.apuchat === false ? null : { ...apuchatConfigFromEnv(), ...(config.apuchat || {}) };
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from './apuchat.mjs';
|
|
36
36
|
import {
|
|
37
37
|
askOt, authorizeUrl, cancelTask, disconnect, exchangeCode, listProjects, manageUrl, orquestaAccountView, orquestaUrl, otsOrquesta, parseAllow, projectUrl, saveKey, taskStatus,
|
|
38
|
+
registerOts,
|
|
38
39
|
} from './orquesta.mjs';
|
|
39
40
|
|
|
40
41
|
const penv = process.env;
|
|
@@ -235,7 +236,9 @@ export function createConnect({ send, readJson, originOf, save }) {
|
|
|
235
236
|
}
|
|
236
237
|
if (body.daily !== undefined) settings.ORQUESTA_DAILY = String(Math.min(200, Math.max(1, Math.floor(Number(body.daily)) || 20)));
|
|
237
238
|
if (settings.ORQUESTA_TASKS && !settings.ORQUESTA_PROJECT) return send(res, 400, { error: t('platform.orquesta.pickProject') });
|
|
238
|
-
|
|
239
|
+
const saved = await save(ots, { settings });
|
|
240
|
+
registerOts(account.id, { force: true }).catch(() => null); // seeds the link in Orquesta for a first project
|
|
241
|
+
return send(res, 200, orquestaView(account, saved));
|
|
239
242
|
}
|
|
240
243
|
if (sub === '/orquesta/ask' && req.method === 'POST') {
|
|
241
244
|
const { message, history } = await readJson(req, 128 * 1024);
|
package/server/platform/db.mjs
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
* sites sites/APIs/MCPs enrolled in Prowl.world so ots know how to use them (sites.mjs)
|
|
18
18
|
* devices computers signed in with `7ots login` (the desktop pet): a bearer token, hashed
|
|
19
19
|
* ots_vms the machine where an ot's assistant worker runs (one per ot; vm.mjs, docs/VM-PLAN.md):
|
|
20
|
-
* provider 'byo' (the owner's own server, enrolled by hand)
|
|
20
|
+
* provider 'byo' (the owner's own server, enrolled by hand), 'orquesta' (in the account's Orquesta
|
|
21
|
+
* child org, paid through Orquesta's checkout) or 'orquesta_user' (in the owner's own Orquesta org)
|
|
21
22
|
* vm_jobs work the desktop sends to that VM, and how it ended
|
|
22
23
|
* vm_job_events what the worker reported while doing it (seq per job, for long-polls)
|
|
23
24
|
*/
|
|
@@ -197,6 +198,9 @@ CREATE TABLE IF NOT EXISTS vm_job_events (
|
|
|
197
198
|
|
|
198
199
|
let db = null;
|
|
199
200
|
|
|
201
|
+
/** Carpeta de datos de la plataforma (junto a PLATFORM_DB): ahí van también los archivos subidos. */
|
|
202
|
+
export const dataDir = () => (process.env.PLATFORM_DB === ':memory:' ? resolve(ROOT, 'data') : dirname(resolve(ROOT, process.env.PLATFORM_DB || join('data', 'platform.db'))));
|
|
203
|
+
|
|
200
204
|
/** Abre (y crea si hace falta) la base de datos. Idempotente. */
|
|
201
205
|
export function openDb(path = process.env.PLATFORM_DB) {
|
|
202
206
|
if (db) return db;
|
|
@@ -208,6 +212,15 @@ export function openDb(path = process.env.PLATFORM_DB) {
|
|
|
208
212
|
migrateLookPrints(db);
|
|
209
213
|
// VMs paid by 7ots (provider 'orquesta') are opt-in per account; your own server never needs it (vm.mjs).
|
|
210
214
|
if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'vm_enabled')) db.exec('ALTER TABLE accounts ADD COLUMN vm_enabled INTEGER NOT NULL DEFAULT 0');
|
|
215
|
+
// VMs created through Orquesta: its last-seen VM status, when it was checked and the monthly price it quoted.
|
|
216
|
+
const vmCols = db.prepare('PRAGMA table_info(ots_vms)').all().map((c) => c.name);
|
|
217
|
+
if (!vmCols.includes('orq_status')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_status TEXT NOT NULL DEFAULT ''");
|
|
218
|
+
if (!vmCols.includes('orq_checked_at')) db.exec('ALTER TABLE ots_vms ADD COLUMN orq_checked_at INTEGER');
|
|
219
|
+
if (!vmCols.includes('price_cents')) db.exec('ALTER TABLE ots_vms ADD COLUMN price_cents INTEGER');
|
|
220
|
+
// One Orquesta child org per 7ots account (partner orgs): the account's org id, and the org each VM lives in
|
|
221
|
+
// ('' = the partner's own org, VMs made before child orgs). Provider 'orquesta_user' VMs live in the owner's org.
|
|
222
|
+
if (!db.prepare('PRAGMA table_info(accounts)').all().some((c) => c.name === 'orq_org_id')) db.exec("ALTER TABLE accounts ADD COLUMN orq_org_id TEXT NOT NULL DEFAULT ''");
|
|
223
|
+
if (!vmCols.includes('orq_org_id')) db.exec("ALTER TABLE ots_vms ADD COLUMN orq_org_id TEXT NOT NULL DEFAULT ''");
|
|
211
224
|
return db;
|
|
212
225
|
}
|
|
213
226
|
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* GET /api/device/me → { account, ots: [summary] } (Authorization: Bearer)
|
|
11
11
|
* GET /api/device/ots/:id → { ots: summary, identity } (the owner's full identity)
|
|
12
12
|
* DELETE /api/device/token → signs this computer out
|
|
13
|
+
* GET /api/device/ots/:id/orquesta → { connected, project, projectName, projects }
|
|
14
|
+
* POST /api/device/ots/:id/orquesta/tasks { text, projectId? } · GET …/tasks/:tid
|
|
15
|
+
* The desktop ot uses the account's Orquesta connection through here: the Orquesta token
|
|
16
|
+
* stays sealed on 7ots.com and never reaches the computer. The device is the owner's (it
|
|
17
|
+
* was approved from the dashboard), so it may pick any project the connection can reach.
|
|
18
|
+
* GET /api/device/ots/:id/inbox → { apuchat, email, conversations: [{ channel, who, at, messages }] }
|
|
19
|
+
* what people wrote to the ot on apuchat and email lately, and what it answered (read only;
|
|
20
|
+
* the channels' keys stay on 7ots.com)
|
|
13
21
|
*
|
|
14
22
|
* From the dashboard (session cookie, X-7ots-Admin: 1, same origin; see routes.mjs):
|
|
15
23
|
* GET /api/platform/device/:code → { name, expiresIn } what is asking
|
|
@@ -21,9 +29,13 @@
|
|
|
21
29
|
*/
|
|
22
30
|
|
|
23
31
|
import { defineIdentity } from '../../src/identity/schema.js';
|
|
24
|
-
import {
|
|
32
|
+
import { createHash } from 'node:crypto';
|
|
33
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { join } from 'node:path';
|
|
35
|
+
import { dataDir, getDb } from './db.mjs';
|
|
25
36
|
import { newId, sha256, token } from './crypto.mjs';
|
|
26
|
-
import { getOwnOts, listOts, publicAccount } from './store.mjs';
|
|
37
|
+
import { getConversation, getOwnOts, listConversations, listOts, publicAccount } from './store.mjs';
|
|
38
|
+
import { listProjects, orquestaAccountView, otsOrquesta, runTask, taskStatus } from './orquesta.mjs';
|
|
27
39
|
|
|
28
40
|
const CODE_TTL = 10 * 60_000;
|
|
29
41
|
const PREFIX = '7d_'; // so a leaked token is recognizable
|
|
@@ -52,7 +64,11 @@ export function listDevices(accountId) {
|
|
|
52
64
|
/**
|
|
53
65
|
* @param {{ send: Function, readJson: Function, base: (req) => string, rateLimit: (req) => boolean, summary: (ots) => object }} deps
|
|
54
66
|
*/
|
|
55
|
-
|
|
67
|
+
/** What the pet and 7ots.com keep in sync (the look goes only as look.character). */
|
|
68
|
+
const SYNCED = ['name', 'role', 'tagline', 'bio', 'language', 'languages', 'personality'];
|
|
69
|
+
const MAX_MODEL = 60 * 1024 * 1024;
|
|
70
|
+
|
|
71
|
+
export function createDevice({ send, readJson, base, rateLimit, summary, save }) {
|
|
56
72
|
/** Public half (/api/device/*). @returns {Promise<boolean>} */
|
|
57
73
|
async function api(req, res, path) {
|
|
58
74
|
res.setHeader('Cache-Control', 'no-store');
|
|
@@ -87,12 +103,93 @@ export function createDevice({ send, readJson, base, rateLimit, summary }) {
|
|
|
87
103
|
if (m && req.method === 'GET') {
|
|
88
104
|
const ots = getOwnOts(account.id, m[1]);
|
|
89
105
|
if (!ots) return send(res, 404, { error: 'not found' }), true;
|
|
90
|
-
return send(res, 200, { ots: summary(ots), identity: defineIdentity(ots.identity) }), true;
|
|
106
|
+
return send(res, 200, { ots: summary(ots), identity: defineIdentity(ots.identity), updatedAt: ots.updatedAt }), true;
|
|
107
|
+
}
|
|
108
|
+
// sync with the desktop pet: the same look, personality and character sheet on both sides
|
|
109
|
+
const sm = /^\/ots\/([a-z0-9_]{4,40})\/identity$/.exec(path);
|
|
110
|
+
if (sm && req.method === 'PUT') {
|
|
111
|
+
const ots = getOwnOts(account.id, sm[1]);
|
|
112
|
+
if (!ots) return send(res, 404, { error: 'not found' }), true;
|
|
113
|
+
const inc = (await readJson(req, 512 * 1024))?.identity;
|
|
114
|
+
if (!inc || typeof inc !== 'object') return send(res, 400, { error: 'identity' }), true;
|
|
115
|
+
const patch = Object.fromEntries(SYNCED.filter((k) => k in inc).map((k) => [k, inc[k]]));
|
|
116
|
+
if (inc.look?.character) patch.look = { character: inc.look.character }; // the rest of the look (image, avatar) is the web's
|
|
117
|
+
try {
|
|
118
|
+
const next = await save(ots, { identity: defineIdentity(patch, ots.identity) });
|
|
119
|
+
return send(res, 200, { identity: defineIdentity(next.identity), updatedAt: next.updatedAt }), true;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return send(res, e.status || 500, { error: e.message }), true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// a full-body model (.glb/.vrm) of a mod: content-addressed, served at /mods/<sha16>.<ext> like on the pet
|
|
125
|
+
const fm = /^\/ots\/([a-z0-9_]{4,40})\/files$/.exec(path);
|
|
126
|
+
if (fm && req.method === 'POST') {
|
|
127
|
+
if (!getOwnOts(account.id, fm[1])) return send(res, 404, { error: 'not found' }), true;
|
|
128
|
+
const ext = new URL(req.url, 'http://x').searchParams.get('ext');
|
|
129
|
+
if (!['glb', 'vrm'].includes(ext)) return send(res, 400, { error: 'ext' }), true;
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
for await (const c of req) {
|
|
133
|
+
size += c.length;
|
|
134
|
+
if (size > MAX_MODEL) return send(res, 413, { error: 'too big' }), true;
|
|
135
|
+
chunks.push(c);
|
|
136
|
+
}
|
|
137
|
+
const buf = Buffer.concat(chunks);
|
|
138
|
+
if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return send(res, 400, { error: 'not a glTF binary' }), true;
|
|
139
|
+
const h = createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
140
|
+
mkdirSync(join(dataDir(), 'mods'), { recursive: true });
|
|
141
|
+
writeFileSync(join(dataDir(), 'mods', `${h}.${ext}`), buf);
|
|
142
|
+
return send(res, 200, { url: `/mods/${h}.${ext}` }), true;
|
|
143
|
+
}
|
|
144
|
+
const im = /^\/ots\/([a-z0-9_]{4,40})\/inbox$/.exec(path);
|
|
145
|
+
if (im && req.method === 'GET') {
|
|
146
|
+
const ots = getOwnOts(account.id, im[1]);
|
|
147
|
+
if (!ots) return send(res, 404, { error: 'not found' }), true;
|
|
148
|
+
const conversations = listConversations(ots.id, { limit: 60 })
|
|
149
|
+
.filter((c) => c.channel === 'apuchat' || c.channel === 'apumail')
|
|
150
|
+
.slice(0, 10)
|
|
151
|
+
.map((c) => ({ channel: c.channel, who: c.visitor, at: c.updatedAt, messages: (getConversation(ots.id, c.id)?.messages || []).slice(-6).map((m) => ({ role: m.role, text: m.content.slice(0, 1200), at: m.at })) }));
|
|
152
|
+
const s = ots.settings;
|
|
153
|
+
return send(res, 200, { apuchat: s.APUCHAT_AGENT_CALLSIGN || null, email: s.APUMAIL_AGENT_INBOX || null, conversations }), true;
|
|
154
|
+
}
|
|
155
|
+
const om = /^\/ots\/([a-z0-9_]{4,40})\/orquesta(\/tasks(?:\/([A-Za-z0-9_-]{4,100}))?)?$/.exec(path);
|
|
156
|
+
if (om) {
|
|
157
|
+
const ots = getOwnOts(account.id, om[1]);
|
|
158
|
+
if (!ots) return send(res, 404, { error: 'not found' }), true;
|
|
159
|
+
return orquesta(req, res, account, ots, !!om[2], om[3]);
|
|
91
160
|
}
|
|
92
161
|
}
|
|
93
162
|
return false;
|
|
94
163
|
}
|
|
95
164
|
|
|
165
|
+
/** The ot's Orquesta, used by the desktop ot with the account's connection. */
|
|
166
|
+
async function orquesta(req, res, account, ots, tasks, tid) {
|
|
167
|
+
const view = orquestaAccountView(account.id);
|
|
168
|
+
if (!tasks && req.method === 'GET') {
|
|
169
|
+
const cfg = otsOrquesta(ots);
|
|
170
|
+
let projects = [];
|
|
171
|
+
let error = view.error;
|
|
172
|
+
if (view.connected) projects = await listProjects(account.id).catch((e) => ((error = e.message), []));
|
|
173
|
+
return send(res, 200, { connected: view.connected, error, project: cfg.project, projectName: cfg.projectName, projects }), true;
|
|
174
|
+
}
|
|
175
|
+
if (!view.connected) return send(res, 409, { error: 'Orquesta is not connected on 7ots.com' }), true;
|
|
176
|
+
if (tasks && !tid && req.method === 'POST') {
|
|
177
|
+
const { text, projectId } = await readJson(req, 64 * 1024);
|
|
178
|
+
let project = otsOrquesta(ots).project;
|
|
179
|
+
if (projectId) {
|
|
180
|
+
const p = (await listProjects(account.id)).find((x) => x.id === String(projectId));
|
|
181
|
+
if (!p) return send(res, 404, { error: 'that project is not shared with 7ots' }), true;
|
|
182
|
+
project = p.id;
|
|
183
|
+
}
|
|
184
|
+
if (!project) return send(res, 400, { error: 'pick a project' }), true;
|
|
185
|
+
// the owner, from their own computer: tasks on for this one, the chosen project
|
|
186
|
+
const out = await runTask({ ...ots, settings: { ...ots.settings, ORQUESTA_TASKS: '1', ORQUESTA_PROJECT: project } }, { task: text, who: `owner ${account.email || account.id}`, channel: 'desktop' });
|
|
187
|
+
return send(res, out.ok === false && !out.id ? 400 : 200, out), true;
|
|
188
|
+
}
|
|
189
|
+
if (tid && req.method === 'GET') return send(res, 200, await taskStatus(ots, tid)), true;
|
|
190
|
+
return send(res, 405, { error: 'method' }), true;
|
|
191
|
+
}
|
|
192
|
+
|
|
96
193
|
/** Dashboard half, already behind the session + same-origin checks. @returns {boolean} */
|
|
97
194
|
async function dashboard(req, res, account, path) {
|
|
98
195
|
const m = /^\/device\/([A-Za-z0-9]{8,40})$/.exec(path);
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Community mods: looks that people share for any ot (src/character/mods.js), with installs,
|
|
3
|
+
* 1–5 star ratings (one per account, only after installing) and reports.
|
|
4
|
+
*
|
|
5
|
+
* New mods wait for an admin (PLATFORM_ADMINS: emails, comma separated) unless an admin uploads
|
|
6
|
+
* them. A mod reported by MOD_REPORTS_HIDE accounts (default 3) is hidden until reviewed.
|
|
7
|
+
*
|
|
8
|
+
* Public (any origin):
|
|
9
|
+
* GET /api/mods[?sort=top|new|popular&q=…] → { mods: [view] } approved only
|
|
10
|
+
* GET /api/mods/:id → { mod: view }
|
|
11
|
+
* Signed in, from the dashboard (/api/platform/mods/*, session) or the pet (/api/device/mods/*, bearer):
|
|
12
|
+
* GET /mods/mine → { mods: [view], admin, pending: [view] (admins) }
|
|
13
|
+
* POST /mods { mod } → publish or update one of yours (by its id)
|
|
14
|
+
* POST /mods/files?ext=glb|vrm raw body (≤ 20 MB) → { url: '/mods/<sha16>.<ext>' }
|
|
15
|
+
* POST /mods/:id/install → { mod: view } (counts once per account)
|
|
16
|
+
* POST /mods/:id/rate { stars: 1..5 }
|
|
17
|
+
* POST /mods/:id/report { reason }
|
|
18
|
+
* POST /mods/:id/review { status: approved|rejected|hidden } (admins)
|
|
19
|
+
* DELETE /mods/:id (its author or an admin)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { normalizeMod } from '../../src/character/mods.js';
|
|
26
|
+
import { dataDir, getDb } from './db.mjs';
|
|
27
|
+
|
|
28
|
+
const MAX_FILE = 20 * 1024 * 1024;
|
|
29
|
+
const MAX_MOD_JSON = 2 * 1024 * 1024;
|
|
30
|
+
const STATUSES = ['pending', 'approved', 'rejected', 'hidden'];
|
|
31
|
+
const MODEL = /^\/mods\/([a-f0-9]{16})\.(glb|vrm)$/;
|
|
32
|
+
|
|
33
|
+
let ready = false;
|
|
34
|
+
function db() {
|
|
35
|
+
const d = getDb();
|
|
36
|
+
if (!ready) {
|
|
37
|
+
d.exec(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS community_mods (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
|
41
|
+
author TEXT NOT NULL DEFAULT '',
|
|
42
|
+
mod TEXT NOT NULL,
|
|
43
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
44
|
+
installs INTEGER NOT NULL DEFAULT 0,
|
|
45
|
+
stars_sum INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
stars_n INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
reports INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
created_at INTEGER NOT NULL,
|
|
49
|
+
updated_at INTEGER NOT NULL
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS community_mods_status ON community_mods(status, created_at DESC);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS mod_installs (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
|
|
53
|
+
CREATE TABLE IF NOT EXISTS mod_ratings (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, stars INTEGER NOT NULL, at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
|
|
54
|
+
CREATE TABLE IF NOT EXISTS mod_reports (mod_id TEXT NOT NULL REFERENCES community_mods(id) ON DELETE CASCADE, account_id TEXT NOT NULL, reason TEXT NOT NULL DEFAULT '', at INTEGER NOT NULL, PRIMARY KEY (mod_id, account_id));
|
|
55
|
+
`);
|
|
56
|
+
ready = true;
|
|
57
|
+
}
|
|
58
|
+
return d;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const isModAdmin = (account) =>
|
|
62
|
+
!!account?.email && String(process.env.PLATFORM_ADMINS || '').toLowerCase().split(/[\s,]+/).filter(Boolean).includes(account.email.toLowerCase());
|
|
63
|
+
|
|
64
|
+
const authorOf = (a) => String(a.name || (a.email || '').split('@')[0] || 'anon').slice(0, 40);
|
|
65
|
+
|
|
66
|
+
function view(row, me) {
|
|
67
|
+
if (!row) return null;
|
|
68
|
+
const v = {
|
|
69
|
+
id: row.id,
|
|
70
|
+
mod: JSON.parse(row.mod),
|
|
71
|
+
author: row.author,
|
|
72
|
+
status: row.status,
|
|
73
|
+
installs: row.installs,
|
|
74
|
+
rating: row.stars_n ? Math.round((row.stars_sum / row.stars_n) * 10) / 10 : null,
|
|
75
|
+
ratings: row.stars_n,
|
|
76
|
+
createdAt: row.created_at,
|
|
77
|
+
updatedAt: row.updated_at,
|
|
78
|
+
};
|
|
79
|
+
if (me) {
|
|
80
|
+
const d = db();
|
|
81
|
+
v.mine = row.account_id === me.id;
|
|
82
|
+
v.installed = !!d.prepare('SELECT 1 FROM mod_installs WHERE mod_id = ? AND account_id = ?').get(row.id, me.id);
|
|
83
|
+
v.myStars = d.prepare('SELECT stars FROM mod_ratings WHERE mod_id = ? AND account_id = ?').get(row.id, me.id)?.stars || null;
|
|
84
|
+
v.reported = !!d.prepare('SELECT 1 FROM mod_reports WHERE mod_id = ? AND account_id = ?').get(row.id, me.id);
|
|
85
|
+
}
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ORDER = {
|
|
90
|
+
// Bayesian average: a single 5★ does not beat many 4.5★
|
|
91
|
+
top: '(stars_sum + 3.0 * 2) / (stars_n + 2) DESC, installs DESC, created_at DESC',
|
|
92
|
+
new: 'created_at DESC',
|
|
93
|
+
popular: 'installs DESC, stars_n DESC, created_at DESC',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export function listMods({ sort = 'top', q = '', limit = 60 } = {}) {
|
|
97
|
+
const like = `%${String(q).toLowerCase().replace(/[%_]/g, '').slice(0, 40)}%`;
|
|
98
|
+
return db()
|
|
99
|
+
.prepare(`SELECT * FROM community_mods WHERE status = 'approved' AND (lower(id) LIKE ? OR lower(mod) LIKE ? OR lower(author) LIKE ?) ORDER BY ${ORDER[sort] || ORDER.top} LIMIT ?`)
|
|
100
|
+
.all(like, like, like, Math.min(100, Math.max(1, Number(limit) || 60)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A model a mod points at must already be on 7ots.com (uploaded first through /mods/files). */
|
|
104
|
+
function modelsMissing(mod) {
|
|
105
|
+
const urls = [mod.model?.url].filter((u) => MODEL.test(u || ''));
|
|
106
|
+
return urls.filter((u) => !existsSync(join(dataDir(), 'mods', u.slice('/mods/'.length))));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createMods({ send, readJson }) {
|
|
110
|
+
/** Public half. @returns {Promise<boolean>} */
|
|
111
|
+
async function publicApi(req, res, path) {
|
|
112
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
113
|
+
if (req.method === 'OPTIONS') return send(res, 204), true;
|
|
114
|
+
if (req.method !== 'GET') return false;
|
|
115
|
+
if (path === '' || path === '/') {
|
|
116
|
+
const u = new URL(req.url, 'http://x');
|
|
117
|
+
return send(res, 200, { mods: listMods({ sort: u.searchParams.get('sort'), q: u.searchParams.get('q') || '', limit: u.searchParams.get('limit') }).map((r) => view(r)) }), true;
|
|
118
|
+
}
|
|
119
|
+
const m = /^\/([a-z0-9-]{1,32})$/.exec(path);
|
|
120
|
+
if (m) {
|
|
121
|
+
const row = db().prepare("SELECT * FROM community_mods WHERE id = ? AND status = 'approved'").get(m[1]);
|
|
122
|
+
return row ? send(res, 200, { mod: view(row) }) : send(res, 404, { error: 'not found' }), true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Signed-in half (account from the session or a device). path is after /mods. @returns {Promise<boolean>} */
|
|
128
|
+
async function accountApi(req, res, account, path) {
|
|
129
|
+
const d = db();
|
|
130
|
+
const admin = isModAdmin(account);
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (path === '' && req.method === 'GET') {
|
|
133
|
+
const u = new URL(req.url, 'http://x');
|
|
134
|
+
return send(res, 200, { mods: listMods({ sort: u.searchParams.get('sort'), q: u.searchParams.get('q') || '' }).map((r) => view(r, account)) }), true;
|
|
135
|
+
}
|
|
136
|
+
if (path === '/mine' && req.method === 'GET') {
|
|
137
|
+
const mine = d.prepare('SELECT * FROM community_mods WHERE account_id = ? ORDER BY updated_at DESC').all(account.id).map((r) => view(r, account));
|
|
138
|
+
const pending = admin ? d.prepare("SELECT * FROM community_mods WHERE status IN ('pending', 'hidden') ORDER BY reports DESC, created_at").all().map((r) => ({ ...view(r, account), reports: r.reports })) : [];
|
|
139
|
+
return send(res, 200, { mods: mine, admin, pending }), true;
|
|
140
|
+
}
|
|
141
|
+
if (path === '/files' && req.method === 'POST') {
|
|
142
|
+
const ext = new URL(req.url, 'http://x').searchParams.get('ext');
|
|
143
|
+
if (!['glb', 'vrm'].includes(ext)) return send(res, 400, { error: 'ext' }), true;
|
|
144
|
+
const chunks = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
for await (const c of req) {
|
|
147
|
+
size += c.length;
|
|
148
|
+
if (size > MAX_FILE) return send(res, 413, { error: 'max 20 MB' }), true;
|
|
149
|
+
chunks.push(c);
|
|
150
|
+
}
|
|
151
|
+
const buf = Buffer.concat(chunks);
|
|
152
|
+
if (buf.length < 20 || buf.toString('latin1', 0, 4) !== 'glTF') return send(res, 400, { error: 'not a glTF binary (.glb/.vrm)' }), true;
|
|
153
|
+
const h = createHash('sha256').update(buf).digest('hex').slice(0, 16);
|
|
154
|
+
mkdirSync(join(dataDir(), 'mods'), { recursive: true });
|
|
155
|
+
writeFileSync(join(dataDir(), 'mods', `${h}.${ext}`), buf);
|
|
156
|
+
return send(res, 200, { url: `/mods/${h}.${ext}` }), true;
|
|
157
|
+
}
|
|
158
|
+
if (path === '' && req.method === 'POST') {
|
|
159
|
+
const body = await readJson(req, MAX_MOD_JSON + 4096);
|
|
160
|
+
const mod = normalizeMod(body?.mod);
|
|
161
|
+
if (!mod) return send(res, 400, { error: 'mod' }), true;
|
|
162
|
+
if (!mod.model && !mod.parts.length && !Object.keys(mod.character).length) return send(res, 400, { error: 'empty mod' }), true;
|
|
163
|
+
const missing = modelsMissing(mod);
|
|
164
|
+
if (missing.length) return send(res, 409, { error: 'upload the model first', missing }), true;
|
|
165
|
+
const prev = d.prepare('SELECT account_id FROM community_mods WHERE id = ?').get(mod.id);
|
|
166
|
+
if (prev && prev.account_id !== account.id) return send(res, 409, { error: 'id taken' }), true;
|
|
167
|
+
const json = JSON.stringify({ ...mod, by: mod.by || authorOf(account) });
|
|
168
|
+
if (prev) d.prepare("UPDATE community_mods SET mod = ?, status = ?, updated_at = ? WHERE id = ?").run(json, admin ? 'approved' : 'pending', now, mod.id);
|
|
169
|
+
else d.prepare('INSERT INTO community_mods (id, account_id, author, mod, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(mod.id, account.id, authorOf(account), json, admin ? 'approved' : 'pending', now, now);
|
|
170
|
+
return send(res, prev ? 200 : 201, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(mod.id), account) }), true;
|
|
171
|
+
}
|
|
172
|
+
const m = /^\/([a-z0-9-]{1,32})(\/(install|rate|report|review))?$/.exec(path);
|
|
173
|
+
if (!m) return false;
|
|
174
|
+
const row = d.prepare('SELECT * FROM community_mods WHERE id = ?').get(m[1]);
|
|
175
|
+
// authors and admins see their mod whatever its status; everyone else only approved ones
|
|
176
|
+
if (!row || (row.status !== 'approved' && row.account_id !== account.id && !admin)) return send(res, 404, { error: 'not found' }), true;
|
|
177
|
+
const action = m[3];
|
|
178
|
+
if (!action && req.method === 'GET') return send(res, 200, { mod: view(row, account) }), true;
|
|
179
|
+
if (!action && req.method === 'DELETE') {
|
|
180
|
+
if (row.account_id !== account.id && !admin) return send(res, 403, { error: 'not yours' }), true;
|
|
181
|
+
d.prepare('DELETE FROM community_mods WHERE id = ?').run(row.id);
|
|
182
|
+
return send(res, 200, { ok: true }), true;
|
|
183
|
+
}
|
|
184
|
+
if (req.method !== 'POST') return send(res, 405, { error: 'method' }), true;
|
|
185
|
+
if (action === 'install') {
|
|
186
|
+
const r = d.prepare('INSERT OR IGNORE INTO mod_installs (mod_id, account_id, at) VALUES (?, ?, ?)').run(row.id, account.id, now);
|
|
187
|
+
if (r.changes) d.prepare('UPDATE community_mods SET installs = installs + 1 WHERE id = ?').run(row.id);
|
|
188
|
+
return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
|
|
189
|
+
}
|
|
190
|
+
if (action === 'rate') {
|
|
191
|
+
const stars = Math.round(Number((await readJson(req, 1024))?.stars));
|
|
192
|
+
if (!(stars >= 1 && stars <= 5)) return send(res, 400, { error: 'stars 1-5' }), true;
|
|
193
|
+
if (row.account_id === account.id) return send(res, 403, { error: 'own mod' }), true;
|
|
194
|
+
if (!d.prepare('SELECT 1 FROM mod_installs WHERE mod_id = ? AND account_id = ?').get(row.id, account.id)) return send(res, 403, { error: 'install it first' }), true;
|
|
195
|
+
const old = d.prepare('SELECT stars FROM mod_ratings WHERE mod_id = ? AND account_id = ?').get(row.id, account.id);
|
|
196
|
+
d.prepare('INSERT INTO mod_ratings (mod_id, account_id, stars, at) VALUES (?, ?, ?, ?) ON CONFLICT(mod_id, account_id) DO UPDATE SET stars = excluded.stars, at = excluded.at').run(row.id, account.id, stars, now);
|
|
197
|
+
d.prepare('UPDATE community_mods SET stars_sum = stars_sum + ?, stars_n = stars_n + ? WHERE id = ?').run(stars - (old?.stars || 0), old ? 0 : 1, row.id);
|
|
198
|
+
return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
|
|
199
|
+
}
|
|
200
|
+
if (action === 'report') {
|
|
201
|
+
const reason = String((await readJson(req, 4096))?.reason || '').replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, 300);
|
|
202
|
+
const r = d.prepare('INSERT OR IGNORE INTO mod_reports (mod_id, account_id, reason, at) VALUES (?, ?, ?, ?)').run(row.id, account.id, reason, now);
|
|
203
|
+
if (r.changes) {
|
|
204
|
+
d.prepare('UPDATE community_mods SET reports = reports + 1 WHERE id = ?').run(row.id);
|
|
205
|
+
const hide = Number(process.env.MOD_REPORTS_HIDE || 3);
|
|
206
|
+
d.prepare("UPDATE community_mods SET status = 'hidden' WHERE id = ? AND status = 'approved' AND reports >= ?").run(row.id, hide);
|
|
207
|
+
}
|
|
208
|
+
return send(res, 200, { ok: true }), true;
|
|
209
|
+
}
|
|
210
|
+
if (action === 'review') {
|
|
211
|
+
if (!admin) return send(res, 403, { error: 'admins only' }), true;
|
|
212
|
+
const status = (await readJson(req, 1024))?.status;
|
|
213
|
+
if (!STATUSES.includes(status)) return send(res, 400, { error: 'status' }), true;
|
|
214
|
+
d.prepare('UPDATE community_mods SET status = ?, reports = CASE WHEN ? = \'approved\' THEN 0 ELSE reports END, updated_at = ? WHERE id = ?').run(status, status, now, row.id);
|
|
215
|
+
if (status === 'approved') d.prepare('DELETE FROM mod_reports WHERE mod_id = ?').run(row.id);
|
|
216
|
+
return send(res, 200, { mod: view(d.prepare('SELECT * FROM community_mods WHERE id = ?').get(row.id), account) }), true;
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { publicApi, accountApi };
|
|
222
|
+
}
|