@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.15
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 +76 -189
- package/dist/broker/claims.js +144 -0
- package/dist/{broker.js → broker/history-stream.js} +46 -99
- package/dist/cli/config.js +115 -121
- package/dist/cli/index.js +51 -64
- package/dist/cli/messenger-api.js +214 -0
- package/dist/cli/messenger-transcribe.js +43 -0
- package/dist/cli/messenger-uploads.js +116 -0
- package/dist/cli/monitor-api.js +205 -0
- package/dist/cli/tail.js +49 -118
- package/dist/cli/webhook.js +103 -3
- package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
- package/dist/codex-rc/protocol.js +38 -0
- package/dist/dispatcher/server.js +122 -0
- package/dist/dispatcher.js +52 -83
- package/dist/history.js +49 -27
- package/dist/ipc.js +28 -10
- package/dist/lines.js +54 -0
- package/dist/local-identity.js +80 -0
- package/dist/paths.js +58 -12
- package/dist/trains/protocol.js +99 -0
- package/dist/trains/supervisor.js +210 -0
- package/dist/tunnel.js +39 -1
- package/docs/broker.md +88 -136
- package/docs/monitor.md +88 -10
- package/docs/uri-scheme.md +10 -7
- package/examples/README.md +32 -0
- package/examples/telegram.ts +121 -0
- package/package.json +6 -5
- package/skills/metro/SKILL.md +67 -213
- package/dist/cache.js +0 -69
- package/dist/cli/actions.js +0 -206
- package/dist/cli/skill.js +0 -62
- package/dist/monitor.js +0 -194
- package/dist/registry.js +0 -48
- package/dist/stations/claude.js +0 -45
- package/dist/stations/codex.js +0 -68
- package/dist/stations/discord.js +0 -216
- package/dist/stations/index.js +0 -129
- package/dist/stations/telegram-md.js +0 -34
- package/dist/stations/telegram-upload.js +0 -113
- package/dist/stations/telegram.js +0 -234
- package/dist/stations/webhook.js +0 -103
- package/dist/webhooks.js +0 -41
- package/docs/users.md +0 -226
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Messenger endpoints: send + react + register, plus Expo push delivery. Upload/serve in messenger-uploads.ts. */
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { mintId, readHistory, userSelf } from '../history.js';
|
|
5
|
+
import { errMsg, log } from '../log.js';
|
|
6
|
+
import { STATE_DIR } from '../paths.js';
|
|
7
|
+
import { handleMessengerFile, handleMessengerUpload } from './messenger-uploads.js';
|
|
8
|
+
import { transcribeAudio } from './messenger-transcribe.js';
|
|
9
|
+
const UPLOADS_DIR = join(STATE_DIR, 'messenger-uploads');
|
|
10
|
+
const MESSENGER_LINE = 'metro://messenger/owner';
|
|
11
|
+
const MESSENGER_USER = 'metro://messenger/user/owner';
|
|
12
|
+
const PUSH_TOKENS_FILE = join(STATE_DIR, 'push-tokens.json');
|
|
13
|
+
function readPushTokens() {
|
|
14
|
+
try {
|
|
15
|
+
return existsSync(PUSH_TOKENS_FILE) ? JSON.parse(readFileSync(PUSH_TOKENS_FILE, 'utf8')) : [];
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writePushTokens(tokens) {
|
|
22
|
+
writeFileSync(PUSH_TOKENS_FILE, JSON.stringify([...new Set(tokens)]));
|
|
23
|
+
}
|
|
24
|
+
async function pushExpo(tokens, title, body, replyTo) {
|
|
25
|
+
if (tokens.length === 0)
|
|
26
|
+
return;
|
|
27
|
+
/** `categoryId: 'messenger.reply'` opts the notification into the inline Reply
|
|
28
|
+
* action the app registered at startup; `data.replyTo` lets the response
|
|
29
|
+
* listener thread the answer back to the originating message. */
|
|
30
|
+
const messages = tokens.map(to => ({
|
|
31
|
+
to, title, body, sound: 'default',
|
|
32
|
+
categoryId: 'messenger.reply',
|
|
33
|
+
...(replyTo ? { data: { replyTo } } : {}),
|
|
34
|
+
}));
|
|
35
|
+
try {
|
|
36
|
+
await fetch('https://exp.host/--/api/v2/push/send', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip, deflate' },
|
|
39
|
+
body: JSON.stringify(messages),
|
|
40
|
+
signal: AbortSignal.timeout(10_000),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
log.warn({ err: errMsg(err) }, 'expo push failed');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function readJsonBody(req) {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
for await (const c of req)
|
|
50
|
+
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c));
|
|
51
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
52
|
+
if (!raw)
|
|
53
|
+
return {};
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
return { __error: `bad JSON body: ${errMsg(err)}` };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Route all /api/messenger/* paths. Returns true if handled. */
|
|
62
|
+
export function routeMessenger(req, res, path, emit, send) {
|
|
63
|
+
const guard = (p, label, methodOk = true) => {
|
|
64
|
+
if (!methodOk) {
|
|
65
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
Promise.resolve(p()).catch(err => {
|
|
69
|
+
log.warn({ err: errMsg(err) }, `monitor: ${label} error`);
|
|
70
|
+
try {
|
|
71
|
+
send(res, req, 500, { error: errMsg(err) });
|
|
72
|
+
}
|
|
73
|
+
catch { /* ignore */ }
|
|
74
|
+
});
|
|
75
|
+
return true;
|
|
76
|
+
};
|
|
77
|
+
if (path === '/api/messenger/send') {
|
|
78
|
+
if (!emit) {
|
|
79
|
+
send(res, req, 500, { error: 'emit not wired' });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return guard(() => handleMessengerSend(req, res, emit, send), 'messenger-send', req.method === 'POST');
|
|
83
|
+
}
|
|
84
|
+
if (path === '/api/messenger/react') {
|
|
85
|
+
if (!emit) {
|
|
86
|
+
send(res, req, 500, { error: 'emit not wired' });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return guard(() => handleMessengerReact(req, res, emit, send), 'messenger-react', req.method === 'POST');
|
|
90
|
+
}
|
|
91
|
+
if (path === '/api/messenger/register') {
|
|
92
|
+
return guard(() => handleMessengerRegister(req, res, send), 'messenger-register', req.method === 'POST');
|
|
93
|
+
}
|
|
94
|
+
if (path === '/api/messenger/upload') {
|
|
95
|
+
return guard(() => handleMessengerUpload(req, res, send), 'messenger-upload', req.method === 'POST');
|
|
96
|
+
}
|
|
97
|
+
const fileMatch = path.match(/^\/api\/messenger\/files\/([^/]+)$/);
|
|
98
|
+
if (fileMatch) {
|
|
99
|
+
if (req.method !== 'GET') {
|
|
100
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
handleMessengerFile(req, res, decodeURIComponent(fileMatch[1]), send);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
export async function handleMessengerSend(req, res, emit, send) {
|
|
109
|
+
const body = await readJsonBody(req);
|
|
110
|
+
if ('__error' in body)
|
|
111
|
+
return send(res, req, 400, { error: body.__error });
|
|
112
|
+
const text = (body.text ?? '').trim();
|
|
113
|
+
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
114
|
+
const question = body.question && Array.isArray(body.question.options) && body.question.options.length > 0
|
|
115
|
+
? body.question : undefined;
|
|
116
|
+
if (!text && attachments.length === 0 && !question) {
|
|
117
|
+
return send(res, req, 400, { error: 'text, attachments, or question required' });
|
|
118
|
+
}
|
|
119
|
+
const fromAgent = body.as === 'agent';
|
|
120
|
+
const agent = userSelf();
|
|
121
|
+
const replyTo = typeof body.replyTo === 'string' && body.replyTo ? body.replyTo : undefined;
|
|
122
|
+
/** Merge attachments + question into a single payload object so the client can read
|
|
123
|
+
* both off `payload.attachments` and `payload.question`. */
|
|
124
|
+
const payload = (attachments.length > 0 || question)
|
|
125
|
+
? { ...(attachments.length > 0 ? { attachments } : {}), ...(question ? { question } : {}) }
|
|
126
|
+
: undefined;
|
|
127
|
+
const entry = {
|
|
128
|
+
id: mintId(),
|
|
129
|
+
ts: new Date().toISOString(),
|
|
130
|
+
station: 'messenger',
|
|
131
|
+
line: MESSENGER_LINE,
|
|
132
|
+
from: fromAgent ? agent : MESSENGER_USER,
|
|
133
|
+
to: fromAgent ? MESSENGER_USER : agent,
|
|
134
|
+
text: text || undefined,
|
|
135
|
+
...(replyTo ? { replyTo } : {}),
|
|
136
|
+
...(payload ? { payload } : {}),
|
|
137
|
+
};
|
|
138
|
+
emit(entry);
|
|
139
|
+
/** Agent → user: push to registered tokens. User → agent: their own message; skip. */
|
|
140
|
+
if (fromAgent) {
|
|
141
|
+
const a = attachments[0];
|
|
142
|
+
const kindLabel = a?.kind === 'image' ? '📷 Photo'
|
|
143
|
+
: a?.kind === 'audio' ? '🎤 Voice message'
|
|
144
|
+
: a?.kind === 'video' ? '🎬 Video'
|
|
145
|
+
: a ? `📎 ${a.name ?? 'File'}` : '';
|
|
146
|
+
const questionLabel = question ? `❓ ${question.header ?? 'Choose an option'}` : '';
|
|
147
|
+
const summary = text || kindLabel || questionLabel || '(empty)';
|
|
148
|
+
void pushExpo(readPushTokens(), 'Metro', summary.slice(0, 200), entry.id);
|
|
149
|
+
}
|
|
150
|
+
/** Fire-and-forget: transcribe any audio attachments and emit a follow-up event. */
|
|
151
|
+
for (const att of attachments) {
|
|
152
|
+
if (att.kind !== 'audio')
|
|
153
|
+
continue;
|
|
154
|
+
const filename = basename(att.url);
|
|
155
|
+
void transcribeAudio(join(UPLOADS_DIR, filename)).then(transcript => {
|
|
156
|
+
if (!transcript)
|
|
157
|
+
return;
|
|
158
|
+
emit({
|
|
159
|
+
id: mintId(), ts: new Date().toISOString(),
|
|
160
|
+
station: 'messenger', line: MESSENGER_LINE,
|
|
161
|
+
from: entry.from, to: entry.to,
|
|
162
|
+
payload: { transcribeFor: entry.id, transcript },
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
send(res, req, 200, { id: entry.id, line: entry.line });
|
|
167
|
+
}
|
|
168
|
+
/** Toggle a reaction; if already-active, emit `{removed: true}` so the client folds pairs. */
|
|
169
|
+
export async function handleMessengerReact(req, res, emit, send) {
|
|
170
|
+
const body = await readJsonBody(req);
|
|
171
|
+
if ('__error' in body)
|
|
172
|
+
return send(res, req, 400, { error: body.__error });
|
|
173
|
+
const messageId = (body.messageId ?? '').trim();
|
|
174
|
+
const emoji = (body.emoji ?? '').trim();
|
|
175
|
+
if (!messageId || !emoji)
|
|
176
|
+
return send(res, req, 400, { error: 'messageId + emoji required' });
|
|
177
|
+
const fromAgent = body.as === 'agent';
|
|
178
|
+
const agent = userSelf();
|
|
179
|
+
const sender = fromAgent ? agent : MESSENGER_USER;
|
|
180
|
+
/** Scan recent messenger history for an already-active reaction from this sender. */
|
|
181
|
+
/** readHistory returns newest-first; break on first match so we get the latest event's state. */
|
|
182
|
+
const recent = readHistory({ limit: 500, station: 'messenger' });
|
|
183
|
+
let active = false;
|
|
184
|
+
for (const e of recent) {
|
|
185
|
+
const p = e.payload;
|
|
186
|
+
if (!p?.reactTo || p.reactTo !== messageId || p.emoji !== emoji || e.from !== sender)
|
|
187
|
+
continue;
|
|
188
|
+
active = !p.removed;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
const entry = {
|
|
192
|
+
id: mintId(),
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
station: 'messenger',
|
|
195
|
+
line: MESSENGER_LINE,
|
|
196
|
+
from: sender,
|
|
197
|
+
to: fromAgent ? MESSENGER_USER : agent,
|
|
198
|
+
payload: { reactTo: messageId, emoji, ...(active ? { removed: true } : {}) },
|
|
199
|
+
};
|
|
200
|
+
emit(entry);
|
|
201
|
+
send(res, req, 200, { id: entry.id, removed: active });
|
|
202
|
+
}
|
|
203
|
+
export async function handleMessengerRegister(req, res, send) {
|
|
204
|
+
const body = await readJsonBody(req);
|
|
205
|
+
if ('__error' in body)
|
|
206
|
+
return send(res, req, 400, { error: body.__error });
|
|
207
|
+
const token = (body.pushToken ?? '').trim();
|
|
208
|
+
if (!token)
|
|
209
|
+
return send(res, req, 400, { error: 'pushToken is required' });
|
|
210
|
+
const tokens = readPushTokens();
|
|
211
|
+
if (!tokens.includes(token))
|
|
212
|
+
writePushTokens([...tokens, token]);
|
|
213
|
+
send(res, req, 200, { ok: true, count: tokens.includes(token) ? tokens.length : tokens.length + 1 });
|
|
214
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Local whisper.cpp audio transcription. Opt-in: requires whisper-cli + ggml model on disk. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { basename, extname, join } from 'node:path';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
const WHISPER_BIN = process.env.METRO_WHISPER_BIN ?? 'whisper-cli';
|
|
8
|
+
const WHISPER_MODEL = process.env.METRO_WHISPER_MODEL
|
|
9
|
+
?? join(homedir(), '.cache', 'whisper-cpp', 'ggml-base.bin');
|
|
10
|
+
const FFMPEG_BIN = process.env.METRO_FFMPEG_BIN ?? 'ffmpeg';
|
|
11
|
+
function runCmd(bin, args) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawn(bin, args, { stdio: 'ignore' });
|
|
14
|
+
child.on('error', reject);
|
|
15
|
+
child.on('exit', code => (code === 0 ? resolve() : reject(new Error(`${bin} exited ${code}`))));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Transcribe a local audio file to text. Returns null on any failure (incl. missing tooling). */
|
|
19
|
+
export async function transcribeAudio(filePath) {
|
|
20
|
+
if (!existsSync(WHISPER_MODEL) || !existsSync(filePath))
|
|
21
|
+
return null;
|
|
22
|
+
const base = basename(filePath, extname(filePath));
|
|
23
|
+
const wav = join(tmpdir(), `metro-tx-${base}.wav`);
|
|
24
|
+
const outPrefix = join(tmpdir(), `metro-tx-${base}`);
|
|
25
|
+
try {
|
|
26
|
+
await runCmd(FFMPEG_BIN, ['-y', '-i', filePath, '-ar', '16000', '-ac', '1', wav]);
|
|
27
|
+
await runCmd(WHISPER_BIN, ['-m', WHISPER_MODEL, '-f', wav, '--output-txt', '-of', outPrefix]);
|
|
28
|
+
const text = readFileSync(`${outPrefix}.txt`, 'utf8').trim();
|
|
29
|
+
return text || null;
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
log.warn({ err: errMsg(err) }, 'messenger transcribe failed');
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
for (const f of [wav, `${outPrefix}.txt`]) {
|
|
37
|
+
try {
|
|
38
|
+
unlinkSync(f);
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Messenger upload + serve: POST /api/messenger/upload, GET /api/messenger/files/:name. */
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { pipeline } from 'node:stream/promises';
|
|
5
|
+
import { mintId } from '../history.js';
|
|
6
|
+
import { errMsg, log } from '../log.js';
|
|
7
|
+
import { STATE_DIR } from '../paths.js';
|
|
8
|
+
const UPLOADS_DIR = join(STATE_DIR, 'messenger-uploads');
|
|
9
|
+
const UPLOAD_MAX = 25 * 1024 * 1024;
|
|
10
|
+
/** Tunable via env: how long uploaded files stick around before they're pruned (ms). */
|
|
11
|
+
const UPLOAD_TTL_MS = Number(process.env.METRO_UPLOAD_TTL_MS ?? String(90 * 24 * 60 * 60 * 1000));
|
|
12
|
+
mkdirSync(UPLOADS_DIR, { recursive: true });
|
|
13
|
+
/** Best-effort: delete uploads older than UPLOAD_TTL_MS. Called once at module load + periodically. */
|
|
14
|
+
function pruneOldUploads() {
|
|
15
|
+
const cutoff = Date.now() - UPLOAD_TTL_MS;
|
|
16
|
+
let removed = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (const name of readdirSync(UPLOADS_DIR)) {
|
|
19
|
+
const path = join(UPLOADS_DIR, name);
|
|
20
|
+
try {
|
|
21
|
+
if (statSync(path).mtimeMs < cutoff) {
|
|
22
|
+
unlinkSync(path);
|
|
23
|
+
removed += 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch { /* ignore individual file errors */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch { /* dir missing — nothing to prune */ }
|
|
30
|
+
if (removed > 0)
|
|
31
|
+
log.info({ removed, dir: UPLOADS_DIR }, 'messenger-uploads: pruned old files');
|
|
32
|
+
}
|
|
33
|
+
pruneOldUploads();
|
|
34
|
+
setInterval(pruneOldUploads, 12 * 60 * 60 * 1000).unref();
|
|
35
|
+
/** Map MIME prefix → attachment kind. */
|
|
36
|
+
export function kindFromMime(mime) {
|
|
37
|
+
if (mime.startsWith('image/'))
|
|
38
|
+
return 'image';
|
|
39
|
+
if (mime.startsWith('audio/'))
|
|
40
|
+
return 'audio';
|
|
41
|
+
if (mime.startsWith('video/'))
|
|
42
|
+
return 'video';
|
|
43
|
+
return 'file';
|
|
44
|
+
}
|
|
45
|
+
const MIME_TO_EXT = {
|
|
46
|
+
'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg',
|
|
47
|
+
'image/webp': '.webp', 'image/gif': '.gif', 'image/heic': '.heic',
|
|
48
|
+
'audio/mp4': '.m4a', 'audio/m4a': '.m4a', 'audio/aac': '.aac',
|
|
49
|
+
'audio/mpeg': '.mp3', 'audio/ogg': '.ogg', 'audio/webm': '.webm', 'audio/wav': '.wav',
|
|
50
|
+
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov',
|
|
51
|
+
'application/pdf': '.pdf', 'application/zip': '.zip',
|
|
52
|
+
'text/plain': '.txt', 'text/markdown': '.md',
|
|
53
|
+
};
|
|
54
|
+
/** mime → file extension. Best-effort, falls back to '.bin'. */
|
|
55
|
+
function extFromMime(mime) {
|
|
56
|
+
return MIME_TO_EXT[mime.toLowerCase().split(';')[0]] ?? '.bin';
|
|
57
|
+
}
|
|
58
|
+
/** ext → mime, used when serving uploads so browsers / image tags interpret them correctly. */
|
|
59
|
+
function mimeFromExt(ext) {
|
|
60
|
+
const reverse = Object.entries(MIME_TO_EXT).find(([, e]) => e === ext.toLowerCase());
|
|
61
|
+
return reverse ? reverse[0] : 'application/octet-stream';
|
|
62
|
+
}
|
|
63
|
+
/** Raw binary upload: body = file bytes, headers `Content-Type` and `X-Filename` (optional). */
|
|
64
|
+
export async function handleMessengerUpload(req, res, send) {
|
|
65
|
+
const mime = (req.headers['content-type'] ?? 'application/octet-stream').toString().split(';')[0].trim();
|
|
66
|
+
const declared = Number(req.headers['content-length'] ?? '0');
|
|
67
|
+
if (declared > UPLOAD_MAX)
|
|
68
|
+
return send(res, req, 413, { error: `upload exceeds ${UPLOAD_MAX} bytes` });
|
|
69
|
+
const name = req.headers['x-filename']?.toString().slice(0, 256);
|
|
70
|
+
const id = mintId();
|
|
71
|
+
const ext = name ? extname(name) || extFromMime(mime) : extFromMime(mime);
|
|
72
|
+
const filename = `${id}${ext}`;
|
|
73
|
+
const dest = join(UPLOADS_DIR, filename);
|
|
74
|
+
let total = 0;
|
|
75
|
+
const out = createWriteStream(dest);
|
|
76
|
+
req.on('data', (chunk) => {
|
|
77
|
+
total += chunk.length;
|
|
78
|
+
if (total > UPLOAD_MAX) {
|
|
79
|
+
req.destroy(new Error('upload too large'));
|
|
80
|
+
out.destroy();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
await pipeline(req, out);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
try {
|
|
88
|
+
send(res, req, 413, { error: errMsg(err) });
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
/** Path-only URL; the client adds host + token. Stable, host-independent across tunnels. */
|
|
94
|
+
send(res, req, 200, {
|
|
95
|
+
id, url: `/api/messenger/files/${filename}`, kind: kindFromMime(mime), mime, size: total, name,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** GET /api/messenger/files/:filename — stream a previously uploaded file back. */
|
|
99
|
+
export function handleMessengerFile(req, res, filename, send) {
|
|
100
|
+
/** Guard path traversal — only basenames allowed. */
|
|
101
|
+
if (filename.includes('/') || filename.includes('..') || !filename) {
|
|
102
|
+
return send(res, req, 400, { error: 'bad filename' });
|
|
103
|
+
}
|
|
104
|
+
const path = join(UPLOADS_DIR, filename);
|
|
105
|
+
if (!existsSync(path))
|
|
106
|
+
return send(res, req, 404, { error: 'not found' });
|
|
107
|
+
const stat = statSync(path);
|
|
108
|
+
const dot = filename.lastIndexOf('.');
|
|
109
|
+
const ext = dot >= 0 ? filename.slice(dot) : '';
|
|
110
|
+
res.writeHead(200, {
|
|
111
|
+
'content-type': mimeFromExt(ext),
|
|
112
|
+
'content-length': stat.size.toString(),
|
|
113
|
+
'cache-control': 'private, max-age=31536000',
|
|
114
|
+
});
|
|
115
|
+
createReadStream(path).pipe(res);
|
|
116
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** HTTP monitor endpoints: GET /api/state, GET /api/tail (SSE), POST /api/call/<train>/<action>. */
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import pkg from '../../package.json' with { type: 'json' };
|
|
4
|
+
import { readClaims } from '../broker/claims.js';
|
|
5
|
+
import { drainTail, followTail, historySize, } from '../broker/history-stream.js';
|
|
6
|
+
import { readHistory } from '../history.js';
|
|
7
|
+
import { ipcCall } from '../ipc.js';
|
|
8
|
+
import { asLine } from '../lines.js';
|
|
9
|
+
import { errMsg, log } from '../log.js';
|
|
10
|
+
import { readBotIds } from '../paths.js';
|
|
11
|
+
import { routeMessenger } from './messenger-api.js';
|
|
12
|
+
/** Monitor endpoints answer only on dedicated hostnames so webhook tunnel can't double-serve them. */
|
|
13
|
+
const MONITOR_HOSTS = new Set((process.env.METRO_MONITOR_HOSTS ?? 'monitor.metro.box,localhost,127.0.0.1')
|
|
14
|
+
.toLowerCase().split(',').map(s => s.trim()).filter(Boolean));
|
|
15
|
+
const CALL_BODY_MAX = 256 * 1024;
|
|
16
|
+
/** Reflect request Origin so browsers (Netlify, custom domains, file://) can call cross-origin. */
|
|
17
|
+
function cors(req) {
|
|
18
|
+
return {
|
|
19
|
+
'access-control-allow-origin': req.headers.origin ?? '*',
|
|
20
|
+
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
|
21
|
+
'access-control-allow-headers': 'Authorization, Content-Type',
|
|
22
|
+
'access-control-max-age': '86400',
|
|
23
|
+
vary: 'Origin',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function send(res, req, status, body) {
|
|
27
|
+
res.writeHead(status, { 'content-type': 'application/json', ...cors(req) });
|
|
28
|
+
res.end(JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
function tokenEq(given, want) {
|
|
31
|
+
const g = Buffer.from(given), w = Buffer.from(want);
|
|
32
|
+
return g.length === w.length && timingSafeEqual(g, w);
|
|
33
|
+
}
|
|
34
|
+
function authorized(req, q) {
|
|
35
|
+
const token = process.env.METRO_MONITOR_TOKEN;
|
|
36
|
+
if (!token)
|
|
37
|
+
return { status: 503, msg: 'monitor endpoints not configured (METRO_MONITOR_TOKEN unset)' };
|
|
38
|
+
const header = [].concat(req.headers['authorization'] ?? [])[0];
|
|
39
|
+
if (header?.startsWith('Bearer ') && tokenEq(header.slice(7), token))
|
|
40
|
+
return null;
|
|
41
|
+
/** Query-token path is for media tags (img/audio) that can't send Authorization headers. */
|
|
42
|
+
const qt = q?.get('token');
|
|
43
|
+
if (qt && tokenEq(qt, token))
|
|
44
|
+
return null;
|
|
45
|
+
return { status: 401, msg: 'unauthorized' };
|
|
46
|
+
}
|
|
47
|
+
/** Mode picker — shared with CLI tail. Conflict/strict-no-self routed through `onErr`. */
|
|
48
|
+
export function pickMode(strict, unclaimed, all, self, onErr) {
|
|
49
|
+
if ([strict, unclaimed, all].filter(Boolean).length > 1) {
|
|
50
|
+
return onErr('strict/unclaimed/all are mutually exclusive');
|
|
51
|
+
}
|
|
52
|
+
if (strict)
|
|
53
|
+
return self ? 'mine-only' : onErr('strict requires --as <user-uri>');
|
|
54
|
+
if (unclaimed)
|
|
55
|
+
return 'unclaimed';
|
|
56
|
+
if (all || !self)
|
|
57
|
+
return 'all';
|
|
58
|
+
return 'mine-or-unclaimed';
|
|
59
|
+
}
|
|
60
|
+
export function handleMonitorRequest(req, res, emit) {
|
|
61
|
+
const url = req.url ?? '';
|
|
62
|
+
if (!url.startsWith('/api/'))
|
|
63
|
+
return false;
|
|
64
|
+
const host = req.headers[':authority'] ?? req.headers.host;
|
|
65
|
+
if (host && !MONITOR_HOSTS.has(host.split(':')[0].toLowerCase()))
|
|
66
|
+
return false;
|
|
67
|
+
/** CORS preflight — short-circuit before auth so browsers can OPTIONS without a token. */
|
|
68
|
+
if (req.method === 'OPTIONS') {
|
|
69
|
+
res.writeHead(204, cors(req));
|
|
70
|
+
res.end();
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const [path, qs = ''] = url.split('?', 2);
|
|
74
|
+
const q = new URLSearchParams(qs);
|
|
75
|
+
const auth = authorized(req, q);
|
|
76
|
+
if (auth) {
|
|
77
|
+
send(res, req, auth.status, { error: auth.msg });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (req.method === 'GET' && path === '/api/state') {
|
|
81
|
+
handleState(res, req, q);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (req.method === 'GET' && path === '/api/tail') {
|
|
85
|
+
handleTail(req, res, q).catch(err => {
|
|
86
|
+
log.warn({ err: errMsg(err) }, 'monitor: tail handler error');
|
|
87
|
+
try {
|
|
88
|
+
if (!res.headersSent)
|
|
89
|
+
res.writeHead(500).end();
|
|
90
|
+
else
|
|
91
|
+
res.end();
|
|
92
|
+
}
|
|
93
|
+
catch { /* ignore */ }
|
|
94
|
+
});
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** POST /api/call/<train>/<action> — JSON body {args} forwarded to train via IPC forward-call. */
|
|
98
|
+
const callMatch = path.match(/^\/api\/call\/([^/]+)\/([^/]+)$/);
|
|
99
|
+
if (callMatch) {
|
|
100
|
+
if (req.method !== 'POST') {
|
|
101
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
handleCall(req, res, callMatch[1], callMatch[2]).catch(err => {
|
|
105
|
+
log.warn({ err: errMsg(err) }, 'monitor: call handler error');
|
|
106
|
+
try {
|
|
107
|
+
send(res, req, 500, { error: errMsg(err) });
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** /api/messenger/* — send, register, upload, files/:name. */
|
|
114
|
+
if (path.startsWith('/api/messenger/') && routeMessenger(req, res, path, emit, send))
|
|
115
|
+
return true;
|
|
116
|
+
/** GET-only paths reject other verbs with 405. Anything else → 404. */
|
|
117
|
+
if (req.method !== 'GET' && (path === '/api/state' || path === '/api/tail')) {
|
|
118
|
+
send(res, req, 405, { error: 'method not allowed' });
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
send(res, req, 404, { error: 'not found' });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function nonNegInt(raw) {
|
|
125
|
+
const n = raw == null ? NaN : Number(raw);
|
|
126
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
127
|
+
}
|
|
128
|
+
function handleState(res, req, q) {
|
|
129
|
+
const before = nonNegInt(q.get('before'));
|
|
130
|
+
const limit = Math.min(nonNegInt(q.get('limit')) ?? 100, 500);
|
|
131
|
+
if (before !== null)
|
|
132
|
+
return send(res, req, 200, { recent_history: readHistory({ limit, skip: before }) });
|
|
133
|
+
const recent = readHistory({ limit }), claims = readClaims();
|
|
134
|
+
const lines = new Set([...recent.map(e => e.line), ...Object.keys(claims)]);
|
|
135
|
+
send(res, req, 200, {
|
|
136
|
+
claims, lines: [...lines], recent_history: recent, bot_ids: readBotIds(), version: pkg.version,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async function handleTail(req, res, q) {
|
|
140
|
+
const asParam = q.get('as');
|
|
141
|
+
const self = asParam ? asLine(asParam) : null;
|
|
142
|
+
const isOn = (k) => q.get(k) === 'true' || q.get('mode') === k;
|
|
143
|
+
const mode = pickMode(isOn('strict'), isOn('unclaimed'), isOn('all'), self, () => 'all');
|
|
144
|
+
const excludeFromCsv = q.get('exclude_from');
|
|
145
|
+
const opts = {
|
|
146
|
+
mode, self, chatFilter: q.get('chat') ?? undefined,
|
|
147
|
+
stationFilter: q.get('station') ?? undefined,
|
|
148
|
+
includeWebhooks: q.get('include_webhooks') === 'true',
|
|
149
|
+
excludeFrom: excludeFromCsv ? excludeFromCsv.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
|
150
|
+
};
|
|
151
|
+
res.writeHead(200, {
|
|
152
|
+
'content-type': 'text/event-stream', 'cache-control': 'no-cache, no-transform',
|
|
153
|
+
'connection': 'keep-alive', ...cors(req),
|
|
154
|
+
/** Cloudflare/proxies buffer SSE without this hint. */
|
|
155
|
+
'x-accel-buffering': 'no',
|
|
156
|
+
});
|
|
157
|
+
/** `since=tail` (default) starts at EOF; `since=0` replays the full file; numeric = byte offset. */
|
|
158
|
+
const since = q.get('since');
|
|
159
|
+
const sinceN = since && since !== 'tail' ? Number(since) : NaN;
|
|
160
|
+
let offset = Number.isFinite(sinceN) && sinceN >= 0 ? sinceN : historySize();
|
|
161
|
+
/** 4 KiB padding so Cloudflare's HTTP/2 buffer flushes (else holds 30+ s on free tier). */
|
|
162
|
+
res.write(`: metro monitor tail (mode=${opts.mode}${self ? `, as=${self}` : ''})\n: ${'-'.repeat(4096)}\n\n`);
|
|
163
|
+
const sse = (e) => {
|
|
164
|
+
res.write(`id: ${e.id}\nevent: history\ndata: ${JSON.stringify(e)}\n\n`);
|
|
165
|
+
};
|
|
166
|
+
offset = drainTail(offset, opts, sse);
|
|
167
|
+
const stop = followTail(offset, opts, sse, 1_000);
|
|
168
|
+
const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
|
|
169
|
+
const cleanup = () => { stop(); clearInterval(keepalive); try {
|
|
170
|
+
res.end();
|
|
171
|
+
}
|
|
172
|
+
catch { /* ignore */ } };
|
|
173
|
+
req.on('close', cleanup);
|
|
174
|
+
req.on('error', cleanup);
|
|
175
|
+
}
|
|
176
|
+
async function handleCall(req, res, train, action) {
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let total = 0;
|
|
179
|
+
for await (const chunk of req) {
|
|
180
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
181
|
+
total += buf.length;
|
|
182
|
+
if (total > CALL_BODY_MAX)
|
|
183
|
+
throw new Error(`request body exceeds ${CALL_BODY_MAX} bytes`);
|
|
184
|
+
chunks.push(buf);
|
|
185
|
+
}
|
|
186
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
187
|
+
let args = {};
|
|
188
|
+
if (raw) {
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(raw);
|
|
191
|
+
args = parsed && typeof parsed === 'object' && 'args' in parsed ? parsed.args : parsed;
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
return send(res, req, 400, { error: `bad JSON body: ${errMsg(err)}` });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const resp = await ipcCall({ op: 'forward-call', train, action, args });
|
|
198
|
+
if (!resp.ok)
|
|
199
|
+
return send(res, req, 502, { error: resp.error });
|
|
200
|
+
if (!('response' in resp))
|
|
201
|
+
return send(res, req, 502, { error: 'malformed daemon response' });
|
|
202
|
+
if (resp.response.error)
|
|
203
|
+
return send(res, req, 502, { error: resp.response.error });
|
|
204
|
+
send(res, req, 200, { result: resp.response.result ?? null });
|
|
205
|
+
}
|