@stage-labs/metro 0.1.0-beta.13 → 0.1.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +76 -189
  2. package/dist/broker/claims.js +144 -0
  3. package/dist/{broker.js → broker/history-stream.js} +44 -99
  4. package/dist/cli/config.js +115 -121
  5. package/dist/cli/index.js +20 -58
  6. package/dist/cli/tail.js +161 -113
  7. package/dist/cli/webhook.js +103 -3
  8. package/dist/{codex-rc.js → codex-rc/client.js} +12 -32
  9. package/dist/codex-rc/protocol.js +38 -0
  10. package/dist/dispatcher/server.js +130 -0
  11. package/dist/dispatcher.js +51 -82
  12. package/dist/history.js +43 -18
  13. package/dist/ipc.js +28 -10
  14. package/dist/lines.js +54 -0
  15. package/dist/local-identity.js +80 -0
  16. package/dist/paths.js +58 -12
  17. package/dist/trains/protocol.js +99 -0
  18. package/dist/trains/supervisor.js +210 -0
  19. package/dist/tunnel.js +39 -1
  20. package/docs/broker.md +88 -136
  21. package/docs/monitor.md +19 -2
  22. package/docs/uri-scheme.md +10 -7
  23. package/examples/README.md +32 -0
  24. package/examples/telegram.ts +121 -0
  25. package/package.json +6 -5
  26. package/skills/metro/SKILL.md +63 -215
  27. package/dist/cache.js +0 -69
  28. package/dist/cli/actions.js +0 -206
  29. package/dist/cli/skill.js +0 -62
  30. package/dist/monitor.js +0 -194
  31. package/dist/registry.js +0 -48
  32. package/dist/stations/claude.js +0 -45
  33. package/dist/stations/codex.js +0 -68
  34. package/dist/stations/discord.js +0 -216
  35. package/dist/stations/index.js +0 -129
  36. package/dist/stations/telegram-md.js +0 -34
  37. package/dist/stations/telegram-upload.js +0 -113
  38. package/dist/stations/telegram.js +0 -234
  39. package/dist/stations/webhook.js +0 -103
  40. package/dist/webhooks.js +0 -41
  41. package/docs/users.md +0 -226
