openzoo 0.50.0 → 0.50.2
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/bin/openzoo.js +26 -0
- package/lib/dotenv.js +37 -0
- package/lib/pay.js +16 -2
- package/lib/proxy.js +1 -8
- package/lib/receipts.js +139 -0
- package/lib/sonar.js +1879 -0
- package/lib/voice.js +714 -0
- package/lib/voiceserve.js +84 -0
- package/lib/voicewatch.js +127 -0
- package/lib/x402.js +21 -4
- package/lib/xbot.js +553 -14
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local voice endpoint — what the browser extension talks to.
|
|
3
|
+
*
|
|
4
|
+
* WHY A LOCAL SERVER AND NOT A DIRECT CALL FROM THE EXTENSION: the rewrite
|
|
5
|
+
* needs the wallet (x402 settles from ~/.openzoo/wallet.json), the local
|
|
6
|
+
* leCore daemon (scored recall over your bound turns), and the style card
|
|
7
|
+
* on disk. None of those belong in a browser extension — shipping a
|
|
8
|
+
* keypair into an extension's storage is how you lose it. The extension
|
|
9
|
+
* stays dumb: it POSTs text to 127.0.0.1 and gets text back.
|
|
10
|
+
*
|
|
11
|
+
* Bound to loopback only, and CORS is restricted to the X origins. The
|
|
12
|
+
* threat model is "a random page in your browser can't spend your wallet",
|
|
13
|
+
* which loopback + origin allowlist covers.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from 'node:http';
|
|
17
|
+
import { voiceText } from './voice.js';
|
|
18
|
+
|
|
19
|
+
const ALLOWED_ORIGINS = new Set([
|
|
20
|
+
'https://x.com',
|
|
21
|
+
'https://twitter.com',
|
|
22
|
+
'https://mobile.x.com',
|
|
23
|
+
'https://mobile.twitter.com',
|
|
24
|
+
...(process.env.OPENZOO_VOICE_ORIGINS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const VOICE_PORT = Number(process.env.OPENZOO_VOICE_PORT || 8403);
|
|
28
|
+
|
|
29
|
+
function cors(req, res) {
|
|
30
|
+
const origin = req.headers.origin || '';
|
|
31
|
+
if (ALLOWED_ORIGINS.has(origin)) {
|
|
32
|
+
res.setHeader('access-control-allow-origin', origin);
|
|
33
|
+
res.setHeader('access-control-allow-headers', 'content-type');
|
|
34
|
+
res.setHeader('access-control-allow-methods', 'POST, OPTIONS');
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runVoiceServe(args = []) {
|
|
41
|
+
const port = Number(args[0]) || VOICE_PORT;
|
|
42
|
+
const log = (m) => console.error(` ${m}`);
|
|
43
|
+
|
|
44
|
+
const server = http.createServer(async (req, res) => {
|
|
45
|
+
const ok = cors(req, res);
|
|
46
|
+
if (req.method === 'OPTIONS') { res.writeHead(ok ? 204 : 403).end(); return; }
|
|
47
|
+
if (req.url === '/health') {
|
|
48
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
49
|
+
res.end(JSON.stringify({ ok: true, service: 'openzoo voice' }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (req.method !== 'POST' || req.url !== '/voice') { res.writeHead(404).end(); return; }
|
|
53
|
+
// An unlisted origin is refused BEFORE the wallet is touched: a rewrite
|
|
54
|
+
// is a paid call, so an open endpoint is an open tab away from spending
|
|
55
|
+
// your money on someone else's page.
|
|
56
|
+
if (!ok && req.headers.origin) { res.writeHead(403).end('origin not allowed'); return; }
|
|
57
|
+
|
|
58
|
+
let body = '';
|
|
59
|
+
req.on('data', (c) => { body += c; if (body.length > 100_000) req.destroy(); });
|
|
60
|
+
req.on('end', async () => {
|
|
61
|
+
try {
|
|
62
|
+
const { text, kind } = JSON.parse(body || '{}');
|
|
63
|
+
if (!text || typeof text !== 'string') { res.writeHead(400).end('no text'); return; }
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
const r = await voiceText(text, { kind });
|
|
66
|
+
log(`${kind || 'post'}: ${text.length} → ${r.text.length} chars · ${((Date.now() - started) / 1000).toFixed(1)}s · ${r.receipt}`);
|
|
67
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
68
|
+
res.end(JSON.stringify({ text: r.text, receipt: r.receipt, stage: r.stage }));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// NEVER block a post on our failure: the extension falls back to
|
|
71
|
+
// sending the draft as typed when this errors.
|
|
72
|
+
log(`voice failed: ${e.message?.slice(0, 160)}`);
|
|
73
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve));
|
|
80
|
+
console.error(`openzoo voice serve: http://127.0.0.1:${port}/voice (loopback only)`);
|
|
81
|
+
console.error(' load the extension from the openzoo checkout: extension/ (chrome://extensions → Load unpacked)');
|
|
82
|
+
console.error(' then post on x.com as usual — drafts are revised before they publish');
|
|
83
|
+
await new Promise(() => {});
|
|
84
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Telegram PREHOOK — as close to one as the platform allows.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's Bot API cannot see your own outgoing messages, but an MTProto
|
|
5
|
+
* USERBOT logged in as you can: it receives everything you send, in every
|
|
6
|
+
* chat, the moment you send it — and can edit your message in place. So
|
|
7
|
+
* the intercept is: you type raw, this watcher rewrites it into your voice
|
|
8
|
+
* (openzoo voice, paid x402 per message) and edits it ~a second later.
|
|
9
|
+
*
|
|
10
|
+
* Honest tradeoffs, stated up front:
|
|
11
|
+
* - readers can glimpse the raw version before the edit lands
|
|
12
|
+
* - edited messages show Telegram's "edited" tag
|
|
13
|
+
* - a userbot is ToS-gray; self-editing sits at the tolerated end, but
|
|
14
|
+
* this is YOUR account — keep the watcher's behavior boring
|
|
15
|
+
*
|
|
16
|
+
* Escapes: a message starting with "." is never touched (the raw marker);
|
|
17
|
+
* neither are /commands, forwards, media captions, or anything under
|
|
18
|
+
* OPENZOO_VOICE_MIN_CHARS. OPENZOO_VOICE_WATCH_CHATS (comma-separated
|
|
19
|
+
* chat ids) restricts the watcher to an allowlist.
|
|
20
|
+
*
|
|
21
|
+
* There is NO X equivalent: the X API has no edit endpoint and no
|
|
22
|
+
* pre-publish hook — the nearest X flows are composing THROUGH the bot,
|
|
23
|
+
* or delete-and-repost (which destroys replies; not built by default).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import readline from 'node:readline';
|
|
30
|
+
|
|
31
|
+
const SESSION_FILE = process.env.OPENZOO_VOICE_TG_SESSION
|
|
32
|
+
|| path.join(os.homedir(), '.openzoo', 'voice-telegram.session');
|
|
33
|
+
|
|
34
|
+
const MIN_CHARS = Number(process.env.OPENZOO_VOICE_MIN_CHARS || 12);
|
|
35
|
+
|
|
36
|
+
function ask(q, { hidden = false } = {}) {
|
|
37
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
rl.question(q, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
40
|
+
if (hidden) rl._writeToOutput = () => {};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function makeClient() {
|
|
45
|
+
let TelegramClient, StringSession;
|
|
46
|
+
try {
|
|
47
|
+
({ TelegramClient } = await import('telegram'));
|
|
48
|
+
({ StringSession } = await import('telegram/sessions/index.js'));
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error("the Telegram userbot needs the 'telegram' package — npm i telegram (in the openzoo checkout) and retry");
|
|
51
|
+
}
|
|
52
|
+
const apiId = Number(process.env.TELEGRAM_APP_ID || 0);
|
|
53
|
+
const apiHash = process.env.TELEGRAM_APP_HASH || '';
|
|
54
|
+
if (!apiId || !apiHash) {
|
|
55
|
+
throw new Error('set TELEGRAM_APP_ID and TELEGRAM_APP_HASH (create an app at https://my.telegram.org/apps) — these are YOUR user-account API credentials, not a bot token');
|
|
56
|
+
}
|
|
57
|
+
let saved = '';
|
|
58
|
+
try { saved = fs.readFileSync(SESSION_FILE, 'utf8').trim(); } catch { /* first run */ }
|
|
59
|
+
const client = new TelegramClient(new StringSession(saved), apiId, apiHash, { connectionRetries: 5 });
|
|
60
|
+
return { client, saved };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function login(client) {
|
|
64
|
+
await client.start({
|
|
65
|
+
phoneNumber: () => ask('phone number (intl format): '),
|
|
66
|
+
phoneCode: () => ask('code from Telegram: '),
|
|
67
|
+
password: () => ask('2FA password (blank if none): ', { hidden: true }),
|
|
68
|
+
onError: (e) => console.error(` login error: ${e.message}`),
|
|
69
|
+
});
|
|
70
|
+
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true, mode: 0o700 });
|
|
71
|
+
fs.writeFileSync(SESSION_FILE, client.session.save() + '\n', { mode: 0o600 });
|
|
72
|
+
console.error(` session saved to ${SESSION_FILE} (0600) — 'openzoo voice watch' now runs headless`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function runVoiceWatch(cmd, _args = []) {
|
|
76
|
+
const { client, saved } = await makeClient();
|
|
77
|
+
|
|
78
|
+
if (cmd === 'login') {
|
|
79
|
+
await login(client);
|
|
80
|
+
await client.disconnect();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!saved) {
|
|
85
|
+
throw new Error("no userbot session yet — run 'openzoo voice login' once (interactive) first");
|
|
86
|
+
}
|
|
87
|
+
await client.connect();
|
|
88
|
+
const me = await client.getMe();
|
|
89
|
+
console.error(`openzoo voice watch: logged in as ${me.username ? '@' + me.username : me.firstName} — intercepting outgoing messages`);
|
|
90
|
+
console.error(` escape hatch: start a message with "." to send it raw · min ${MIN_CHARS} chars · Ctrl-C to stop`);
|
|
91
|
+
|
|
92
|
+
const allow = (process.env.OPENZOO_VOICE_WATCH_CHATS || '')
|
|
93
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
94
|
+
|
|
95
|
+
const { NewMessage } = await import('telegram/events/index.js');
|
|
96
|
+
const { voiceText } = await import('./voice.js');
|
|
97
|
+
|
|
98
|
+
client.addEventHandler(async (event) => {
|
|
99
|
+
const m = event.message;
|
|
100
|
+
try {
|
|
101
|
+
const text = String(m?.message || '');
|
|
102
|
+
if (!m?.out || !text) return; // only MY outgoing text
|
|
103
|
+
if (m.fwdFrom || m.viaBotId || m.media) return; // forwards/inline/media stay raw
|
|
104
|
+
if (text.startsWith('.') || text.startsWith('/')) return;
|
|
105
|
+
if (text.length < MIN_CHARS) return;
|
|
106
|
+
const chatId = String(m.chatId ?? '');
|
|
107
|
+
if (allow.length && !allow.includes(chatId)) return;
|
|
108
|
+
|
|
109
|
+
const { text: revised, receipt } = await voiceText(text);
|
|
110
|
+
const same = revised.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
111
|
+
=== text.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
112
|
+
if (!revised || same) {
|
|
113
|
+
console.error(` [${chatId}] already in voice — untouched · ${receipt}`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await m.edit({ text: revised });
|
|
117
|
+
console.error(` [${chatId}] revised (${text.length} → ${revised.length} chars) · ${receipt}`);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
// Never let a rewrite failure eat a message: the raw text is already
|
|
120
|
+
// sent and stays; the watcher just reports and moves on.
|
|
121
|
+
console.error(` watch: ${e.message?.slice(0, 120)}`);
|
|
122
|
+
}
|
|
123
|
+
}, new NewMessage({ outgoing: true }));
|
|
124
|
+
|
|
125
|
+
// Run until killed.
|
|
126
|
+
await new Promise(() => {});
|
|
127
|
+
}
|
package/lib/x402.js
CHANGED
|
@@ -161,9 +161,26 @@ export function evmChainId(network) {
|
|
|
161
161
|
*/
|
|
162
162
|
export function orderAccepts(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
|
|
163
163
|
const rows = parse402(body).accepts.filter((a) => a?.scheme === 'exact');
|
|
164
|
+
// CHEAPEST FIRST, AFTER AN EXPLICIT PREFERENCE.
|
|
165
|
+
//
|
|
166
|
+
// The 402 no longer prices every rail the same. Since 2026-08-26 the gateway
|
|
167
|
+
// bills $TOKEN/$LEOS at 2x its cost and USDC/USDG at 4x, so two rows for the
|
|
168
|
+
// SAME call differ by exactly 2x. This function ordered purely by rail and
|
|
169
|
+
// then by preferredSymbol, so the shim would have paid the stable row and
|
|
170
|
+
// silently thrown away half — a discount our own client could not see.
|
|
171
|
+
//
|
|
172
|
+
// An explicit preferredSymbol still wins: that is the caller saying which
|
|
173
|
+
// asset they intend to spend, and price is not a reason to override intent.
|
|
174
|
+
// Everything after it goes cheapest-first. Rows without a usable billedUsd
|
|
175
|
+
// sort last rather than first, so a missing price can never masquerade as
|
|
176
|
+
// free and jump the queue.
|
|
177
|
+
const priceOf = (a) => {
|
|
178
|
+
const v = Number(a?.extra?.billedUsd);
|
|
179
|
+
return Number.isFinite(v) && v > 0 ? v : Infinity;
|
|
180
|
+
};
|
|
164
181
|
const bySym = (list) => [
|
|
165
182
|
...list.filter((a) => a?.extra?.symbol === preferredSymbol),
|
|
166
|
-
...list.filter((a) => a?.extra?.symbol !== preferredSymbol),
|
|
183
|
+
...list.filter((a) => a?.extra?.symbol !== preferredSymbol).sort((x, y) => priceOf(x) - priceOf(y)),
|
|
167
184
|
];
|
|
168
185
|
if (forceRail) {
|
|
169
186
|
const want = String(forceRail).toLowerCase();
|
|
@@ -181,9 +198,9 @@ export function orderAccepts(body, preferredSymbol, { allowRH = false, forceRail
|
|
|
181
198
|
}
|
|
182
199
|
const out = [
|
|
183
200
|
...bySym(rows.filter((a) => railOf(a) === 'solana')),
|
|
184
|
-
...rows.filter((a) => railOf(a) === 'base'),
|
|
185
|
-
...rows.filter((a) => railOf(a) === 'evm'),
|
|
186
|
-
...(allowRH ? rows.filter((a) => railOf(a) === 'robinhood') : []),
|
|
201
|
+
...bySym(rows.filter((a) => railOf(a) === 'base')),
|
|
202
|
+
...bySym(rows.filter((a) => railOf(a) === 'evm')),
|
|
203
|
+
...(allowRH ? bySym(rows.filter((a) => railOf(a) === 'robinhood')) : []),
|
|
187
204
|
];
|
|
188
205
|
if (!out.length) {
|
|
189
206
|
throw new Error(rows.some((a) => railOf(a) === 'robinhood')
|