@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
package/dist/stations/discord.js
DELETED
|
@@ -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
|
-
}
|
package/dist/stations/index.js
DELETED
|
@@ -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 = { '&': '&', '<': '<', '>': '>' };
|
|
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)((?:>\s?[^\n]*\n?)+)/g, (_m, lead, block) => {
|
|
30
|
-
const inner = block.replace(/^>\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
|
-
}
|