@7ots/cli 0.1.1 → 0.1.3
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 +17 -1
- package/cli/7ots.mjs +133 -36
- package/cli/lib/assistant.mjs +337 -0
- package/cli/lib/brain.mjs +10 -7
- package/cli/lib/i18n.mjs +78 -11
- package/cli/lib/install.mjs +29 -1
- package/cli/lib/pet-core.mjs +3 -2
- package/cli/lib/pet-server.mjs +30 -0
- package/cli/lib/prompts.mjs +199 -7
- package/cli/lib/terminal.mjs +27 -6
- package/cli/pet/electron/main.cjs +79 -28
- package/cli/pet/pet.html +55 -6
- package/dist/7ots.esm.js +370 -142
- package/dist/7ots.esm.js.map +4 -4
- package/dist/7ots.iife.js +370 -142
- package/dist/7ots.iife.js.map +4 -4
- package/llms.txt +198 -6
- package/package.json +1 -1
- package/server/channels/apuchat.mjs +8 -5
- package/server/channels/apumail.mjs +13 -3
- package/server/channels/index.mjs +5 -3
- package/server/platform/apuchat.mjs +266 -0
- package/server/platform/auth.mjs +39 -10
- package/server/platform/connect.mjs +347 -0
- package/server/platform/db.mjs +55 -0
- package/server/platform/look.mjs +20 -0
- package/server/platform/orquesta.mjs +499 -0
- package/server/platform/routes.mjs +57 -8
- package/server/platform/runtime.mjs +75 -15
- package/server/platform/store.mjs +130 -9
- package/server/server.mjs +14 -3
- package/src/avatar/AvatarStage.js +54 -9
- package/src/avatar/three.js +62 -0
- package/src/character/AvatarEditor.js +174 -50
- package/src/character/Character.js +164 -26
- package/src/character/motion.js +95 -34
- package/src/character/parts.js +43 -12
- package/src/character/skin.js +412 -0
- package/src/character/styles.js +1429 -0
- package/src/character3d/Character3D.js +1693 -0
- package/src/character3d/body.js +154 -0
- package/src/character3d/fx.js +323 -0
- package/src/character3d/index.js +281 -0
- package/src/character3d/materials.js +429 -0
- package/src/character3d/stage.js +187 -0
- package/src/i18n/messages/character.js +18 -42
- package/src/i18n/messages/platform.js +135 -0
- package/src/identity/schema.js +16 -8
- package/src/index.js +1 -0
package/llms.txt
CHANGED
|
@@ -1,13 +1,205 @@
|
|
|
1
1
|
# 7ots
|
|
2
2
|
|
|
3
3
|
> Open-source embeddable web agent: it sees the page, speaks and acts for your visitor. One HTML tag, your server, your brand.
|
|
4
|
-
> Every ot has an identity (face, personality, voice, meet avatar) reproducible from a short seed
|
|
5
|
-
>
|
|
4
|
+
> Every ot has an identity (face, personality, voice, meet avatar) reproducible from a short seed. The same ot can live
|
|
5
|
+
> on a website (widget), on the user's computer (a virtual pet: desktop window, terminal or tmux), in apuchat meet
|
|
6
|
+
> video calls, and answer email (apumail) and chat (apuchat). 7ots integrates those services; it does not host audio or mail.
|
|
6
7
|
|
|
7
|
-
- Site: https://7ots.com/
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
8
|
+
- Site: https://7ots.com/ · create an identity in the browser: https://7ots.com/#create
|
|
9
|
+
- Hosted ots (dashboard, no server needed): https://7ots.com/app/
|
|
10
|
+
- CLI: npm `@7ots/cli` (https://www.npmjs.com/package/@7ots/cli) → `npx -y @7ots/cli help`, `npx -y @7ots/cli <cmd> --help`
|
|
11
|
+
- This file: https://7ots.com/llms.txt (also `npx -y @7ots/cli docs`)
|
|
12
|
+
|
|
13
|
+
# Read this first (for coding agents)
|
|
14
|
+
|
|
15
|
+
1. Never invent identity fields or config files by hand: use the CLI, it validates everything.
|
|
16
|
+
2. Ask the user only what you can't tell from the project. The usual question is WHERE the ot should live:
|
|
17
|
+
- a website in this repo → widget (section "Website");
|
|
18
|
+
- not a website (library, backend, CLI, data…) → the pet on their computer, a meet call, or hosting on https://7ots.com/app/;
|
|
19
|
+
- "on my computer" / "a tamagotchi" → the pet (section "Pet").
|
|
20
|
+
3. From an agent, commands must return: use `--detach` for the pet, run `meet` in the background, and ask the user to run
|
|
21
|
+
interactive commands themselves (`setup`, the terminal pet) — in Claude Code: `! npx -y @7ots/cli setup`.
|
|
22
|
+
4. Language: `--lang en|es|pt` on `new` sets the ot's language, and from then on every CLI text follows it
|
|
23
|
+
(order: SEVENOTS_LANG > ~/.7ots/config.json "lang" > the ot's language > LANG > English).
|
|
24
|
+
5. Secrets (API keys, tokens) never go in HTML, in identity.json or in your reply: they go in env vars / .env of a server,
|
|
25
|
+
in ~/.7ots/keys.json (written by `setup`, 0600) or in the https://7ots.com/app/ dashboard.
|
|
26
|
+
6. When done, tell the user: the ot's name and seed, where it lives, and how to see it (URL, `7ots pet`, …).
|
|
27
|
+
|
|
28
|
+
# Typical flows
|
|
29
|
+
|
|
30
|
+
- "Use 7ots" in a website repo:
|
|
31
|
+
`npx -y @7ots/cli new --lang <en|es|pt>` → `npx -y @7ots/cli install` → tell the user which AI endpoint the widget uses.
|
|
32
|
+
- "Give me a pet":
|
|
33
|
+
`npx -y @7ots/cli new` (if there is no identity) → `npx -y @7ots/cli pet --mode desktop --detach` → `npx -y @7ots/cli hooks install --claude`.
|
|
34
|
+
- "Put it in a call": `npx -y @7ots/cli meet --new` (in the background) and give the printed link only to the user.
|
|
35
|
+
- "Host it, no server": create it at https://7ots.com/app/ (or open the link `install` prints) and paste its one-line embed.
|
|
36
|
+
|
|
37
|
+
# CLI reference (`npx -y @7ots/cli <command>`, or `7ots <command>` once installed globally)
|
|
38
|
+
|
|
39
|
+
Every command accepts `--help` (prints usage, never launches anything) and `--lang en|es|pt`.
|
|
40
|
+
|
|
41
|
+
| Command | What it does |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `new [--seed s] [--name n] [--lang en\|es\|pt] [--global] [--force] [--json]` | Random identity → `.7ots/identity.json` (`--global`: `~/.7ots/identity.json`, shared by every project). Keeps an existing one unless `--force`. |
|
|
44
|
+
| `show [--json\|--prompt]` | The identity in use (the project's, else the global one). `--prompt`: as a system prompt. |
|
|
45
|
+
| `seed <text>` | Normalizes a seed. Same seed = same ot everywhere. |
|
|
46
|
+
| `install [--target html\|self\|print] [file]` | Puts the widget in the project (see "Website"). |
|
|
47
|
+
| `setup` (alias `wizard`, `init`) | Interactive wizard: where the ot lives, brain, voice, pet mode, chattiness, hooks. Writes `~/.7ots/config.json` and `keys.json`. |
|
|
48
|
+
| `pet [--mode desktop\|terminal\|browser\|auto] [--detach] [--opaque] [--tmux] [--compact]` | Wakes the pet (see "Pet"). |
|
|
49
|
+
| `feed` · `play` · `sleep` · `wake` · `say <text>` | Care for the pet (works even if it is not running: the state is saved). |
|
|
50
|
+
| `status [--line\|--json]` | Level, food, energy, fun. `--line`: one short line for status bars. |
|
|
51
|
+
| `stop` | Stops the pet daemon (and its windows). |
|
|
52
|
+
| `hooks install\|remove [--claude] [--shell] [--global]` | Lets the pet react to your work (see "Hooks"). |
|
|
53
|
+
| `meet --new [--minutes n]` · `meet "<invite or link>"` | The ot joins an apuchat meet video call. |
|
|
54
|
+
| `prompt [identity\|install\|pet\|meet\|all]` | Short recipes for coding agents. |
|
|
55
|
+
| `docs` | Prints this file. |
|
|
56
|
+
| `login orquesta` | Connects a getorquesta.com account (brain "batuta", home "orquesta"). |
|
|
57
|
+
| `server` | Runs the 7ots proxy (`/api/agent`) with the env vars below. |
|
|
58
|
+
| `version` · `help` | |
|
|
59
|
+
|
|
60
|
+
# Files
|
|
61
|
+
|
|
62
|
+
| Path | Content |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `.7ots/identity.json` (project) / `~/.7ots/identity.json` (global) | The identity. Public data only (name, role, look, voice, personality, contact). Commit it if you want. |
|
|
65
|
+
| `~/.7ots/config.json` | Written by `setup`: `lang`, `home`, `brain`, `voice`, `pet` (below). |
|
|
66
|
+
| `~/.7ots/keys.json` (0600) | Keys typed in `setup`, under their env-var names. Env vars always win. Never print it. |
|
|
67
|
+
| `~/.7ots/pet.json` · `pet.log` · `pet.token` | Pet state, daemon log, local auth token. |
|
|
68
|
+
| `SEVENOTS_HOME` | Moves `~/.7ots` elsewhere. `SEVENOTS_PET_PORT` (default 7717) moves the pet daemon. `SEVENOTS_LANG` forces the CLI language. |
|
|
69
|
+
|
|
70
|
+
Identity fields: `id, name, role, tagline, seed, bio, language, languages, personality{tone, traits, instructions}, look{color, …},
|
|
71
|
+
voice{provider, id, lang}, contact{…}`. Change them with `new --force`, the web editor (https://7ots.com/#create) or the backoffice, not by hand.
|
|
72
|
+
|
|
73
|
+
## config.json
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
home { kind: 'local' } only on this computer
|
|
77
|
+
{ kind: '7ots', url: 'https://7ots.com/api/o/<id>' } hosted on 7ots.com
|
|
78
|
+
{ kind: 'server', url: 'https://site/api/agent' } the user's own 7ots server
|
|
79
|
+
{ kind: 'orquesta', projectId, projectName } a getorquesta.com project
|
|
80
|
+
brain { kind: 'lines' } built-in phrases, no AI (default)
|
|
81
|
+
{ kind: 'cli', cli: 'claude'|'codex'|'gemini'|'ollama'|'custom', model?, command? } the user's own AI CLI
|
|
82
|
+
{ kind: 'api', provider: 'anthropic'|'openai', model?, baseUrl? } a key in keys.json/env
|
|
83
|
+
{ kind: '7ots' } the home's /chat (7ots.com or own server)
|
|
84
|
+
{ kind: 'batuta', model? } Orquesta's Batuta
|
|
85
|
+
voice { kind: 'none'|'browser'|'apuchat'|'elevenlabs'|'grok'|'fish'|'openai', voiceId? }
|
|
86
|
+
pet { mode: 'ask'|'desktop'|'terminal'|'browser', annoy: 0..3, opaque?: true }
|
|
87
|
+
annoy: 0 silent · 1 rarely · 2 normal · 3 chatty (+ terminal bell)
|
|
88
|
+
```
|
|
89
|
+
Keys by name: ANTHROPIC_API_KEY, OPENAI_API_KEY, ORQUESTA_TOKEN, APUCHAT_VOICE_TOKEN, ELEVENLABS_API_KEY, XAI_API_KEY, FISH_API_KEY.
|
|
90
|
+
|
|
91
|
+
# Pet
|
|
92
|
+
|
|
93
|
+
- `desktop`: a small always-on-top window that walks along the bottom of the screen (Electron via npx, ~100 MB the first time).
|
|
94
|
+
Find it bottom-right; menu on right click or the tray icon (Show / Hide / Feed / Play / Sleep / Quit). Hide keeps it alive.
|
|
95
|
+
- `terminal`: drawn in the current terminal, keys f feed · p play · s sleep/wake · q quit. Needs its own terminal: ask the user.
|
|
96
|
+
- `browser`: a tab at http://127.0.0.1:7717/pet.
|
|
97
|
+
- `auto`: desktop if there is a screen, else terminal.
|
|
98
|
+
- `--detach`: return immediately (always from agents). The daemon keeps running until `7ots stop`.
|
|
99
|
+
- tmux: `7ots pet --tmux` opens the terminal pet in a side pane (`--compact` for a narrow one); for the status bar add
|
|
100
|
+
`set -g status-right "#(7ots status --line)"` to ~/.tmux.conf.
|
|
101
|
+
- Needs: food, energy and fun decay with time (time away counts at most 8 h); it gains XP and levels from passing
|
|
102
|
+
commands and finished agent turns.
|
|
103
|
+
|
|
104
|
+
Troubleshooting:
|
|
105
|
+
- A grey/black box behind the pet (Linux without a compositor, some GPUs): `7ots pet --opaque` (or `"pet": {"opaque": true}` in config.json).
|
|
106
|
+
- Can't see it: look at the tray icon, or `7ots stop && 7ots pet --mode desktop` (it comes back bottom-right).
|
|
107
|
+
- Electron can't start (no display, headless, WSL): it falls back to the browser tab; or use `--mode terminal`.
|
|
108
|
+
- Port busy: `SEVENOTS_PET_PORT=7720`.
|
|
109
|
+
|
|
110
|
+
# Hooks
|
|
111
|
+
|
|
112
|
+
- `hooks install --claude`: Claude Code hooks in `.claude/settings.local.json` of this project (`--global`: ~/.claude/settings.json).
|
|
113
|
+
The pet hears tool calls, errors and finished turns (`7ots event --from claude`, reads the hook JSON on stdin; never fails).
|
|
114
|
+
- `hooks install --shell`: bash/zsh hook in ~/.bashrc / ~/.zshrc: the pet hears commands and exit codes (secrets are redacted).
|
|
115
|
+
- `hooks remove` undoes both. Without a running pet the events still change its state silently.
|
|
116
|
+
|
|
117
|
+
# Website (widget)
|
|
118
|
+
|
|
119
|
+
`npx -y @7ots/cli install` detects the project:
|
|
120
|
+
- plain HTML (index.html in ., public/, src/, static/, www/, site/) → inserts the widget before </body> between
|
|
121
|
+
`<!-- 7ots -->` markers (idempotent: re-running replaces the block);
|
|
122
|
+
- a framework (Next, Nuxt, Astro, SvelteKit, Remix, Gatsby, Vite, Django, Rails, Laravel, Hugo, Jekyll…) → prints the snippet and
|
|
123
|
+
which file to paste it in (root layout/template, before </body>);
|
|
124
|
+
- not a website → says so and suggests the pet, meet or hosting;
|
|
125
|
+
- `--target print` only prints the snippet; `--target self` prepares the identity for the user's own server.
|
|
126
|
+
|
|
127
|
+
Snippet for an ot hosted on 7ots.com (simplest; allowed domains are set in the dashboard):
|
|
128
|
+
```html
|
|
129
|
+
<script src="https://7ots.com/api/o/<id>/embed.js" defer></script>
|
|
130
|
+
```
|
|
131
|
+
Snippet with your own proxy:
|
|
132
|
+
```html
|
|
133
|
+
<script src="https://cdn.jsdelivr.net/npm/@7ots/cli/dist/7ots.iife.js" defer></script>
|
|
134
|
+
<script>
|
|
135
|
+
addEventListener('DOMContentLoaded', () => SevenOts.init({ endpoint: '/api/agent', identity: true }));
|
|
136
|
+
</script>
|
|
137
|
+
```
|
|
138
|
+
Declarative: `<ots-agent endpoint="/api/agent" site-key="pk_x" name="Ana"></ots-agent>`. ES module: `import { init } from '@7ots/cli'`.
|
|
139
|
+
|
|
140
|
+
`SevenOts.init(options)`:
|
|
141
|
+
| Option | Meaning |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `endpoint` | Proxy URL (`/api/agent`, `https://7ots.com/api/o/<id>`, …). Required. |
|
|
144
|
+
| `siteKey` | Public site id the proxy may require (`SITE_KEYS`). |
|
|
145
|
+
| `identity` | `true` (ask the endpoint), a URL, or an inline identity object (public fields only). |
|
|
146
|
+
| `agent` | `{ name, role, siteName, language, instructions, expressive }`. |
|
|
147
|
+
| `avatar` | `{ url, body: 'F'\|'M', mood, cameraView }` (3D, needs an importmap for three) or `false`; default is the 2D character. |
|
|
148
|
+
| `voice` | `{ tts: 'proxy'\|'browser'\|false, stt: true, lang }` or `false`. |
|
|
149
|
+
| `auth` | `{ token }`, `{ getToken, getUser }` or `{ credentials: 'include' }`; `exposeClaims`. |
|
|
150
|
+
| `mcp` | `[{ url, name, requiresAuth, filter, confirm }]` MCP servers (Streamable HTTP). |
|
|
151
|
+
| `actions` | Custom tools `[{ name, description, parameters, handler, confirm }]`. |
|
|
152
|
+
| `templates` | `{ name: (data, { escape }) => html }` for show_modal / open_sidebar. |
|
|
153
|
+
| `navigation` | `{ allowedOrigins, router }` (`router` for SPAs). |
|
|
154
|
+
| `proactive` | `{ level: 'quiet'\|'normal'\|'bold', greetDelayMs, dwellMs, idleMs, cooldownMs, maxPerSession, … }` or `false`. |
|
|
155
|
+
| `context` | `{ privateSelectors, ignoreSelectors, extra: () => ({...}) }` — what it may read on the page. |
|
|
156
|
+
| `contact` | `{ apuchat: true, apumail: true \| { categories } }` or `false` — hand off to a human. |
|
|
157
|
+
| `builtins` | `{ exclude: ['click', …], confirmClicks: 'submit'\|'all'\|'none' }`. |
|
|
158
|
+
| `pointer` | `{ speed, visible }` or `false` — the virtual mouse/keyboard. |
|
|
159
|
+
| `pageTools` | `true` (default): forms/buttons with `data-ots-tool` become tools. |
|
|
160
|
+
| `mode` / `companion` | `'panel'` (default) or `'companion'` (the avatar walks the page); `{ size, idleHomeMs, follow, wanderMs, watchCursor }`. |
|
|
161
|
+
| `theme` | `{ primary, radius, position: 'right'\|'left', font }`. |
|
|
162
|
+
|
|
163
|
+
HTML tools without JS: `<form data-ots-tool="invite_member" data-ots-description="…" data-ots-confirm="Invite {email}?" data-ots-auth>`
|
|
164
|
+
(named fields become parameters). Runtime API: `agent.ask(text)`, `say`, `notify`, `registerAction`, `registerMcp`,
|
|
165
|
+
`setAuthToken`, `setIdentity`, `setProactivity`, `open/close/reset/destroy`, `on(event, fn)`.
|
|
166
|
+
|
|
167
|
+
# Own server (`npx -y @7ots/cli server`, or `node server/server.mjs` from the repo)
|
|
168
|
+
|
|
169
|
+
Reads env vars / `.env`. Keys live only here. Admin UI at /backoffice/ (localhost only unless ADMIN_PASSWORD).
|
|
170
|
+
- Core: PORT (8787), ALLOWED_ORIGINS (comma list), SITE_KEYS, RATE_LIMIT_PER_MIN (40), TRUST_PROXY, SERVER_INSTRUCTIONS, LOCALE (es|en|pt), LOG_LEVEL.
|
|
171
|
+
- LLM: LLM_PROVIDER (anthropic | openai | mock), ANTHROPIC_API_KEY, OPENAI_API_KEY, LLM_MODEL, LLM_BASE_URL (OpenAI-compatible:
|
|
172
|
+
DeepSeek, Groq, Ollama, OpenRouter…), LLM_EFFORT (low…max), LLM_MAX_TOKENS, LLM_FALLBACKS (on|off).
|
|
173
|
+
- Voice (TTS): TTS_PROVIDER (apuchat | elevenlabs | grok | fish | openai | none = browser voice; empty = first with a key);
|
|
174
|
+
APUCHAT_VOICE_URL, APUCHAT_VOICE_TOKEN, APUCHAT_VOICE_ID, APUCHAT_VOICE_PROVIDER; ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID,
|
|
175
|
+
ELEVENLABS_MODEL; XAI_API_KEY, XAI_TTS_VOICE; FISH_API_KEY, FISH_VOICE_ID, FISH_MODEL; OPENAI_TTS_MODEL, OPENAI_TTS_VOICE.
|
|
176
|
+
- Email tickets (apumail): APUMAIL_API, APUMAIL_INBOX, APUMAIL_INBOX_TOKEN, APUMAIL_TO.
|
|
177
|
+
- Human handoff (apuchat): APUCHAT_HUB, APUCHAT_NOTIFIER_IDENTITY_KEY, APUCHAT_OPERATOR_HANDLE, APUCHAT_TRANSCRIBE.
|
|
178
|
+
- Backoffice: ADMIN_PASSWORD, CONFIG_FILE, SECRETS_FILE, IDENTITY_FILE (default data/identity.json).
|
|
179
|
+
- The agent outside the web: AGENT_NAME, AGENT_ROLE, AGENT_SITE_URL, AGENT_INSTRUCTIONS, AGENT_LANG; own mailbox APUMAIL_AGENT_INBOX,
|
|
180
|
+
APUMAIL_AGENT_TOKEN, APUMAIL_AGENT_WEBHOOK_SECRET, APUMAIL_AGENT_DAILY; own apuchat/meet identity APUCHAT_AGENT_IDENTITY_KEY,
|
|
181
|
+
APUCHAT_AGENT_AVATAR, APUCHAT_AGENT_SCENE, APUCHAT_AGENT_ALLOW, AGENT_MAX_CALLS, AGENT_CALL_MAX_MINUTES, AGENT_CALL_MAX_TURNS.
|
|
182
|
+
- Demo: DEMO, SERVE_STATIC, DEMO_JWT_SECRET.
|
|
183
|
+
- Platform (multi-tenant, what https://7ots.com runs): PLATFORM=true, PLATFORM_URL, PLATFORM_DB, PLATFORM_SECRET, PLATFORM_MAX_OTS,
|
|
184
|
+
PLATFORM_OTS_RATE_PER_MIN, PLATFORM_OTS_DAILY_LIMIT, PLATFORM_NEW_ACCOUNTS_PER_IP_DAY, PLATFORM_MAX_INFLIGHT,
|
|
185
|
+
PLATFORM_LLM_PROVIDER/API_KEY/BASE_URL/MODEL, PLATFORM_FREE_MESSAGES, PLATFORM_APUCHAT_VOICE_TOKEN, PLATFORM_FREE_TTS,
|
|
186
|
+
PLATFORM_MAIL_INBOX/TOKEN, NOTLOGIN_URL/CLIENT_ID/CLIENT_SECRET, PLATFORM_APUCHAT_IDENTITIES_PER_DAY (free apuchat
|
|
187
|
+
identities per account and day, default 5), ORQUESTA_URL (default https://getorquesta.com).
|
|
188
|
+
Endpoints: POST /api/agent/chat, POST /api/agent/tts, GET /api/agent/identity, /api/agent/contact/*, /llms.txt.
|
|
189
|
+
|
|
190
|
+
# Hosted on https://7ots.com (no server)
|
|
191
|
+
|
|
192
|
+
- Sign in at https://7ots.com/app/, create an ot (`7ots install` also prints a link that opens this same identity there), set the allowed domains, copy the embed.
|
|
193
|
+
- Each ot's public API: `https://7ots.com/api/o/<id>/` — embed.js, config, identity, identity.vcf, chat, tts, contact/apumail, contact/apuchat.
|
|
194
|
+
Point the CLI at it with `setup` → home "7ots" (brain "7ots" then uses its /chat).
|
|
195
|
+
- Brain: the account's free monthly quota on the platform's AI, or the user's own key (BYOK) set in the ot's settings.
|
|
196
|
+
Same for voice. Keys are encrypted and never shown again. Per-ot daily and per-minute limits apply.
|
|
197
|
+
- Settings per ot in /backoffice/?ots=<id>: instructions, LLM/voice keys, apumail/apuchat channels.
|
|
198
|
+
- apuchat tab of an ot: a free @handle (expires after 24h without activity, e.g. while the ot is paused), made permanent
|
|
199
|
+
by paying once by card on apuchat; connect your apuchat account to keep paid identities under it, or paste an identity key you own.
|
|
200
|
+
- Orquesta tab of an ot: connect Orquesta (OAuth or an oak_ key), pick a project, enable tasks. Then the ot can start Orquesta
|
|
201
|
+
tasks (run_task, task_status) only for its owner in the dashboard ("Ask your ot to do something") and for apuchat DMs /
|
|
202
|
+
apumail emails from senders on the ot's allowlist — never from the public chat (/api/o/<id>/chat). Daily cap per ot (default 20).
|
|
11
203
|
|
|
12
204
|
# Recipes for coding agents
|
|
13
205
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7ots/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "7ots.com — open-source embeddable web agent: it sees the page, speaks and acts for your visitor. Web Component + self-hosted Node proxy, Claude/OpenAI, 2D/3D avatar with voice, i18n, backoffice — plus a CLI with reproducible identities, a desktop pet and apuchat meet.",
|
|
5
5
|
"homepage": "https://7ots.com",
|
|
6
6
|
"repository": {
|
|
@@ -55,9 +55,10 @@ export function apuchatConfigured(cfg = apuchatConfigFromEnv()) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, persona: Function, log: Function, config?: object }} deps
|
|
58
|
+
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, persona: Function, log: Function, config?: object, tools?: Function }} deps
|
|
59
|
+
* tools: extra tools for a DM, decided by sender ({ channel:'dm', from } → { tools, run } | null)
|
|
59
60
|
*/
|
|
60
|
-
export function createApuchatChannel({ agent, persona, log, config = apuchatConfigFromEnv() }) {
|
|
61
|
+
export function createApuchatChannel({ agent, persona, log, config = apuchatConfigFromEnv(), tools: extraTools = null }) {
|
|
61
62
|
const cfg = { ...apuchatConfigFromEnv({}), ...config };
|
|
62
63
|
const key = cfg.identityKey;
|
|
63
64
|
const hub = String(cfg.hub).replace(/\/$/, '');
|
|
@@ -169,11 +170,13 @@ export function createApuchatChannel({ agent, persona, log, config = apuchatConf
|
|
|
169
170
|
}
|
|
170
171
|
if (backlog && age > BACKLOG_MAX_AGE_MS) return;
|
|
171
172
|
if (!allowed(from)) return;
|
|
173
|
+
const extra = extraTools?.({ channel: 'dm', from }) || null;
|
|
172
174
|
const reply = await agent.respond(`dm:${from}`, 'dm', text, {
|
|
173
|
-
tools: [ringTool],
|
|
175
|
+
tools: [ringTool, ...(extra?.tools || [])],
|
|
174
176
|
run: async (name, args) => {
|
|
175
|
-
if (name
|
|
176
|
-
return
|
|
177
|
+
if (name === 'llamar_por_apuchat') return ring(from, args.motivo);
|
|
178
|
+
if (extra?.tools.some((x) => x.name === name)) return extra.run(name, args);
|
|
179
|
+
throw new Error(`Herramienta desconocida: ${name}`);
|
|
177
180
|
},
|
|
178
181
|
});
|
|
179
182
|
if (reply) await dm(from, reply);
|
|
@@ -43,9 +43,10 @@ export function apumailConfigured(cfg = apumailConfigFromEnv()) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, log: Function, config?: object }} deps
|
|
46
|
+
* @param {{ agent: ReturnType<import('./agent.mjs').createServerAgent>, log: Function, config?: object, tools?: Function }} deps
|
|
47
|
+
* tools: extra tools for a mail, decided by sender ({ channel:'email', from, verified } → { tools, run } | null)
|
|
47
48
|
*/
|
|
48
|
-
export function createApumailChannel({ agent, log, config = apumailConfigFromEnv() }) {
|
|
49
|
+
export function createApumailChannel({ agent, log, config = apumailConfigFromEnv(), tools = null }) {
|
|
49
50
|
const cfg = { ...apumailConfigFromEnv({}), ...config };
|
|
50
51
|
const inbox = String(cfg.inbox || '').toLowerCase();
|
|
51
52
|
const token = cfg.token;
|
|
@@ -87,7 +88,8 @@ export function createApumailChannel({ agent, log, config = apumailConfigFromEnv
|
|
|
87
88
|
}
|
|
88
89
|
const subject = String(mail.subject || '').slice(0, 200);
|
|
89
90
|
const body = (mail.text || stripHtml(mail.html) || '').split('\n').filter((l) => !l.startsWith('>')).join('\n').trim().slice(0, 6000);
|
|
90
|
-
const
|
|
91
|
+
const extra = tools?.({ channel: 'email', from, verified: senderVerified(mail) }) || null;
|
|
92
|
+
const reply = await agent.respond(`mail:${from}`, 'email', `Asunto: ${subject || '(sin asunto)'}\n\n${body || '(vacío)'}`, extra || {});
|
|
91
93
|
if (!reply) return;
|
|
92
94
|
const res = await fetch(`${base}/send`, {
|
|
93
95
|
method: 'POST',
|
|
@@ -175,6 +177,14 @@ export function emailOf(from) {
|
|
|
175
177
|
return (s.match(/<([^>]+)>/)?.[1] || s).trim().toLowerCase();
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Did apumail verify the sender (DMARC pass for the From domain)? Only a structured verdict from
|
|
182
|
+
* apumail counts; headers inside the mail can be written by whoever sent it.
|
|
183
|
+
*/
|
|
184
|
+
export function senderVerified(mail) {
|
|
185
|
+
return mail?.auth?.dmarc === 'pass';
|
|
186
|
+
}
|
|
187
|
+
|
|
178
188
|
/** Correos que nunca se contestan: propios, automáticos, rebotes, listas. */
|
|
179
189
|
export function skipReason(mail, from, inbox) {
|
|
180
190
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(from)) return 'remitente inválido';
|
|
@@ -18,14 +18,16 @@ import { apuchatConfigFromEnv, apuchatConfigured, createApuchatChannel } from '.
|
|
|
18
18
|
* @param {() => string} [deps.instructions] instrucciones fijas del servidor (defecto: SERVER_INSTRUCTIONS)
|
|
19
19
|
* @param {{ apumail?: object|false, apuchat?: object|false }} [deps.config] por defecto, el entorno
|
|
20
20
|
* @param {Function} [deps.log]
|
|
21
|
+
* @param {(ctx: { channel: 'dm'|'email', from: string, verified?: boolean }) => ({ tools: object[], run: Function }|null)} [deps.tools]
|
|
22
|
+
* extra server tools for one conversation (DMs and mails, never voice calls), decided per sender
|
|
21
23
|
*/
|
|
22
|
-
export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a) }) {
|
|
24
|
+
export function startAgentChannels({ llm, brain, identity = getIdentity, instructions, config = {}, log = (...a) => console.log('[7ots]', ...a), tools = null }) {
|
|
23
25
|
const agent = createServerAgent({ llm, brain, identity, instructions });
|
|
24
26
|
const persona = () => personaOf(identity);
|
|
25
27
|
const mailCfg = config.apumail === false ? null : { ...apumailConfigFromEnv(), ...(config.apumail || {}) };
|
|
26
28
|
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
|
+
const apumail = mailCfg && apumailConfigured(mailCfg) ? createApumailChannel({ agent, log, config: mailCfg, tools }) : null;
|
|
30
|
+
const apuchat = chatCfg && apuchatConfigured(chatCfg) ? createApuchatChannel({ agent, persona, log, config: chatCfg, tools }) : null;
|
|
29
31
|
apumail?.start();
|
|
30
32
|
apuchat?.start();
|
|
31
33
|
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* apuchat for the platform: an apuchat identity (@callsign) for each ot, made from the dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Every 7ots account can hold two apuchat accounts (sealed in `integrations`, kind 'apuchat'):
|
|
5
|
+
* managed an anonymous apuchat account the platform creates on first use (no sign-up): it owns the
|
|
6
|
+
* identities made for the user's ots. Its session lasts 90 days; when it is refused, the
|
|
7
|
+
* recovery token gets a new one (and if even that fails, a new anonymous account is made).
|
|
8
|
+
* linked the user's own apuchat account, connected with apuchat's /link flow (app "7ots"): new
|
|
9
|
+
* identities go there and the ot's identity can be claimed into it.
|
|
10
|
+
*
|
|
11
|
+
* Hub contract (APUCHAT_HUB, read once at boot; server to server only):
|
|
12
|
+
* POST /api/account → { account_id, recovery_token, session_token }
|
|
13
|
+
* POST /api/account/recover {recovery_token} → { session_token }
|
|
14
|
+
* POST /api/account/identities Bearer session|apt_ → { callsign, identity_key, free_identity, … }
|
|
15
|
+
* DELETE /api/account/identities/:callsign
|
|
16
|
+
* GET /api/identities/mint/quote?callsign= Bearer → { valid, taken, reason, tier, tier_label, price_usd, … }
|
|
17
|
+
* POST /api/identities/mint/checkout { callsign, return_url } → { checkout_id, url, price_usd, tier }
|
|
18
|
+
* GET /api/identities/mint/checkout/:id → { status, callsign, upgraded, identity_key? }
|
|
19
|
+
* POST /api/v1/link/exchange { code, app } → { account_id, identities, app_token (apt_, shown once), scopes, notice }
|
|
20
|
+
* POST /api/account/identities/claim { identity_key } Bearer apt_ → { ok, callsign, moved, paid }
|
|
21
|
+
* (409 *_not_transferable when it is paid/verified elsewhere)
|
|
22
|
+
* apt_ errors: 401 invalid_app_token (the link is forgotten) · 403 insufficient_scope. Paying needs
|
|
23
|
+
* Stripe on apuchat: 503 not_configured until then (we point to minting with USDC on apuchat instead).
|
|
24
|
+
* return_url must be on 7ots.com (or localhost:8787 in development); apuchat appends ?apuchat_checkout=<id>&status=….
|
|
25
|
+
* GET /api/dm/wait?timeout=1 X-Identity-Key → { callsign, … } (checks a pasted key)
|
|
26
|
+
*
|
|
27
|
+
* Free identities expire after 24 h without DM activity; the ot's channel long-polls /api/dm/wait,
|
|
28
|
+
* which counts as activity, so they live while the ot is on.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { i18nError } from '../i18n.mjs';
|
|
32
|
+
import { getIntegration, setIntegration } from './store.mjs';
|
|
33
|
+
|
|
34
|
+
const penv = process.env;
|
|
35
|
+
const HUB = (penv.APUCHAT_HUB || 'https://apuchat.com').replace(/\/+$/, '');
|
|
36
|
+
const SESSION_REFRESH_MS = 80 * 24 * 3600_000; // sessions last 90 days: renew a bit before
|
|
37
|
+
export const CALLSIGN_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
|
|
38
|
+
const IDENTITIES_PER_DAY = () => Number(penv.PLATFORM_APUCHAT_IDENTITIES_PER_DAY || 5);
|
|
39
|
+
|
|
40
|
+
export const apuchatHub = () => HUB;
|
|
41
|
+
|
|
42
|
+
async function hub(method, path, { bearer, identityKey, body, timeoutMs = 15000 } = {}) {
|
|
43
|
+
let res;
|
|
44
|
+
try {
|
|
45
|
+
res = await fetch(HUB + path, {
|
|
46
|
+
method,
|
|
47
|
+
headers: {
|
|
48
|
+
Accept: 'application/json',
|
|
49
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
50
|
+
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
|
51
|
+
...(identityKey ? { 'X-Identity-Key': identityKey } : {}),
|
|
52
|
+
},
|
|
53
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
+
redirect: 'manual',
|
|
55
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
throw i18nError(424, 'platform.apuchat.unreachable');
|
|
59
|
+
}
|
|
60
|
+
let data = null;
|
|
61
|
+
try {
|
|
62
|
+
data = await res.json();
|
|
63
|
+
} catch {}
|
|
64
|
+
return { ok: res.ok, status: res.status, data: data || {} };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Upstream error → our error (the hub's own text is never shown; its code helps a few cases). */
|
|
68
|
+
function hubError(r) {
|
|
69
|
+
const code = String(r.data.code || r.data.error || '');
|
|
70
|
+
if (r.status === 503 && /not_configured/.test(code)) return i18nError(501, 'platform.apuchat.paymentsOff', { url: `${HUB}/account/mint` });
|
|
71
|
+
if (r.status === 429) return i18nError(429, 'platform.apuchat.hubBusy');
|
|
72
|
+
if (r.status === 403 && /insufficient_scope/.test(code)) return i18nError(403, 'platform.apuchat.insufficientScope');
|
|
73
|
+
if (r.status === 409 && /not_transferable/.test(code)) return i18nError(409, 'platform.apuchat.notTransferable');
|
|
74
|
+
if (r.status === 409) return i18nError(409, 'platform.apuchat.taken');
|
|
75
|
+
return i18nError(424, 'platform.apuchat.failed', { status: r.status });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ───────────────────────────── account state ─────────────────────────────
|
|
79
|
+
|
|
80
|
+
const state = (accountId) => getIntegration(accountId, 'apuchat') || {};
|
|
81
|
+
const saveState = (accountId, s) => setIntegration(accountId, 'apuchat', s.managed || s.linked || s.checkouts?.length ? s : null);
|
|
82
|
+
const locks = new Map(); // accountId → Promise (one session refresh / account creation at a time)
|
|
83
|
+
|
|
84
|
+
function locked(accountId, fn) {
|
|
85
|
+
const prev = locks.get(accountId) || Promise.resolve();
|
|
86
|
+
const run = prev.catch(() => {}).then(fn);
|
|
87
|
+
const tail = run.catch(() => {});
|
|
88
|
+
locks.set(accountId, tail);
|
|
89
|
+
tail.then(() => locks.get(accountId) === tail && locks.delete(accountId));
|
|
90
|
+
return run;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Public view for the dashboard (never tokens). */
|
|
94
|
+
export function apuchatAccountView(accountId) {
|
|
95
|
+
const s = state(accountId);
|
|
96
|
+
return { managed: !!s.managed, linked: s.linked ? { accountId: s.linked.account_id || '', canClaim: can(s.linked, 'identities:write') } : null };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Whether the app token of the link was granted a scope (older links without a scope list: assume yes). */
|
|
100
|
+
const can = (linked, scope) => !!linked?.app_token && (!Array.isArray(linked.scopes) || linked.scopes.includes(scope));
|
|
101
|
+
|
|
102
|
+
async function createManaged(accountId) {
|
|
103
|
+
const r = await hub('POST', '/api/account', { body: {} });
|
|
104
|
+
if (!r.ok || !r.data.session_token || !r.data.recovery_token) throw hubError(r);
|
|
105
|
+
const s = state(accountId);
|
|
106
|
+
s.managed = { account_id: r.data.account_id, recovery_token: r.data.recovery_token, session_token: r.data.session_token, at: Date.now() };
|
|
107
|
+
saveState(accountId, s);
|
|
108
|
+
return s.managed.session_token;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Gets a fresh session with the recovery token; if apuchat no longer knows it, starts a new anonymous account. */
|
|
112
|
+
async function recoverManaged(accountId) {
|
|
113
|
+
const s = state(accountId);
|
|
114
|
+
if (!s.managed?.recovery_token) return createManaged(accountId);
|
|
115
|
+
const r = await hub('POST', '/api/account/recover', { body: { recovery_token: s.managed.recovery_token } });
|
|
116
|
+
if (r.ok && r.data.session_token) {
|
|
117
|
+
s.managed = { ...s.managed, session_token: r.data.session_token, at: Date.now() };
|
|
118
|
+
saveState(accountId, s);
|
|
119
|
+
return s.managed.session_token;
|
|
120
|
+
}
|
|
121
|
+
if (r.status === 401 || r.status === 403 || r.status === 404) {
|
|
122
|
+
console.warn('[7ots] apuchat: recovery refused, new anonymous account for', accountId);
|
|
123
|
+
return createManaged(accountId);
|
|
124
|
+
}
|
|
125
|
+
throw hubError(r);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Session of the managed account (made on first use, renewed when old). */
|
|
129
|
+
function managedSession(accountId, { create = true } = {}) {
|
|
130
|
+
return locked(accountId, async () => {
|
|
131
|
+
const m = state(accountId).managed;
|
|
132
|
+
if (!m) {
|
|
133
|
+
if (!create) return null;
|
|
134
|
+
return createManaged(accountId);
|
|
135
|
+
}
|
|
136
|
+
if (Date.now() - (m.at || 0) > SESSION_REFRESH_MS) return recoverManaged(accountId);
|
|
137
|
+
return m.session_token;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A call as one of the account's apuchat accounts. 'managed' renews its session once on 401;
|
|
143
|
+
* 'linked' uses the app token (a 401 means the user revoked it: the link is forgotten).
|
|
144
|
+
*/
|
|
145
|
+
async function as(accountId, owner, method, path, o = {}) {
|
|
146
|
+
if (owner === 'linked') {
|
|
147
|
+
const l = state(accountId).linked;
|
|
148
|
+
if (!l?.app_token) throw i18nError(409, 'platform.apuchat.notLinked');
|
|
149
|
+
const r = await hub(method, path, { ...o, bearer: l.app_token });
|
|
150
|
+
if (r.status === 401) {
|
|
151
|
+
const s = state(accountId);
|
|
152
|
+
delete s.linked;
|
|
153
|
+
saveState(accountId, s);
|
|
154
|
+
throw i18nError(409, 'platform.apuchat.linkExpired');
|
|
155
|
+
}
|
|
156
|
+
return r;
|
|
157
|
+
}
|
|
158
|
+
let token = await managedSession(accountId);
|
|
159
|
+
let r = await hub(method, path, { ...o, bearer: token });
|
|
160
|
+
if (r.status === 401) {
|
|
161
|
+
token = await locked(accountId, () => recoverManaged(accountId));
|
|
162
|
+
r = await hub(method, path, { ...o, bearer: token });
|
|
163
|
+
}
|
|
164
|
+
return r;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Which apuchat account new identities go to: the user's own when linked with a token, else the managed one. */
|
|
168
|
+
export const defaultOwner = (accountId, scope = 'identities:write') => (can(state(accountId).linked, scope) ? 'linked' : 'managed');
|
|
169
|
+
|
|
170
|
+
// ───────────────────────────── identities ─────────────────────────────
|
|
171
|
+
|
|
172
|
+
const madeToday = new Map(); // accountId → timestamps (free identities made in the last day)
|
|
173
|
+
|
|
174
|
+
/** A new free identity. Limited per account and day (each one is a handle taken on apuchat). */
|
|
175
|
+
export async function createFreeIdentity(accountId) {
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
const recent = (madeToday.get(accountId) || []).filter((t) => now - t < 86400_000);
|
|
178
|
+
if (recent.length >= IDENTITIES_PER_DAY()) throw i18nError(429, 'platform.apuchat.tooManyIdentities', { max: IDENTITIES_PER_DAY() });
|
|
179
|
+
const owner = defaultOwner(accountId);
|
|
180
|
+
const r = await as(accountId, owner, 'POST', '/api/account/identities', { body: {} });
|
|
181
|
+
if (!r.ok || !r.data.identity_key || !r.data.callsign) throw hubError(r);
|
|
182
|
+
recent.push(now);
|
|
183
|
+
madeToday.set(accountId, recent);
|
|
184
|
+
if (madeToday.size > 5000) madeToday.clear();
|
|
185
|
+
return { callsign: String(r.data.callsign).toLowerCase(), identityKey: r.data.identity_key, free: r.data.free_identity !== false, owner };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Deletes an identity on apuchat (only the free ones the platform made; best effort). */
|
|
189
|
+
export async function deleteIdentity(accountId, owner, callsign) {
|
|
190
|
+
if (!CALLSIGN_RE.test(callsign || '') || (owner !== 'managed' && owner !== 'linked')) return false;
|
|
191
|
+
const r = await as(accountId, owner, 'DELETE', `/api/account/identities/${encodeURIComponent(callsign)}`).catch(() => null);
|
|
192
|
+
return !!r?.ok;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The @callsign of an identity key, or null if apuchat does not accept it. */
|
|
196
|
+
export async function callsignOf(identityKey) {
|
|
197
|
+
const r = await hub('GET', '/api/dm/wait?timeout=1', { identityKey, timeoutMs: 20000 });
|
|
198
|
+
if (r.status === 401 || r.status === 403 || r.status === 404) return null;
|
|
199
|
+
if (!r.ok) throw hubError(r);
|
|
200
|
+
return r.data.callsign ? String(r.data.callsign).toLowerCase() : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ───────────────────────────── permanent handle (paid) ─────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function quote(accountId, owner, callsign) {
|
|
206
|
+
const r = await as(accountId, owner, 'GET', `/api/identities/mint/quote?callsign=${encodeURIComponent(callsign)}`);
|
|
207
|
+
if (!r.ok) throw hubError(r);
|
|
208
|
+
const q = r.data;
|
|
209
|
+
return { callsign, valid: !!q.valid, taken: !!q.taken, reason: q.reason || null, tier: q.tier || null, tierLabel: q.tier_label || null, priceUsd: q.price_usd ?? null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Starts a card checkout; the checkout is remembered so only this ot can read its result. */
|
|
213
|
+
export async function checkout(accountId, owner, { otsId, callsign, returnUrl }) {
|
|
214
|
+
const r = await as(accountId, owner, 'POST', '/api/identities/mint/checkout', { body: { callsign, return_url: returnUrl } });
|
|
215
|
+
if (!r.ok || !r.data.checkout_id || !r.data.url) throw hubError(r);
|
|
216
|
+
const s = state(accountId);
|
|
217
|
+
s.checkouts = [{ id: String(r.data.checkout_id), otsId, callsign, owner, at: Date.now() }, ...(s.checkouts || []).filter((c) => Date.now() - c.at < 7 * 86400_000)].slice(0, 20);
|
|
218
|
+
saveState(accountId, s);
|
|
219
|
+
return { checkoutId: String(r.data.checkout_id), url: String(r.data.url), priceUsd: r.data.price_usd ?? null, tier: r.data.tier || null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const pendingCheckouts = (accountId, otsId) => (state(accountId).checkouts || []).filter((c) => c.otsId === otsId).map(({ id, callsign, at }) => ({ id, callsign, at }));
|
|
223
|
+
|
|
224
|
+
/** Status of a checkout started for this ot (null if it isn't one of its own). Fulfilled/failed ones are forgotten. */
|
|
225
|
+
export async function checkoutStatus(accountId, otsId, id) {
|
|
226
|
+
const c = (state(accountId).checkouts || []).find((x) => x.id === String(id) && x.otsId === otsId);
|
|
227
|
+
if (!c) return null;
|
|
228
|
+
const r = await as(accountId, c.owner, 'GET', `/api/identities/mint/checkout/${encodeURIComponent(c.id)}`);
|
|
229
|
+
if (!r.ok) throw hubError(r);
|
|
230
|
+
const status = ['pending', 'fulfilled', 'failed'].includes(r.data.status) ? r.data.status : 'pending';
|
|
231
|
+
if (status !== 'pending') {
|
|
232
|
+
const s = state(accountId);
|
|
233
|
+
s.checkouts = (s.checkouts || []).filter((x) => x.id !== c.id);
|
|
234
|
+
saveState(accountId, s);
|
|
235
|
+
}
|
|
236
|
+
return { status, callsign: String(r.data.callsign || c.callsign).toLowerCase(), upgraded: !!r.data.upgraded, identityKey: r.data.identity_key || null, owner: c.owner };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ───────────────────────────── linking the user's apuchat account ─────────────────────────────
|
|
240
|
+
|
|
241
|
+
/** Where to send the browser to link (apuchat appends `code`; it doesn't echo `state`, so it rides in the return URL). */
|
|
242
|
+
export const linkUrl = (returnUrl) => `${HUB}/link?${new URLSearchParams({ app: '7ots', return: returnUrl })}`;
|
|
243
|
+
|
|
244
|
+
export async function exchangeLink(accountId, code) {
|
|
245
|
+
const r = await hub('POST', '/api/v1/link/exchange', { body: { code, app: '7ots' } });
|
|
246
|
+
if (!r.ok || !r.data.account_id) throw hubError(r);
|
|
247
|
+
const s = state(accountId);
|
|
248
|
+
const scopes = Array.isArray(r.data.scopes) ? r.data.scopes.map(String) : typeof r.data.scopes === 'string' ? r.data.scopes.split(/[\s,]+/).filter(Boolean) : null;
|
|
249
|
+
// The app token is shown once: it lives only sealed in the database and is never logged.
|
|
250
|
+
s.linked = { account_id: String(r.data.account_id), app_token: r.data.app_token || '', scopes, at: Date.now() };
|
|
251
|
+
saveState(accountId, s);
|
|
252
|
+
return { accountId: s.linked.account_id, canClaim: can(s.linked, 'identities:write') };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function unlink(accountId) {
|
|
256
|
+
const s = state(accountId);
|
|
257
|
+
delete s.linked;
|
|
258
|
+
saveState(accountId, s);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Moves an identity (by its key) into the linked account. */
|
|
262
|
+
export async function claimIdentity(accountId, identityKey) {
|
|
263
|
+
const r = await as(accountId, 'linked', 'POST', '/api/account/identities/claim', { body: { identity_key: identityKey } });
|
|
264
|
+
if (!r.ok) throw hubError(r);
|
|
265
|
+
return { callsign: r.data.callsign ? String(r.data.callsign).toLowerCase() : null, moved: r.data.moved !== false, paid: !!r.data.paid };
|
|
266
|
+
}
|