@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
@@ -1,129 +0,0 @@
1
- /** Line URI scheme + ChatStation interface + station listing. The whole station surface. */
2
- import { tryClaudeAccountId } from './claude.js';
3
- import { tryCodexAccountId } from './codex.js';
4
- import { listUsers } from '../registry.js';
5
- import { listEndpoints, webhookPort } from '../webhooks.js';
6
- import { loadTunnelConfig } from '../tunnel.js';
7
- export const asLine = (s) => s;
8
- const PREFIX = 'metro://';
9
- const build = (station, ...seg) => asLine(`${PREFIX}${station}/${seg.map(String).join('/')}`);
10
- /** Shared parser for `metro://{claude,codex}/<userId>/<sessionId>`. Skips the `/user/…` participant URI. */
11
- function parseLocalSession(line, station) {
12
- const p = Line.parse(line);
13
- if (p?.station !== station || p.path[0] === 'user' || p.path.length < 2)
14
- return null;
15
- return { userId: p.path[0], sessionId: p.path[1] };
16
- }
17
- /** URI helpers. Lives on a const that doubles as the `Line` type's value-side namespace. */
18
- export const Line = {
19
- discord: (channelId) => build('discord', channelId),
20
- telegram: (chatId, topicId) => topicId !== undefined ? build('telegram', chatId, topicId) : build('telegram', chatId),
21
- /** `metro://claude/<orgId>/<sessionId>` — orgId from `claude auth status`, session from `CLAUDE_CODE_SESSION_ID`. */
22
- claude: (orgId, sessionId) => build('claude', orgId, sessionId),
23
- /** `metro://codex/<accountId>/<threadId>` — accountId from auth.json, thread from codex-rc handshake. */
24
- codex: (accountId, threadId) => build('codex', accountId, threadId),
25
- /** `metro://webhook/<endpoint-id>` — one HTTP receive endpoint, registered via `metro webhook add`. */
26
- webhook: (endpointId) => build('webhook', endpointId),
27
- /** Participant URI — `metro://<station>/user/<id>`. */
28
- user: (station, id) => build(station, 'user', id),
29
- parse(line) {
30
- if (!line.startsWith(PREFIX))
31
- return null;
32
- const rest = line.slice(PREFIX.length);
33
- const slash = rest.indexOf('/');
34
- if (slash <= 0)
35
- return null;
36
- const path = rest.slice(slash + 1).split('/').filter(Boolean);
37
- return path.length ? { station: rest.slice(0, slash), path } : null;
38
- },
39
- station: (line) => Line.parse(line)?.station ?? null,
40
- parseDiscord(line) {
41
- const p = Line.parse(line);
42
- return p?.station === 'discord' && p.path.length === 1 ? p.path[0] : null;
43
- },
44
- parseTelegram(line) {
45
- const p = Line.parse(line);
46
- if (p?.station !== 'telegram')
47
- return null;
48
- const chatId = Number(p.path[0]);
49
- if (!Number.isFinite(chatId))
50
- return null;
51
- if (p.path.length === 1)
52
- return { chatId };
53
- const topicId = Number(p.path[1]);
54
- return Number.isFinite(topicId) ? { chatId, topicId } : null;
55
- },
56
- parseClaude: (line) => parseLocalSession(line, 'claude'),
57
- parseCodex: (line) => parseLocalSession(line, 'codex'),
58
- parseWebhook(line) {
59
- const p = Line.parse(line);
60
- return p?.station === 'webhook' && p.path.length === 1 ? p.path[0] : null;
61
- },
62
- isLocal: (line) => {
63
- const s = Line.station(line);
64
- return s === 'claude' || s === 'codex';
65
- },
66
- };
67
- function seenSummary(station) {
68
- const users = listUsers(station);
69
- if (!users.length)
70
- return '';
71
- const sessions = users.reduce((n, u) => n + u.sessions.length, 0);
72
- return ` · seen ${users.length} user${users.length === 1 ? '' : 's'}, ${sessions} session${sessions === 1 ? '' : 's'}`;
73
- }
74
- function claudeStationDetail() {
75
- const seen = seenSummary('claude');
76
- if (!process.env.CLAUDECODE)
77
- return `launch metro from inside a Claude Code session${seen}`;
78
- const orgId = tryClaudeAccountId();
79
- return `${orgId ? `account: ${orgId}` : 'logged out — run `claude auth login`'}${seen}`;
80
- }
81
- function codexStationDetail() {
82
- const rc = process.env.METRO_CODEX_RC;
83
- const accountId = tryCodexAccountId();
84
- const seen = seenSummary('codex');
85
- const parts = [
86
- accountId ? `account: ${accountId}` : (rc ? '(no Codex account — run `codex login`)' : null),
87
- rc ? `push → ${rc}` : (!accountId ? 'set METRO_CODEX_RC=ws://… to push' : null),
88
- ].filter(Boolean);
89
- return `${parts.join(' · ')}${seen}`;
90
- }
91
- export const listStations = () => [
92
- {
93
- name: 'discord',
94
- capabilities: { in: ['text', 'image'], out: ['text'], features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'] },
95
- configured: !!process.env.DISCORD_BOT_TOKEN, detail: 'DISCORD_BOT_TOKEN',
96
- },
97
- {
98
- name: 'telegram',
99
- capabilities: { in: ['text', 'image'], out: ['text'], features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'] },
100
- configured: !!process.env.TELEGRAM_BOT_TOKEN, detail: 'TELEGRAM_BOT_TOKEN',
101
- },
102
- {
103
- name: 'claude',
104
- capabilities: { in: ['text'], out: ['text'], features: ['send'] },
105
- configured: !!process.env.CLAUDECODE,
106
- detail: claudeStationDetail(),
107
- },
108
- {
109
- name: 'codex',
110
- capabilities: { in: ['text'], out: ['text'], features: ['send'] },
111
- configured: !!(process.env.METRO_CODEX_RC || process.env.CODEX_HOME),
112
- detail: codexStationDetail(),
113
- },
114
- {
115
- name: 'webhook',
116
- capabilities: { in: ['text'], out: [], features: [] },
117
- configured: listEndpoints().length > 0,
118
- detail: webhookStationDetail(),
119
- },
120
- ];
121
- function webhookStationDetail() {
122
- const eps = listEndpoints();
123
- const t = loadTunnelConfig();
124
- const base = t ? `https://${t.hostname}` : `http://127.0.0.1:${webhookPort()}`;
125
- if (!eps.length)
126
- return `no endpoints (run \`metro webhook add <label>\`)${t ? ` · tunnel → ${t.hostname}` : ''}`;
127
- return `${eps.length} endpoint${eps.length === 1 ? '' : 's'} · base ${base}${t ? '' : ' (no tunnel — run `metro tunnel setup`)'}`;
128
- }
129
- export const fmtCapabilities = (c) => `in: ${c.in.join('+') || '–'} · out: ${c.out.join('+') || '–'} · features: ${c.features.join(', ') || '–'}`;
@@ -1,34 +0,0 @@
1
- /** Convert markdown → Telegram HTML. Stream-safe: unmatched markers fall through as literal text. */
2
- const ENTITY_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;' };
3
- const esc = (s) => s.replace(/[&<>]/g, c => ENTITY_MAP[c]);
4
- /** SOH — invalid in Telegram text + never in user output, so collisions are impossible. */
5
- const SENT = '\x01';
6
- export function mdToTelegramHtml(md) {
7
- const slots = [];
8
- const stash = (html) => { slots.push(html); return `${SENT}${slots.length - 1}${SENT}`; };
9
- /* Fenced code first so its contents aren't touched by other rules. */
10
- let work = md.replace(/```([A-Za-z0-9_+\-.]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
11
- return stash(lang
12
- ? `<pre><code class="language-${esc(lang)}">${esc(code)}</code></pre>`
13
- : `<pre>${esc(code)}</pre>`);
14
- });
15
- /* Inline code (single-line only — multi-line spans look like unclosed mid-stream fences). */
16
- work = work.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${esc(code)}</code>`));
17
- work = esc(work);
18
- work = work.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, text, url) => stash(`<a href="${url.replace(/"/g, '%22')}">${text}</a>`));
19
- /* Bold before italic so `*`-rule doesn't eat half of `**bold**`. */
20
- work = work.replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>').replace(/__([^_\n]+?)__/g, '<b>$1</b>');
21
- /* \S guards keep `2 * 3` arithmetic and `foo_bar` identifiers intact. */
22
- work = work
23
- .replace(/(^|[^*\w])\*(\S[^*\n]*?\S|\S)\*(?!\w)/g, '$1<i>$2</i>')
24
- .replace(/(^|[^_\w])_(\S[^_\n]*?\S|\S)_(?!\w)/g, '$1<i>$2</i>');
25
- work = work.replace(/~~([^~\n]+?)~~/g, '<s>$1</s>');
26
- /* Headings → bold (Telegram has no heading element). */
27
- work = work.replace(/^#{1,6}\s+(.+?)\s*$/gm, '<b>$1</b>');
28
- /* Collapse consecutive `> ` lines into one <blockquote>. */
29
- work = work.replace(/(^|\n)((?:&gt;\s?[^\n]*\n?)+)/g, (_m, lead, block) => {
30
- const inner = block.replace(/^&gt;\s?/gm, '').replace(/\n+$/, '');
31
- return `${lead}<blockquote>${inner}</blockquote>${block.endsWith('\n') ? '\n' : ''}`;
32
- });
33
- return work.replace(new RegExp(`${SENT}(\\d+)${SENT}`, 'g'), (_m, idx) => slots[Number(idx)]);
34
- }
@@ -1,113 +0,0 @@
1
- /** Telegram outgoing sends — single multipart, media groups, and the rich-content dispatch. */
2
- import { readFile } from 'node:fs/promises';
3
- import { basename } from 'node:path';
4
- import { log } from '../log.js';
5
- import { mdToTelegramHtml } from './telegram-md.js';
6
- const API_BASE = 'https://api.telegram.org';
7
- const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
8
- export const inlineKeyboard = (rows) => ({ inline_keyboard: rows });
9
- async function post(token, method, form) {
10
- const res = await fetch(`${API_BASE}/bot${token}/${method}`, {
11
- method: 'POST', body: form, signal: AbortSignal.timeout(60_000),
12
- });
13
- const json = (await res.json());
14
- if (!json.ok)
15
- throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
16
- return json.result;
17
- }
18
- function appendFields(form, fields) {
19
- for (const [k, v] of Object.entries(fields)) {
20
- if (v === undefined || v === null)
21
- continue;
22
- form.append(k, typeof v === 'string' ? v : JSON.stringify(v));
23
- }
24
- }
25
- async function appendFile(form, field, filePath) {
26
- form.append(field, new Blob([new Uint8Array(await readFile(filePath))]), basename(filePath));
27
- }
28
- const isParseError = (err) => err instanceof Error && err.message.includes("can't parse entities");
29
- /** sendPhoto / sendDocument / sendVoice with HTML-caption + plain fallback. */
30
- async function sendOne(token, base, method, fileField, filePath, caption) {
31
- const attempt = async (html) => {
32
- const form = new FormData();
33
- appendFields(form, {
34
- ...base,
35
- caption: html ? mdToTelegramHtml(caption) : caption,
36
- ...(html ? { parse_mode: 'HTML' } : {}),
37
- });
38
- await appendFile(form, fileField, filePath);
39
- return post(token, method, form);
40
- };
41
- try {
42
- return String((await attempt(true)).message_id);
43
- }
44
- catch (err) {
45
- if (!isParseError(err))
46
- throw err;
47
- log.warn('telegram: HTML rejected, retrying plain');
48
- return String((await attempt(false)).message_id);
49
- }
50
- }
51
- /** sendMediaGroup — 2-10 same-kind files. Caption goes on the first item only. */
52
- async function sendGroup(token, base, kind, paths, caption) {
53
- const form = new FormData();
54
- appendFields(form, base);
55
- const media = await Promise.all(paths.map(async (p, i) => {
56
- const field = `m${i}`;
57
- await appendFile(form, field, p);
58
- return {
59
- type: kind, media: `attach://${field}`,
60
- ...(i === 0 && caption ? { caption: mdToTelegramHtml(caption), parse_mode: 'HTML' } : {}),
61
- };
62
- }));
63
- form.append('media', JSON.stringify(media));
64
- const sent = await post(token, 'sendMediaGroup', form);
65
- return String(sent[0].message_id);
66
- }
67
- /** Plain text via sendMessage with HTML + plain fallback. */
68
- async function sendText(send, base, text) {
69
- const attempt = (html) => send('sendMessage', html ? { ...base, ...NO_PREVIEW, text: mdToTelegramHtml(text), parse_mode: 'HTML' }
70
- : { ...base, ...NO_PREVIEW, text });
71
- try {
72
- return String((await attempt(true)).message_id);
73
- }
74
- catch (err) {
75
- if (!isParseError(err))
76
- throw err;
77
- log.warn('telegram: HTML rejected, sending plain');
78
- return String((await attempt(false)).message_id);
79
- }
80
- }
81
- /** Dispatch for text + images + documents + voice + buttons. Returns the first message id. */
82
- export async function tgSendRich(token, send, base, text, opts) {
83
- const images = opts?.images ?? [], docs = opts?.documents ?? [], voice = opts?.voice;
84
- const total = images.length + docs.length + (voice ? 1 : 0);
85
- const baseWithButtons = opts?.buttons?.length
86
- ? { ...base, reply_markup: inlineKeyboard(opts.buttons) } : base;
87
- if (total === 0)
88
- return sendText(send, baseWithButtons, text);
89
- if (total === 1) {
90
- if (voice)
91
- return sendOne(token, baseWithButtons, 'sendVoice', 'voice', voice, text);
92
- if (images.length)
93
- return sendOne(token, baseWithButtons, 'sendPhoto', 'photo', images[0], text);
94
- return sendOne(token, baseWithButtons, 'sendDocument', 'document', docs[0], text);
95
- }
96
- /* Multi-attachment: media groups don't support reply_markup — buttons are dropped. */
97
- if (opts?.buttons?.length)
98
- log.warn('telegram: buttons dropped on multi-attachment send');
99
- let first;
100
- let caption = text;
101
- const claim = async (id) => { first ??= id; caption = ''; };
102
- if (voice)
103
- await claim(await sendOne(token, base, 'sendVoice', 'voice', voice, caption));
104
- if (images.length === 1)
105
- await claim(await sendOne(token, base, 'sendPhoto', 'photo', images[0], caption));
106
- else if (images.length)
107
- await claim(await sendGroup(token, base, 'photo', images, caption));
108
- if (docs.length === 1)
109
- await claim(await sendOne(token, base, 'sendDocument', 'document', docs[0], caption));
110
- else if (docs.length)
111
- await claim(await sendGroup(token, base, 'document', docs, caption));
112
- return first;
113
- }
@@ -1,234 +0,0 @@
1
- /** Telegram station: long-poll Bot API; sends markdown as HTML with plain-text fallback. */
2
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
- import { writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { errMsg, log } from '../log.js';
6
- import { mintId } from '../history.js';
7
- import { mdToTelegramHtml } from './telegram-md.js';
8
- import { inlineKeyboard, tgSendRich } from './telegram-upload.js';
9
- import { Line, } from './index.js';
10
- import { STATE_DIR } from '../paths.js';
11
- const API_BASE = 'https://api.telegram.org';
12
- const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
13
- const MAX_BYTES = 20 * 1024 * 1024;
14
- const token = () => {
15
- const t = process.env.TELEGRAM_BOT_TOKEN;
16
- if (!t)
17
- throw new Error('TELEGRAM_BOT_TOKEN is not set');
18
- return t;
19
- };
20
- async function tg(method, body, opts = {}) {
21
- const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
22
- if (opts.signal)
23
- signals.push(opts.signal);
24
- const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
25
- method: 'POST', headers: { 'Content-Type': 'application/json' },
26
- body: JSON.stringify(body), signal: AbortSignal.any(signals),
27
- });
28
- const json = (await res.json());
29
- if (!json.ok)
30
- throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
31
- return json.result;
32
- }
33
- const isParseError = (err) => errMsg(err).includes("can't parse entities");
34
- const isNoopEdit = (err) => errMsg(err).includes('message is not modified');
35
- const targetOf = (line) => {
36
- const t = Line.parseTelegram(line);
37
- if (!t)
38
- throw new Error(`not a telegram line: ${line}`);
39
- return t;
40
- };
41
- function attachmentTags(m) {
42
- const out = [];
43
- if (m.photo?.length)
44
- out.push('[image]');
45
- if (m.document?.mime_type?.startsWith('image/'))
46
- out.push('[image]');
47
- else if (m.document)
48
- out.push(`[file: ${m.document.file_name ?? m.document.file_id}]`);
49
- if (m.voice)
50
- out.push('[voice]');
51
- if (m.audio)
52
- out.push('[audio]');
53
- return out;
54
- }
55
- export class TelegramStation {
56
- name = 'telegram';
57
- pollOffset = 0;
58
- pollAbort = null;
59
- messageHandler = () => { };
60
- reactionHandler = () => { };
61
- offsetFile = join(STATE_DIR, 'telegram-offset.json');
62
- /** Snapshot recent inbounds in memory so `metro download <line> <id>` can resolve them. */
63
- recent = new Map();
64
- onMessage(handler) { this.messageHandler = handler; }
65
- onReaction(handler) { this.reactionHandler = handler; }
66
- async start() {
67
- await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
68
- const persisted = Number(existsSync(this.offsetFile) ? readFileSync(this.offsetFile, 'utf8').trim() : 0) || 0;
69
- if (persisted > 0)
70
- this.pollOffset = persisted;
71
- else {
72
- /* First run: anchor on latest update id (-1 returns most recent without consuming). */
73
- const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
74
- this.pollOffset = initial.length ? initial[0].update_id + 1 : 0;
75
- this.saveOffset();
76
- }
77
- log.info({ offset: this.pollOffset }, 'telegram station: polling started');
78
- this.pollAbort = new AbortController();
79
- void this.pollLoop();
80
- }
81
- async stop() { this.pollAbort?.abort(); this.pollAbort = null; }
82
- async getMe() {
83
- return tg('getMe', {});
84
- }
85
- async send(line, text, opts) {
86
- const { chatId, topicId } = targetOf(line);
87
- const base = { chat_id: chatId };
88
- if (topicId !== undefined)
89
- base.message_thread_id = topicId;
90
- if (opts?.replyTo)
91
- base.reply_parameters = { message_id: Number(opts.replyTo) };
92
- return tgSendRich(token(), tg, base, text, opts);
93
- }
94
- async edit(line, messageId, text, opts) {
95
- const { chatId } = targetOf(line);
96
- const base = { chat_id: chatId, message_id: Number(messageId), ...NO_PREVIEW };
97
- if (opts?.buttons)
98
- base.reply_markup = opts.buttons.length ? inlineKeyboard(opts.buttons) : { inline_keyboard: [] };
99
- try {
100
- await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
101
- }
102
- catch (err) {
103
- if (isNoopEdit(err))
104
- return;
105
- if (!isParseError(err))
106
- throw err;
107
- log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
108
- try {
109
- await tg('editMessageText', { ...base, text });
110
- }
111
- catch (e) {
112
- if (!isNoopEdit(e))
113
- throw e;
114
- }
115
- }
116
- }
117
- async react(line, messageId, emoji) {
118
- await tg('setMessageReaction', {
119
- chat_id: targetOf(line).chatId, message_id: Number(messageId),
120
- reaction: emoji ? [{ type: 'emoji', emoji }] : [],
121
- });
122
- }
123
- async download(line, messageId, outDir) {
124
- /* Telegram has no "get message by id" — resolve from the in-memory snapshot. */
125
- const { chatId } = targetOf(line);
126
- const m = this.recent.get(`${chatId}:${messageId}`);
127
- if (!m)
128
- return [];
129
- const refs = [];
130
- if (m.photo?.length)
131
- refs.push({ id: m.photo[m.photo.length - 1].file_id, mime: 'image/jpeg' });
132
- if (m.document?.mime_type?.startsWith('image/'))
133
- refs.push({ id: m.document.file_id, mime: m.document.mime_type });
134
- const out = [];
135
- for (const [i, { id, mime }] of refs.entries()) {
136
- try {
137
- const file = await tg('getFile', { file_id: id });
138
- const res = await fetch(`${API_BASE}/file/bot${token()}/${file.file_path}`, { signal: AbortSignal.timeout(30_000) });
139
- if (!res.ok)
140
- throw new Error(`status ${res.status}`);
141
- const buf = Buffer.from(await res.arrayBuffer());
142
- if (buf.byteLength > MAX_BYTES) {
143
- log.warn({ id, size: buf.byteLength }, 'telegram: attachment too large');
144
- continue;
145
- }
146
- const path = join(outDir, `${chatId}-${messageId}-${i}.${mime.split('/')[1] ?? 'bin'}`);
147
- await writeFile(path, buf);
148
- out.push({ path, mediaType: mime });
149
- }
150
- catch (err) {
151
- log.warn({ err: errMsg(err), id }, 'telegram: attachment fetch failed');
152
- }
153
- }
154
- return out;
155
- }
156
- /** Bot API has no history endpoint — only the in-memory snapshot is reachable. */
157
- async fetch() { return []; }
158
- saveOffset() {
159
- try {
160
- writeFileSync(this.offsetFile, String(this.pollOffset));
161
- }
162
- catch (err) {
163
- log.warn({ err: errMsg(err) }, 'telegram offset save failed');
164
- }
165
- }
166
- dispatchReaction(r) {
167
- if (!r.user || r.user.is_bot)
168
- return;
169
- const emojis = (xs) => xs.filter((x) => x.type === 'emoji').map(x => x.emoji);
170
- const had = new Set(emojis(r.old_reaction));
171
- const added = emojis(r.new_reaction).filter(e => !had.has(e));
172
- if (!added.length)
173
- return;
174
- const fromName = r.user.username ? `@${r.user.username}` : r.user.first_name;
175
- log.info({ from: fromName, chat: r.chat.id, emojis: added }, 'telegram: reaction');
176
- const ts = new Date((r.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString();
177
- for (const emoji of added)
178
- this.reactionHandler({
179
- id: mintId(), ts, station: 'telegram', line: Line.telegram(r.chat.id),
180
- lineName: r.chat.title ?? r.chat.first_name ?? undefined,
181
- from: Line.user('telegram', r.user.id), fromName,
182
- messageId: String(r.message_id), emoji, isPrivate: r.chat.type === 'private',
183
- });
184
- }
185
- async pollLoop() {
186
- const signal = this.pollAbort?.signal;
187
- const body = { timeout: 25, allowed_updates: ['message', 'message_reaction'] };
188
- while (this.pollAbort && !this.pollAbort.signal.aborted) {
189
- try {
190
- const updates = await tg('getUpdates', { offset: this.pollOffset, ...body }, { timeoutMs: 60_000, signal });
191
- for (const u of updates) {
192
- this.pollOffset = u.update_id + 1;
193
- this.dispatchUpdate(u);
194
- }
195
- if (updates.length)
196
- this.saveOffset();
197
- }
198
- catch (err) {
199
- if (this.pollAbort?.signal.aborted)
200
- break;
201
- log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
202
- await new Promise(r => setTimeout(r, 2000));
203
- }
204
- }
205
- }
206
- dispatchUpdate(u) {
207
- if (u.message_reaction) {
208
- this.dispatchReaction(u.message_reaction);
209
- return;
210
- }
211
- const m = u.message;
212
- if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
213
- return;
214
- const text = [m.text ?? m.caption, ...attachmentTags(m)].filter(Boolean).join(' ');
215
- if (!text)
216
- return;
217
- const topicId = m.is_topic_message ? m.message_thread_id : undefined;
218
- const fromName = m.from?.username ? `@${m.from.username}` : m.from?.first_name;
219
- log.info({ from: fromName, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
220
- if (this.recent.size >= 50) {
221
- const first = this.recent.keys().next().value;
222
- if (first)
223
- this.recent.delete(first);
224
- }
225
- this.recent.set(`${m.chat.id}:${m.message_id}`, m);
226
- this.messageHandler({
227
- id: mintId(), ts: new Date((m.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
228
- station: 'telegram', line: Line.telegram(m.chat.id, topicId), messageId: String(m.message_id),
229
- lineName: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
230
- from: Line.user('telegram', m.from?.id ?? 'unknown'), fromName, text, payload: m,
231
- isPrivate: m.chat.type === 'private',
232
- });
233
- }
234
- }
@@ -1,103 +0,0 @@
1
- /** Receive-only HTTP station. Each registered endpoint = one path `/wh/<id>` → InboundMessage on stdout. */
2
- import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
3
- import { createServer } from 'node:http';
4
- import { errMsg, log } from '../log.js';
5
- import { mintId } from '../history.js';
6
- import { handleMonitorRequest } from '../monitor.js';
7
- import { findEndpoint, listEndpoints, webhookPort } from '../webhooks.js';
8
- import { Line } from './index.js';
9
- /** Synthesize an `event` tag from common provider-specific headers (GitHub, Intercom). */
10
- function pickEvent(headers) {
11
- return headers['x-github-event'] ?? headers['x-intercom-topic'] ?? 'event';
12
- }
13
- /** Constant-time signature check against `X-Hub-Signature-256: sha256=<hex>` (GitHub/Intercom style). */
14
- function verifySig(secret, raw, header) {
15
- if (!header?.startsWith('sha256='))
16
- return false;
17
- const given = Buffer.from(header.slice(7), 'hex');
18
- const want = createHmac('sha256', secret).update(raw).digest();
19
- return given.length === want.length && timingSafeEqual(given, want);
20
- }
21
- async function readBody(req) {
22
- const chunks = [];
23
- for await (const c of req)
24
- chunks.push(c);
25
- return Buffer.concat(chunks);
26
- }
27
- export class WebhookStation {
28
- name = 'webhook';
29
- server = null;
30
- handler = null;
31
- onMessage(h) { this.handler = h; }
32
- start() {
33
- const port = webhookPort();
34
- return new Promise((resolve, reject) => {
35
- this.server = createServer((req, res) => this.handle(req, res).catch(err => {
36
- log.warn({ err: errMsg(err) }, 'webhook handler error');
37
- if (!res.headersSent)
38
- res.writeHead(500).end();
39
- }));
40
- this.server.on('error', reject);
41
- this.server.listen(port, '127.0.0.1', () => {
42
- log.info({ port, endpoints: listEndpoints().length }, 'webhook station ready');
43
- resolve();
44
- });
45
- });
46
- }
47
- async stop() {
48
- const srv = this.server;
49
- if (!srv)
50
- return;
51
- await new Promise(resolve => srv.close(() => resolve()));
52
- this.server = null;
53
- }
54
- async handle(req, res) {
55
- /** Read-only monitor endpoints (`/api/state`, `/api/tail`) share this port. */
56
- if (handleMonitorRequest(req, res))
57
- return;
58
- const m = req.url?.match(/^\/wh\/([A-Za-z0-9_-]+)/);
59
- if (!m) {
60
- res.writeHead(404).end();
61
- return;
62
- }
63
- const endpointId = m[1];
64
- const endpoint = findEndpoint(endpointId);
65
- if (!endpoint) {
66
- res.writeHead(404).end();
67
- return;
68
- }
69
- if (req.method === 'GET') {
70
- res.writeHead(200).end(`metro webhook ${endpointId} ready\n`);
71
- return;
72
- }
73
- if (req.method !== 'POST') {
74
- res.writeHead(405).end();
75
- return;
76
- }
77
- const raw = await readBody(req);
78
- const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v ?? '']));
79
- if (endpoint.secret && !verifySig(endpoint.secret, raw, headers['x-hub-signature-256'])) {
80
- log.warn({ endpoint: endpointId }, 'webhook signature mismatch — rejecting');
81
- res.writeHead(401).end('signature mismatch');
82
- return;
83
- }
84
- let body = raw.toString('utf8');
85
- try {
86
- body = JSON.parse(body);
87
- }
88
- catch { /* keep as string */ }
89
- const line = Line.webhook(endpointId);
90
- this.handler?.({
91
- id: mintId(),
92
- ts: new Date().toISOString(),
93
- station: 'webhook',
94
- line,
95
- lineName: endpoint.label,
96
- from: line,
97
- messageId: headers['x-github-delivery'] || headers['x-request-id'] || randomUUID(),
98
- text: `${pickEvent(headers)} ${req.method} ${req.url}`,
99
- payload: { headers, body },
100
- });
101
- res.writeHead(200).end('ok');
102
- }
103
- }
package/dist/webhooks.js DELETED
@@ -1,41 +0,0 @@
1
- /** Webhook endpoint registry: persists `(id, label, secret?)` for each receive endpoint. */
2
- import { randomBytes } from 'node:crypto';
3
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { STATE_DIR } from './paths.js';
6
- const FILE = join(STATE_DIR, 'webhooks.json');
7
- /** Local listener port — `127.0.0.1` only; expose publicly via Cloudflare tunnel. */
8
- export const webhookPort = () => Number(process.env.METRO_WEBHOOK_PORT) || 8420;
9
- function read() {
10
- if (!existsSync(FILE))
11
- return { endpoints: [] };
12
- try {
13
- return JSON.parse(readFileSync(FILE, 'utf8'));
14
- }
15
- catch {
16
- return { endpoints: [] };
17
- }
18
- }
19
- function write(s) { writeFileSync(FILE, JSON.stringify(s, null, 2)); }
20
- export const listEndpoints = () => read().endpoints;
21
- export const findEndpoint = (id) => read().endpoints.find(e => e.id === id);
22
- export function addEndpoint(label, secret) {
23
- const s = read();
24
- /** 16-char URL-safe id (~96 bits — collision-proof for any reasonable count). */
25
- const ep = {
26
- id: randomBytes(12).toString('base64url'), label, createdAt: new Date().toISOString(),
27
- ...(secret ? { secret } : {}),
28
- };
29
- s.endpoints.push(ep);
30
- write(s);
31
- return ep;
32
- }
33
- export function removeEndpoint(id) {
34
- const s = read();
35
- const before = s.endpoints.length;
36
- s.endpoints = s.endpoints.filter(e => e.id !== id);
37
- if (s.endpoints.length === before)
38
- return false;
39
- write(s);
40
- return true;
41
- }