package/dist/cli/skill.js DELETED
@@ -1,62 +0,0 @@
1
- /** `metro setup skill` — install the bundled SKILL.md into each detected user runtime. */
2
- import { copyFileSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { errMsg } from '../log.js';
7
- import { emit, exitErr } from './util.js';
8
- const RUNTIME_DIRS = {
9
- 'claude-code': join(homedir(), '.claude', 'skills', 'metro'),
10
- codex: join(homedir(), '.codex', 'skills', 'metro'),
11
- };
12
- /** dist/cli/skill.js → <package-root>/skills/metro/SKILL.md */
13
- const bundledPath = () => join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'skills', 'metro', 'SKILL.md');
14
- const dest = (r) => join(RUNTIME_DIRS[r], 'SKILL.md');
15
- export const skillStatus = () => ({
16
- 'claude-code': existsSync(dest('claude-code')),
17
- codex: existsSync(dest('codex')),
18
- });
19
- export async function cmdSetupSkill(p, f) {
20
- const [sub] = p;
21
- if (sub === 'clear')
22
- return clear(f);
23
- if (sub && sub !== 'install')
24
- throw exitErr(`unknown skill subcommand '${sub}' (try: install, clear)`, 1);
25
- return install(f);
26
- }
27
- function install(f) {
28
- const src = bundledPath();
29
- if (!existsSync(src))
30
- throw exitErr(`bundled SKILL.md missing at ${src} (broken install?)`, 2);
31
- const installed = [];
32
- for (const r of Object.keys(RUNTIME_DIRS)) {
33
- if (!existsSync(join(homedir(), r === 'claude-code' ? '.claude' : '.codex')))
34
- continue;
35
- try {
36
- mkdirSync(RUNTIME_DIRS[r], { recursive: true });
37
- copyFileSync(src, dest(r));
38
- installed.push(dest(r));
39
- }
40
- catch (err) {
41
- throw exitErr(`failed to install skill for ${r}: ${errMsg(err)}`, 2);
42
- }
43
- }
44
- if (!installed.length) {
45
- throw exitErr('no user runtime detected (~/.claude or ~/.codex). Install one and rerun.', 2);
46
- }
47
- emit(f, `installed metro skill → ${installed.join(', ')}`, { ok: true, installed });
48
- }
49
- function clear(f) {
50
- const removed = [];
51
- for (const r of Object.keys(RUNTIME_DIRS)) {
52
- const path = dest(r);
53
- if (existsSync(path)) {
54
- try {
55
- unlinkSync(path);
56
- removed.push(path);
57
- }
58
- catch { /* ignore */ }
59
- }
60
- }
61
- emit(f, removed.length ? `removed metro skill from ${removed.join(', ')}` : 'no installed skill found', { ok: true, removed });
62
- }
package/dist/monitor.js DELETED
@@ -1,194 +0,0 @@
1
- /** Read-only HTTP monitor endpoints. `/api/state` (snapshot) + `/api/tail` (SSE). */
2
- /** Mounted on the webhook server. Bearer auth via METRO_MONITOR_TOKEN (503 when unset). */
3
- import { timingSafeEqual } from 'node:crypto';
4
- import { watch } from 'node:fs';
5
- import pkg from '../package.json' with { type: 'json' };
6
- import { errMsg, log } from './log.js';
7
- import { HISTORY_FILE, historySize, passesMode, readClaims, readEntriesFrom, } from './broker.js';
8
- import { readBotIds } from './cache.js';
9
- import { readHistory } from './history.js';
10
- import { asLine } from './stations/index.js';
11
- const HISTORY_LIMIT = 100;
12
- function authorized(req) {
13
- const token = process.env.METRO_MONITOR_TOKEN;
14
- if (!token)
15
- return { ok: false, status: 503, msg: 'monitor endpoints not configured (METRO_MONITOR_TOKEN unset)' };
16
- const header = req.headers['authorization'];
17
- const value = Array.isArray(header) ? header[0] : header;
18
- if (!value || !value.startsWith('Bearer '))
19
- return { ok: false, status: 401, msg: 'unauthorized' };
20
- const given = Buffer.from(value.slice('Bearer '.length));
21
- const want = Buffer.from(token);
22
- if (given.length !== want.length || !timingSafeEqual(given, want)) {
23
- return { ok: false, status: 401, msg: 'unauthorized' };
24
- }
25
- return { ok: true };
26
- }
27
- /** Hosts that serve `/api/*`. webhook.metro.box stays scoped to /wh/*. */
28
- const MONITOR_HOSTS = new Set(['monitor.metro.box', 'localhost', '127.0.0.1']);
29
- function monitorHostAllowed(req) {
30
- const raw = req.headers[':authority'] ?? req.headers.host;
31
- if (!raw)
32
- return true;
33
- return MONITOR_HOSTS.has(raw.split(':')[0].toLowerCase());
34
- }
35
- export function handleMonitorRequest(req, res) {
36
- const url = req.url ?? '';
37
- if (!url.startsWith('/api/'))
38
- return false;
39
- if (!monitorHostAllowed(req))
40
- return false; // let outer router 404 it
41
- const [pathOnly, queryString = ''] = url.split('?', 2);
42
- if (req.method !== 'GET') {
43
- res.writeHead(405, { 'content-type': 'application/json' });
44
- res.end(JSON.stringify({ error: 'method not allowed' }));
45
- return true;
46
- }
47
- const auth = authorized(req);
48
- if (!auth.ok) {
49
- res.writeHead(auth.status, { 'content-type': 'application/json' });
50
- res.end(JSON.stringify({ error: auth.msg }));
51
- return true;
52
- }
53
- const query = new URLSearchParams(queryString);
54
- if (pathOnly === '/api/state') {
55
- handleState(res, query);
56
- return true;
57
- }
58
- if (pathOnly === '/api/tail') {
59
- handleTail(req, res, query).catch(err => {
60
- log.warn({ err: errMsg(err) }, 'monitor: tail handler error');
61
- try {
62
- if (!res.headersSent)
63
- res.writeHead(500).end();
64
- else
65
- res.end();
66
- }
67
- catch { /* ignore */ }
68
- });
69
- return true;
70
- }
71
- res.writeHead(404, { 'content-type': 'application/json' });
72
- res.end(JSON.stringify({ error: 'not found' }));
73
- return true;
74
- }
75
- function parseNonNegInt(raw) {
76
- if (raw === null)
77
- return null;
78
- const n = Number(raw);
79
- if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n))
80
- return null;
81
- return n;
82
- }
83
- function handleState(res, query) {
84
- const before = parseNonNegInt(query.get('before'));
85
- const limitRaw = parseNonNegInt(query.get('limit'));
86
- /** Page mode: skip `before` newest entries, return next `limit` (default HISTORY_LIMIT, capped at 500). */
87
- if (before !== null) {
88
- const limit = Math.min(limitRaw ?? HISTORY_LIMIT, 500);
89
- const page = readHistory({ limit, skip: before });
90
- res.writeHead(200, { 'content-type': 'application/json' });
91
- res.end(JSON.stringify({ recent_history: page }));
92
- return;
93
- }
94
- /** `readHistory` is newest-first — what the activity feed expects. */
95
- const limit = Math.min(limitRaw ?? HISTORY_LIMIT, 500);
96
- const recent = readHistory({ limit });
97
- const claims = readClaims();
98
- const linesSet = new Set();
99
- for (const e of recent)
100
- linesSet.add(e.line);
101
- for (const line of Object.keys(claims))
102
- linesSet.add(line);
103
- res.writeHead(200, { 'content-type': 'application/json' });
104
- res.end(JSON.stringify({
105
- claims,
106
- lines: [...linesSet],
107
- recent_history: recent,
108
- bot_ids: readBotIds(),
109
- version: pkg.version,
110
- }));
111
- }
112
- /** Mirrors `cli/tail.ts:resolveMode` but operates on URLSearchParams. */
113
- function resolveQueryMode(query, self) {
114
- const strict = query.get('strict') === 'true' || query.get('mode') === 'strict';
115
- const unclaimed = query.get('unclaimed') === 'true' || query.get('mode') === 'unclaimed';
116
- const all = query.get('all') === 'true' || query.get('mode') === 'all';
117
- if ([strict, unclaimed, all].filter(Boolean).length > 1)
118
- return 'all';
119
- if (strict && self)
120
- return 'mine-only';
121
- if (unclaimed)
122
- return 'unclaimed';
123
- if (all || !self)
124
- return 'all';
125
- return 'mine-or-unclaimed';
126
- }
127
- async function handleTail(req, res, query) {
128
- const asParam = query.get('as');
129
- const self = asParam ? asLine(asParam) : null;
130
- const mode = resolveQueryMode(query, self);
131
- const chatFilter = query.get('chat');
132
- const stationFilter = query.get('station');
133
- const includeWebhooks = query.get('include_webhooks') === 'true';
134
- res.writeHead(200, {
135
- 'content-type': 'text/event-stream',
136
- 'cache-control': 'no-cache, no-transform',
137
- 'connection': 'keep-alive',
138
- /** Bearer auth already gates us; CORS can be permissive. */
139
- 'access-control-allow-origin': '*',
140
- /** Cloudflare/proxies buffer SSE without this hint. */
141
- 'x-accel-buffering': 'no',
142
- });
143
- /** `since=tail` (default) starts at EOF; `since=0` replays the full file. */
144
- const since = query.get('since');
145
- let offset = since === '0' ? 0 : historySize();
146
- if (since && since !== '0' && since !== 'tail') {
147
- const n = Number(since);
148
- if (Number.isFinite(n) && n >= 0)
149
- offset = n;
150
- }
151
- /** 4 KiB padding so Cloudflare's HTTP/2 buffer flushes (else holds 30+ s on free tier). */
152
- res.write(`: metro monitor tail (mode=${mode}${self ? `, as=${self}` : ''})\n`);
153
- res.write(`: ${'-'.repeat(4096)}\n\n`);
154
- const drain = () => {
155
- const claims = readClaims();
156
- for (const { entry, offset: next } of readEntriesFrom(offset)) {
157
- offset = next;
158
- if (chatFilter && entry.line !== chatFilter)
159
- continue;
160
- if (stationFilter && entry.station !== stationFilter)
161
- continue;
162
- if (!passesMode(entry, mode, self, claims, { includeWebhooks }))
163
- continue;
164
- res.write(`id: ${entry.id}\n`);
165
- res.write('event: history\n');
166
- res.write(`data: ${JSON.stringify(entry)}\n\n`);
167
- }
168
- };
169
- drain();
170
- /** fs.watch coalesces on macOS — poll every 1s as a backstop. */
171
- let watcher = null;
172
- try {
173
- watcher = watch(HISTORY_FILE, () => drain());
174
- }
175
- catch { /* file may not exist yet */ }
176
- const poll = setInterval(drain, 1_000);
177
- const keepalive = setInterval(() => res.write(': keepalive\n\n'), 25_000);
178
- const cleanup = () => {
179
- clearInterval(poll);
180
- clearInterval(keepalive);
181
- if (watcher) {
182
- try {
183
- watcher.close();
184
- }
185
- catch { /* ignore */ }
186
- }
187
- try {
188
- res.end();
189
- }
190
- catch { /* ignore */ }
191
- };
192
- req.on('close', cleanup);
193
- req.on('error', cleanup);
194
- }
package/dist/registry.js DELETED
@@ -1,48 +0,0 @@
1
- /** Append-only registry of `(station, user-id, sessions[])` tuples metro has seen. */
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { STATE_DIR } from './paths.js';
5
- import { Line } from './stations/index.js';
6
- import { errMsg, log } from './log.js';
7
- const REGISTRY_FILE = join(STATE_DIR, 'user-registry.json');
8
- function readRegistry() {
9
- if (!existsSync(REGISTRY_FILE))
10
- return {};
11
- try {
12
- return JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
13
- }
14
- catch (err) {
15
- log.warn({ err: errMsg(err) }, 'user-registry: malformed, resetting');
16
- return {};
17
- }
18
- }
19
- function record(station, userId, sessionId) {
20
- const reg = readRegistry();
21
- const rows = (reg[station] ??= []);
22
- let row = rows.find(r => r.userId === userId);
23
- if (!row) {
24
- row = { userId, sessions: [], lastSeen: '' };
25
- rows.push(row);
26
- }
27
- if (sessionId && !row.sessions.includes(sessionId))
28
- row.sessions.push(sessionId);
29
- row.lastSeen = new Date().toISOString();
30
- try {
31
- writeFileSync(REGISTRY_FILE, JSON.stringify(reg, null, 2));
32
- }
33
- catch (err) {
34
- log.warn({ err: errMsg(err) }, 'user-registry: write failed');
35
- }
36
- }
37
- /** Scan a line URI for `(station, userId, sessionId)` and record it. No-op on non-user or participant URIs. */
38
- export function noteUserFromLine(line) {
39
- const station = Line.station(line);
40
- if (station !== 'claude' && station !== 'codex')
41
- return;
42
- const p = station === 'claude' ? Line.parseClaude(line) : Line.parseCodex(line);
43
- if (p)
44
- record(station, p.userId, p.sessionId);
45
- }
46
- export function listUsers(station) {
47
- return readRegistry()[station] ?? [];
48
- }
@@ -1,45 +0,0 @@
1
- /** Resolve the Claude Code user identity (account id + session id). */
2
- import { execFileSync } from 'node:child_process';
3
- /** Short TTL so account switches via `claude auth login` propagate to the daemon within seconds. */
4
- const TTL_MS = 5_000;
5
- let cache = null;
6
- /** Stable per-Anthropic-account UUID. Same across devices for the same login. */
7
- export function claudeAccountId() {
8
- if (cache && Date.now() - cache.at < TTL_MS)
9
- return cache.id;
10
- let raw;
11
- try {
12
- raw = execFileSync('claude', ['auth', 'status', '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
13
- }
14
- catch (e) {
15
- throw new Error(`metro: failed to run 'claude auth status --json' — is Claude Code installed and on PATH? (${e.message})`);
16
- }
17
- let parsed;
18
- try {
19
- parsed = JSON.parse(raw);
20
- }
21
- catch {
22
- throw new Error(`metro: 'claude auth status --json' returned non-JSON: ${raw.slice(0, 200)}`);
23
- }
24
- if (!parsed.loggedIn || !parsed.orgId) {
25
- throw new Error('metro: Claude Code is not logged in — run \'claude auth login\'');
26
- }
27
- cache = { id: parsed.orgId, at: Date.now() };
28
- return parsed.orgId;
29
- }
30
- export function tryClaudeAccountId() {
31
- try {
32
- return claudeAccountId();
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
- /** User-id for the line URI: `METRO_USER_ID` override, else the account id. */
39
- export function claudeUserId() {
40
- return process.env.METRO_USER_ID || claudeAccountId();
41
- }
42
- /** Session: `CLAUDE_CODE_SESSION_ID` (stable across `--resume`). Override: `METRO_USER_SESSION_ID`. */
43
- export function claudeSessionId() {
44
- return process.env.METRO_USER_SESSION_ID || process.env.CLAUDE_CODE_SESSION_ID || null;
45
- }
@@ -1,68 +0,0 @@
1
- /** Resolve the Codex user identity (account id + session id). */
2
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { dirname, join } from 'node:path';
5
- import { STATE_DIR } from '../paths.js';
6
- /** Short TTL so account switches via `codex login` propagate to the daemon within seconds. */
7
- const TTL_MS = 5_000;
8
- let cache = null;
9
- function authPath() {
10
- return join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'auth.json');
11
- }
12
- export function codexAccountId() {
13
- if (cache && Date.now() - cache.at < TTL_MS)
14
- return cache.id;
15
- const path = authPath();
16
- let raw;
17
- try {
18
- raw = readFileSync(path, 'utf8');
19
- }
20
- catch (e) {
21
- throw new Error(`metro: failed to read ${path} — is Codex logged in? (${e.message})`);
22
- }
23
- let parsed;
24
- try {
25
- parsed = JSON.parse(raw);
26
- }
27
- catch {
28
- throw new Error(`metro: ${path} is not valid JSON`);
29
- }
30
- const id = parsed.tokens?.account_id;
31
- if (!id) {
32
- throw new Error(`metro: no Codex account_id in ${path} (auth_mode=${parsed.auth_mode ?? 'unknown'}) — sign in with 'codex login' (ChatGPT mode required)`);
33
- }
34
- cache = { id, at: Date.now() };
35
- return id;
36
- }
37
- export function tryCodexAccountId() {
38
- try {
39
- return codexAccountId();
40
- }
41
- catch {
42
- return null;
43
- }
44
- }
45
- /** User-id for the line URI: `METRO_USER_ID` override, else the account id. */
46
- export function codexUserId() {
47
- return process.env.METRO_USER_ID || codexAccountId();
48
- }
49
- const SESSION_FILE = join(STATE_DIR, 'stations', 'codex', 'session-id');
50
- /** Session: codex-rc thread id (daemon persists; CLIs read state file). Override: `METRO_USER_SESSION_ID`. */
51
- export function codexSessionId() {
52
- if (process.env.METRO_USER_SESSION_ID)
53
- return process.env.METRO_USER_SESSION_ID;
54
- try {
55
- return readFileSync(SESSION_FILE, 'utf8').trim() || null;
56
- }
57
- catch {
58
- return null;
59
- }
60
- }
61
- /** Daemon-side: persist the rc thread id so CLI processes can read it. Best-effort. */
62
- export function setCodexSessionId(threadId) {
63
- try {
64
- mkdirSync(dirname(SESSION_FILE), { recursive: true });
65
- writeFileSync(SESSION_FILE, threadId ?? '');
66
- }
67
- catch { /* CLI just won't have a session segment */ }
68
- }
@@ -1,216 +0,0 @@
1
- /** Discord station: receive via discord.js gateway; send/edit/react/download/fetch via REST. */
2
- import { writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
5
- import { readFile } from 'node:fs/promises';
6
- import { basename } from 'node:path';
7
- import { errMsg, log } from '../log.js';
8
- import { mintId } from '../history.js';
9
- import { Line, } from './index.js';
10
- const API_BASE = 'https://discord.com/api/v10';
11
- const SUPPRESS_EMBEDS = 1 << 2;
12
- const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
13
- const token = () => {
14
- const t = process.env.DISCORD_BOT_TOKEN;
15
- if (!t)
16
- throw new Error('DISCORD_BOT_TOKEN is not set');
17
- return t;
18
- };
19
- async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
20
- const res = await fetch(`${API_BASE}${path}`, {
21
- method,
22
- headers: {
23
- 'Authorization': `Bot ${token()}`,
24
- 'User-Agent': 'metro (https://github.com/bonustrack/metro, dev)',
25
- ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
26
- },
27
- body: body !== undefined ? JSON.stringify(body) : undefined,
28
- signal: AbortSignal.timeout(timeoutMs),
29
- });
30
- if (res.status === 429 && retriesLeft > 0) {
31
- const retryAfter = Number(res.headers.get('retry-after')) || 1;
32
- log.debug({ path, retryAfter }, 'discord 429; backing off');
33
- await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, 250)));
34
- return rest(method, path, body, timeoutMs, retriesLeft - 1);
35
- }
36
- if (!res.ok) {
37
- const text = await res.text().catch(() => '');
38
- throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
39
- }
40
- return res.status === 204 ? undefined : (await res.json());
41
- }
42
- /** Multipart upload (payload_json + files[N]). Same retry semantics as `rest`. */
43
- async function restMultipart(method, path, payload, files) {
44
- const form = new FormData();
45
- form.append('payload_json', JSON.stringify(payload));
46
- for (const [i, f] of files.entries()) {
47
- form.append(`files[${i}]`, new Blob([new Uint8Array(f.data)]), basename(f.path));
48
- }
49
- const res = await fetch(`${API_BASE}${path}`, {
50
- method,
51
- headers: {
52
- 'Authorization': `Bot ${token()}`,
53
- 'User-Agent': 'metro (https://github.com/bonustrack/metro, dev)',
54
- },
55
- body: form, signal: AbortSignal.timeout(60_000),
56
- });
57
- if (!res.ok) {
58
- const t = await res.text().catch(() => '');
59
- throw new Error(`discord ${method} ${path}: ${res.status} ${t}`);
60
- }
61
- return (await res.json());
62
- }
63
- const collectFiles = async (opts) => {
64
- const paths = [...(opts?.images ?? []), ...(opts?.documents ?? []), ...(opts?.voice ? [opts.voice] : [])];
65
- return Promise.all(paths.map(async (p) => ({ path: p, data: await readFile(p) })));
66
- };
67
- /** Convert button rows to Discord component arrays (URL buttons only — style=5). */
68
- const discordButtons = (rows) => rows.map(row => ({
69
- type: 1, components: row.map(b => ({ type: 2, style: 5, label: b.text, url: b.url })),
70
- }));
71
- const channelOf = (line) => {
72
- const id = Line.parseDiscord(line);
73
- if (!id)
74
- throw new Error(`not a discord line: ${line}`);
75
- return id;
76
- };
77
- export class DiscordStation {
78
- name = 'discord';
79
- client = null;
80
- messageHandler = () => { };
81
- reactionHandler = () => { };
82
- onMessage(handler) {
83
- this.messageHandler = handler;
84
- }
85
- onReaction(handler) { this.reactionHandler = handler; }
86
- getClient() {
87
- return this.client ??= new Client({
88
- intents: [
89
- GatewayIntentBits.DirectMessages, GatewayIntentBits.Guilds,
90
- GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent,
91
- GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.DirectMessageReactions,
92
- ],
93
- partials: [Partials.Channel, Partials.Message, Partials.Reaction],
94
- });
95
- }
96
- async start() {
97
- const c = this.getClient();
98
- c.on(Events.MessageCreate, m => { void this.handleMessage(m); });
99
- c.on(Events.MessageReactionAdd, (r, u) => { void this.handleReaction(r, u); });
100
- c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
101
- await c.login(process.env.DISCORD_BOT_TOKEN);
102
- await new Promise(r => c.once(Events.ClientReady, () => r()));
103
- }
104
- async stop() {
105
- if (!this.client)
106
- return;
107
- await this.client.destroy();
108
- this.client = null;
109
- }
110
- async getMe() {
111
- return rest('GET', '/users/@me');
112
- }
113
- async send(line, text, opts) {
114
- const payload = { content: text, flags: SUPPRESS_EMBEDS };
115
- if (opts?.replyTo)
116
- payload.message_reference = { message_id: opts.replyTo };
117
- if (opts?.buttons?.length)
118
- payload.components = discordButtons(opts.buttons);
119
- const path = `/channels/${channelOf(line)}/messages`;
120
- const files = await collectFiles(opts);
121
- const sent = files.length
122
- ? await restMultipart('POST', path, payload, files)
123
- : await rest('POST', path, payload);
124
- return sent.id;
125
- }
126
- async edit(line, messageId, text, opts) {
127
- const payload = { content: text, flags: SUPPRESS_EMBEDS };
128
- payload.components = opts?.buttons?.length ? discordButtons(opts.buttons) : [];
129
- await rest('PATCH', `/channels/${channelOf(line)}/messages/${messageId}`, payload);
130
- }
131
- async react(line, messageId, emoji) {
132
- const ch = channelOf(line);
133
- if (!emoji) {
134
- await rest('DELETE', `/channels/${ch}/messages/${messageId}/reactions/@me`);
135
- return;
136
- }
137
- await rest('PUT', `/channels/${ch}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
138
- }
139
- async download(line, messageId, outDir) {
140
- const ch = channelOf(line);
141
- const msg = await rest('GET', `/channels/${ch}/messages/${messageId}`);
142
- const out = [];
143
- for (const [i, a] of (msg.attachments ?? []).entries()) {
144
- if (!a.content_type?.startsWith('image/'))
145
- continue;
146
- if (a.size > MAX_ATTACHMENT_BYTES) {
147
- log.warn({ size: a.size, name: a.filename }, 'discord: attachment too large; skipped');
148
- continue;
149
- }
150
- try {
151
- const res = await fetch(a.url, { signal: AbortSignal.timeout(30_000) });
152
- if (!res.ok)
153
- throw new Error(`status ${res.status}`);
154
- const buf = Buffer.from(await res.arrayBuffer());
155
- const path = join(outDir, `${messageId}-${i}-${a.filename}`);
156
- await writeFile(path, buf);
157
- out.push({ path, mediaType: a.content_type });
158
- }
159
- catch (err) {
160
- log.warn({ err: errMsg(err), url: a.url }, 'discord: attachment fetch failed');
161
- }
162
- }
163
- return out;
164
- }
165
- async fetch(line, limit) {
166
- const capped = Math.max(1, Math.min(100, limit | 0));
167
- const msgs = await rest('GET', `/channels/${channelOf(line)}/messages?limit=${capped}`);
168
- return [...msgs].reverse().map(m => ({
169
- messageId: m.id, author: m.author.username, text: m.content, timestamp: m.timestamp,
170
- }));
171
- }
172
- async handleReaction(r, u) {
173
- if (u.bot)
174
- return;
175
- const emoji = r.emoji.name;
176
- if (!emoji)
177
- return;
178
- const channelId = r.message.channelId;
179
- const messageId = r.message.id;
180
- const username = 'username' in u && u.username ? u.username : undefined;
181
- log.info({ from: username, channel: channelId, emoji, messageId }, 'discord: reaction');
182
- this.reactionHandler({
183
- id: mintId(), ts: new Date().toISOString(),
184
- station: 'discord', line: Line.discord(channelId),
185
- from: Line.user('discord', u.id), fromName: username,
186
- messageId, emoji, isPrivate: r.message.guildId === null,
187
- });
188
- }
189
- async handleMessage(m) {
190
- if (m.author.bot)
191
- return;
192
- const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
193
- : a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
194
- const text = [m.content.trim(), ...tags].filter(Boolean).join(' ');
195
- if (!text)
196
- return;
197
- log.info({ from: m.author.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
198
- const lineName = m.channel && 'name' in m.channel
199
- ? m.channel.name ?? undefined : undefined;
200
- const payload = m.toJSON();
201
- if (m.reference?.messageId) {
202
- try {
203
- payload.referencedMessage = (await m.fetchReference()).toJSON();
204
- }
205
- catch (err) {
206
- log.debug({ err: errMsg(err) }, 'discord: fetchReference failed');
207
- }
208
- }
209
- this.messageHandler({
210
- id: mintId(), ts: new Date(m.createdTimestamp).toISOString(),
211
- station: 'discord', line: Line.discord(m.channelId), lineName,
212
- from: Line.user('discord', m.author.id), fromName: m.author.username,
213
- messageId: m.id, text, payload, isPrivate: m.guildId === null,
214
- });
215
- }
216
- }