@stage-labs/metro 0.1.0-beta.4 → 0.1.0-beta.6
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 +138 -63
- package/dist/cache.js +74 -0
- package/dist/cli/actions.js +156 -0
- package/dist/cli/config.js +184 -0
- package/dist/cli/index.js +162 -0
- package/dist/cli/skill.js +62 -0
- package/dist/cli/util.js +72 -0
- package/dist/codex-rc.js +236 -0
- package/dist/dispatcher.js +112 -0
- package/dist/history.js +84 -0
- package/dist/ipc.js +71 -0
- package/dist/log.js +7 -2
- package/dist/stations/discord.js +198 -0
- package/dist/stations/index.js +73 -0
- package/dist/{helpers/telegram-format.js → stations/telegram-md.js} +9 -14
- package/dist/stations/telegram-upload.js +113 -0
- package/dist/stations/telegram.js +235 -0
- package/docs/agents.md +168 -0
- package/docs/uri-scheme.md +71 -0
- package/package.json +6 -3
- package/skills/metro/SKILL.md +220 -0
- package/dist/agents/claude.js +0 -207
- package/dist/agents/codex.js +0 -207
- package/dist/agents/types.js +0 -2
- package/dist/channels/discord.js +0 -104
- package/dist/channels/telegram.js +0 -189
- package/dist/cli.js +0 -221
- package/dist/helpers/scope-cache.js +0 -65
- package/dist/helpers/streaming.js +0 -209
- package/dist/helpers/turn.js +0 -40
- package/dist/orchestrator.js +0 -208
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
const CAPS = {
|
|
78
|
+
in: ['text', 'image'], out: ['text'],
|
|
79
|
+
features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'],
|
|
80
|
+
};
|
|
81
|
+
export class DiscordStation {
|
|
82
|
+
name = 'discord';
|
|
83
|
+
capabilities = CAPS;
|
|
84
|
+
client = null;
|
|
85
|
+
messageHandler = () => { };
|
|
86
|
+
onMessage(handler) {
|
|
87
|
+
this.messageHandler = handler;
|
|
88
|
+
}
|
|
89
|
+
getClient() {
|
|
90
|
+
return this.client ??= new Client({
|
|
91
|
+
intents: [
|
|
92
|
+
GatewayIntentBits.DirectMessages, GatewayIntentBits.Guilds,
|
|
93
|
+
GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent,
|
|
94
|
+
],
|
|
95
|
+
partials: [Partials.Channel],
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async start() {
|
|
99
|
+
const c = this.getClient();
|
|
100
|
+
c.on(Events.MessageCreate, m => { void this.handleMessage(m, c); });
|
|
101
|
+
c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
|
|
102
|
+
await c.login(process.env.DISCORD_BOT_TOKEN);
|
|
103
|
+
await new Promise(r => c.once(Events.ClientReady, () => r()));
|
|
104
|
+
}
|
|
105
|
+
async stop() {
|
|
106
|
+
if (!this.client)
|
|
107
|
+
return;
|
|
108
|
+
await this.client.destroy();
|
|
109
|
+
this.client = null;
|
|
110
|
+
}
|
|
111
|
+
async getMe() {
|
|
112
|
+
return rest('GET', '/users/@me');
|
|
113
|
+
}
|
|
114
|
+
async send(line, text, opts) {
|
|
115
|
+
const payload = { content: text, flags: SUPPRESS_EMBEDS };
|
|
116
|
+
if (opts?.replyTo)
|
|
117
|
+
payload.message_reference = { message_id: opts.replyTo };
|
|
118
|
+
if (opts?.buttons?.length)
|
|
119
|
+
payload.components = discordButtons(opts.buttons);
|
|
120
|
+
const path = `/channels/${channelOf(line)}/messages`;
|
|
121
|
+
const files = await collectFiles(opts);
|
|
122
|
+
const sent = files.length
|
|
123
|
+
? await restMultipart('POST', path, payload, files)
|
|
124
|
+
: await rest('POST', path, payload);
|
|
125
|
+
return sent.id;
|
|
126
|
+
}
|
|
127
|
+
async edit(line, messageId, text, opts) {
|
|
128
|
+
const payload = { content: text, flags: SUPPRESS_EMBEDS };
|
|
129
|
+
payload.components = opts?.buttons?.length ? discordButtons(opts.buttons) : [];
|
|
130
|
+
await rest('PATCH', `/channels/${channelOf(line)}/messages/${messageId}`, payload);
|
|
131
|
+
}
|
|
132
|
+
async react(line, messageId, emoji) {
|
|
133
|
+
const ch = channelOf(line);
|
|
134
|
+
if (!emoji) {
|
|
135
|
+
await rest('DELETE', `/channels/${ch}/messages/${messageId}/reactions/@me`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await rest('PUT', `/channels/${ch}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
|
|
139
|
+
}
|
|
140
|
+
async download(line, messageId, outDir) {
|
|
141
|
+
const ch = channelOf(line);
|
|
142
|
+
const msg = await rest('GET', `/channels/${ch}/messages/${messageId}`);
|
|
143
|
+
const out = [];
|
|
144
|
+
for (const [i, a] of (msg.attachments ?? []).entries()) {
|
|
145
|
+
if (!a.content_type?.startsWith('image/'))
|
|
146
|
+
continue;
|
|
147
|
+
if (a.size > MAX_ATTACHMENT_BYTES) {
|
|
148
|
+
log.warn({ size: a.size, name: a.filename }, 'discord: attachment too large; skipped');
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(a.url, { signal: AbortSignal.timeout(30_000) });
|
|
153
|
+
if (!res.ok)
|
|
154
|
+
throw new Error(`status ${res.status}`);
|
|
155
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
156
|
+
const path = join(outDir, `${messageId}-${i}-${a.filename}`);
|
|
157
|
+
await writeFile(path, buf);
|
|
158
|
+
out.push({ path, mediaType: a.content_type });
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
log.warn({ err: errMsg(err), url: a.url }, 'discord: attachment fetch failed');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
async fetch(line, limit) {
|
|
167
|
+
const capped = Math.max(1, Math.min(100, limit | 0));
|
|
168
|
+
const msgs = await rest('GET', `/channels/${channelOf(line)}/messages?limit=${capped}`);
|
|
169
|
+
return [...msgs].reverse().map(m => ({
|
|
170
|
+
messageId: m.id, author: m.author.username, text: m.content, timestamp: m.timestamp,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
async handleMessage(m, c) {
|
|
174
|
+
if (m.author.bot)
|
|
175
|
+
return;
|
|
176
|
+
const attachmentNames = [];
|
|
177
|
+
for (const a of m.attachments.values()) {
|
|
178
|
+
if (a.contentType?.startsWith('image/'))
|
|
179
|
+
attachmentNames.push('[image]');
|
|
180
|
+
else
|
|
181
|
+
attachmentNames.push(a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]` : `[file: ${a.name}]`);
|
|
182
|
+
}
|
|
183
|
+
const text = m.content.trim();
|
|
184
|
+
if (!text && !attachmentNames.length)
|
|
185
|
+
return;
|
|
186
|
+
log.info({ from: m.author.username, bot: c.user?.username, channel: m.channelId, text: text.slice(0, 80) }, 'discord: inbound');
|
|
187
|
+
const lineName = m.channel && 'name' in m.channel
|
|
188
|
+
? m.channel.name ?? undefined : undefined;
|
|
189
|
+
this.messageHandler({
|
|
190
|
+
id: mintId(), ts: new Date(m.createdTimestamp).toISOString(),
|
|
191
|
+
station: 'discord', line: Line.discord(m.channelId), lineName,
|
|
192
|
+
from: Line.user('discord', m.author.id), fromName: m.author.username,
|
|
193
|
+
messageId: m.id, text, attachmentNames,
|
|
194
|
+
mentionsBot: !m.guildId || (c.user ? m.mentions.has(c.user.id) : false),
|
|
195
|
+
meta: { inGuild: !!m.guildId },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Line URI scheme + ChatStation interface + station listing. The whole station surface. */
|
|
2
|
+
export const asLine = (s) => s;
|
|
3
|
+
const PREFIX = 'metro://';
|
|
4
|
+
const build = (station, ...seg) => asLine(`${PREFIX}${station}/${seg.map(String).join('/')}`);
|
|
5
|
+
/** URI helpers. Lives on a const that doubles as the `Line` type's value-side namespace. */
|
|
6
|
+
export const Line = {
|
|
7
|
+
discord: (channelId) => build('discord', channelId),
|
|
8
|
+
telegram: (chatId, topicId) => topicId !== undefined ? build('telegram', chatId, topicId) : build('telegram', chatId),
|
|
9
|
+
claude: (topic) => build('claude', topic),
|
|
10
|
+
codex: (topic) => build('codex', topic),
|
|
11
|
+
/** Participant URIs — `metro://<station>/user/<id>` and `metro://<station>/bot/<id>`. */
|
|
12
|
+
user: (station, id) => build(station, 'user', id),
|
|
13
|
+
bot: (station, id) => build(station, 'bot', id),
|
|
14
|
+
parse(line) {
|
|
15
|
+
if (!line.startsWith(PREFIX))
|
|
16
|
+
return null;
|
|
17
|
+
const rest = line.slice(PREFIX.length);
|
|
18
|
+
const slash = rest.indexOf('/');
|
|
19
|
+
if (slash <= 0)
|
|
20
|
+
return null;
|
|
21
|
+
const path = rest.slice(slash + 1).split('/').filter(Boolean);
|
|
22
|
+
return path.length ? { station: rest.slice(0, slash), path } : null;
|
|
23
|
+
},
|
|
24
|
+
station: (line) => Line.parse(line)?.station ?? null,
|
|
25
|
+
parseDiscord(line) {
|
|
26
|
+
const p = Line.parse(line);
|
|
27
|
+
return p?.station === 'discord' && p.path.length === 1 ? p.path[0] : null;
|
|
28
|
+
},
|
|
29
|
+
parseTelegram(line) {
|
|
30
|
+
const p = Line.parse(line);
|
|
31
|
+
if (p?.station !== 'telegram')
|
|
32
|
+
return null;
|
|
33
|
+
const chatId = Number(p.path[0]);
|
|
34
|
+
if (!Number.isFinite(chatId))
|
|
35
|
+
return null;
|
|
36
|
+
if (p.path.length === 1)
|
|
37
|
+
return { chatId };
|
|
38
|
+
const topicId = Number(p.path[1]);
|
|
39
|
+
return Number.isFinite(topicId) ? { chatId, topicId } : null;
|
|
40
|
+
},
|
|
41
|
+
isAgent: (line) => {
|
|
42
|
+
const s = Line.station(line);
|
|
43
|
+
return s === 'claude' || s === 'codex';
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const AGENT_CAPS = { in: ['text'], out: [], features: ['notify'] };
|
|
47
|
+
const CHAT_CAPS = {
|
|
48
|
+
in: ['text', 'image'],
|
|
49
|
+
out: ['text'],
|
|
50
|
+
features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'],
|
|
51
|
+
};
|
|
52
|
+
export const listStations = () => [
|
|
53
|
+
{
|
|
54
|
+
name: 'discord', kind: 'chat', capabilities: CHAT_CAPS,
|
|
55
|
+
configured: !!process.env.DISCORD_BOT_TOKEN, detail: 'DISCORD_BOT_TOKEN',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'telegram', kind: 'chat', capabilities: CHAT_CAPS,
|
|
59
|
+
configured: !!process.env.TELEGRAM_BOT_TOKEN, detail: 'TELEGRAM_BOT_TOKEN',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'claude', kind: 'agent', capabilities: AGENT_CAPS,
|
|
63
|
+
configured: null, detail: 'stdout stream (watch via Claude Code Monitor)',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'codex', kind: 'agent', capabilities: AGENT_CAPS,
|
|
67
|
+
configured: process.env.METRO_CODEX_RC ? true : null,
|
|
68
|
+
detail: process.env.METRO_CODEX_RC
|
|
69
|
+
? `push → ${process.env.METRO_CODEX_RC}`
|
|
70
|
+
: 'set METRO_CODEX_RC=ws://… to push',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
export const fmtCapabilities = (c) => `in: ${c.in.join('+') || '–'} · out: ${c.out.join('+') || '–'} · features: ${c.features.join(', ') || '–'}`;
|
|
@@ -5,32 +5,27 @@ const esc = (s) => s.replace(/[&<>]/g, c => ENTITY_MAP[c]);
|
|
|
5
5
|
const SENT = '\x01';
|
|
6
6
|
export function mdToTelegramHtml(md) {
|
|
7
7
|
const slots = [];
|
|
8
|
-
const stash = (html) => {
|
|
9
|
-
|
|
10
|
-
return `${SENT}${slots.length - 1}${SENT}`;
|
|
11
|
-
};
|
|
12
|
-
/** Fenced code first so its contents aren't touched by other rules. */
|
|
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. */
|
|
13
10
|
let work = md.replace(/```([A-Za-z0-9_+\-.]*)\n?([\s\S]*?)```/g, (_m, lang, code) => {
|
|
14
|
-
|
|
11
|
+
return stash(lang
|
|
15
12
|
? `<pre><code class="language-${esc(lang)}">${esc(code)}</code></pre>`
|
|
16
|
-
: `<pre>${esc(code)}</pre
|
|
17
|
-
return stash(inner);
|
|
13
|
+
: `<pre>${esc(code)}</pre>`);
|
|
18
14
|
});
|
|
19
|
-
|
|
15
|
+
/* Inline code (single-line only — multi-line spans look like unclosed mid-stream fences). */
|
|
20
16
|
work = work.replace(/`([^`\n]+)`/g, (_m, code) => stash(`<code>${esc(code)}</code>`));
|
|
21
|
-
/** Escape outside stashes before running tag-emitting rules. */
|
|
22
17
|
work = esc(work);
|
|
23
18
|
work = work.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_m, text, url) => stash(`<a href="${url.replace(/"/g, '%22')}">${text}</a>`));
|
|
24
|
-
|
|
19
|
+
/* Bold before italic so `*`-rule doesn't eat half of `**bold**`. */
|
|
25
20
|
work = work.replace(/\*\*([^*\n]+?)\*\*/g, '<b>$1</b>').replace(/__([^_\n]+?)__/g, '<b>$1</b>');
|
|
26
|
-
|
|
21
|
+
/* \S guards keep `2 * 3` arithmetic and `foo_bar` identifiers intact. */
|
|
27
22
|
work = work
|
|
28
23
|
.replace(/(^|[^*\w])\*(\S[^*\n]*?\S|\S)\*(?!\w)/g, '$1<i>$2</i>')
|
|
29
24
|
.replace(/(^|[^_\w])_(\S[^_\n]*?\S|\S)_(?!\w)/g, '$1<i>$2</i>');
|
|
30
25
|
work = work.replace(/~~([^~\n]+?)~~/g, '<s>$1</s>');
|
|
31
|
-
|
|
26
|
+
/* Headings → bold (Telegram has no heading element). */
|
|
32
27
|
work = work.replace(/^#{1,6}\s+(.+?)\s*$/gm, '<b>$1</b>');
|
|
33
|
-
|
|
28
|
+
/* Collapse consecutive `> ` lines into one <blockquote>. */
|
|
34
29
|
work = work.replace(/(^|\n)((?:>\s?[^\n]*\n?)+)/g, (_m, lead, block) => {
|
|
35
30
|
const inner = block.replace(/^>\s?/gm, '').replace(/\n+$/, '');
|
|
36
31
|
return `${lead}<blockquote>${inner}</blockquote>${block.endsWith('\n') ? '\n' : ''}`;
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/** Telegram station: long-poll bot API; sends agent-style 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
|
+
const CAPS = {
|
|
56
|
+
in: ['text', 'image'], out: ['text'],
|
|
57
|
+
features: ['reply', 'send', 'edit', 'react', 'download', 'fetch'],
|
|
58
|
+
};
|
|
59
|
+
export class TelegramStation {
|
|
60
|
+
name = 'telegram';
|
|
61
|
+
capabilities = CAPS;
|
|
62
|
+
botUsername = null;
|
|
63
|
+
botUserId = null;
|
|
64
|
+
pollOffset = 0;
|
|
65
|
+
pollAbort = null;
|
|
66
|
+
messageHandler = () => { };
|
|
67
|
+
offsetFile = join(STATE_DIR, 'telegram-offset.json');
|
|
68
|
+
/** Snapshot recent inbounds in memory so `metro download <line> <id>` can resolve them. */
|
|
69
|
+
recent = new Map();
|
|
70
|
+
onMessage(handler) { this.messageHandler = handler; }
|
|
71
|
+
async start() {
|
|
72
|
+
await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
|
|
73
|
+
const persisted = Number(existsSync(this.offsetFile) ? readFileSync(this.offsetFile, 'utf8').trim() : 0) || 0;
|
|
74
|
+
if (persisted > 0)
|
|
75
|
+
this.pollOffset = persisted;
|
|
76
|
+
else {
|
|
77
|
+
/* First run: anchor on latest update id (-1 returns most recent without consuming). */
|
|
78
|
+
const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
|
|
79
|
+
this.pollOffset = initial.length ? initial[0].update_id + 1 : 0;
|
|
80
|
+
this.saveOffset();
|
|
81
|
+
}
|
|
82
|
+
log.info({ offset: this.pollOffset }, 'telegram station: polling started');
|
|
83
|
+
this.pollAbort = new AbortController();
|
|
84
|
+
void this.pollLoop();
|
|
85
|
+
}
|
|
86
|
+
async stop() { this.pollAbort?.abort(); this.pollAbort = null; }
|
|
87
|
+
async getMe() {
|
|
88
|
+
const me = await tg('getMe', {});
|
|
89
|
+
this.botUsername = me.username;
|
|
90
|
+
this.botUserId = me.id;
|
|
91
|
+
return me;
|
|
92
|
+
}
|
|
93
|
+
async send(line, text, opts) {
|
|
94
|
+
const { chatId, topicId } = targetOf(line);
|
|
95
|
+
const base = { chat_id: chatId };
|
|
96
|
+
if (topicId !== undefined)
|
|
97
|
+
base.message_thread_id = topicId;
|
|
98
|
+
if (opts?.replyTo)
|
|
99
|
+
base.reply_parameters = { message_id: Number(opts.replyTo) };
|
|
100
|
+
return tgSendRich(token(), tg, base, text, opts);
|
|
101
|
+
}
|
|
102
|
+
async edit(line, messageId, text, opts) {
|
|
103
|
+
const { chatId } = targetOf(line);
|
|
104
|
+
const base = { chat_id: chatId, message_id: Number(messageId), ...NO_PREVIEW };
|
|
105
|
+
if (opts?.buttons)
|
|
106
|
+
base.reply_markup = opts.buttons.length ? inlineKeyboard(opts.buttons) : { inline_keyboard: [] };
|
|
107
|
+
try {
|
|
108
|
+
await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
if (isNoopEdit(err))
|
|
112
|
+
return;
|
|
113
|
+
if (!isParseError(err))
|
|
114
|
+
throw err;
|
|
115
|
+
log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
|
|
116
|
+
try {
|
|
117
|
+
await tg('editMessageText', { ...base, text });
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
if (!isNoopEdit(e))
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async react(line, messageId, emoji) {
|
|
126
|
+
await tg('setMessageReaction', {
|
|
127
|
+
chat_id: targetOf(line).chatId, message_id: Number(messageId),
|
|
128
|
+
reaction: emoji ? [{ type: 'emoji', emoji }] : [],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async download(line, messageId, outDir) {
|
|
132
|
+
/* Telegram has no "get message by id" — resolve from the in-memory snapshot. */
|
|
133
|
+
const { chatId } = targetOf(line);
|
|
134
|
+
const m = this.recent.get(`${chatId}:${messageId}`);
|
|
135
|
+
if (!m)
|
|
136
|
+
return [];
|
|
137
|
+
const refs = [];
|
|
138
|
+
if (m.photo?.length)
|
|
139
|
+
refs.push({ id: m.photo[m.photo.length - 1].file_id, mime: 'image/jpeg' });
|
|
140
|
+
if (m.document?.mime_type?.startsWith('image/'))
|
|
141
|
+
refs.push({ id: m.document.file_id, mime: m.document.mime_type });
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const [i, { id, mime }] of refs.entries()) {
|
|
144
|
+
try {
|
|
145
|
+
const file = await tg('getFile', { file_id: id });
|
|
146
|
+
const res = await fetch(`${API_BASE}/file/bot${token()}/${file.file_path}`, { signal: AbortSignal.timeout(30_000) });
|
|
147
|
+
if (!res.ok)
|
|
148
|
+
throw new Error(`status ${res.status}`);
|
|
149
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
150
|
+
if (buf.byteLength > MAX_BYTES) {
|
|
151
|
+
log.warn({ id, size: buf.byteLength }, 'telegram: attachment too large');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const path = join(outDir, `${chatId}-${messageId}-${i}.${mime.split('/')[1] ?? 'bin'}`);
|
|
155
|
+
await writeFile(path, buf);
|
|
156
|
+
out.push({ path, mediaType: mime });
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
log.warn({ err: errMsg(err), id }, 'telegram: attachment fetch failed');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** Bot API has no history endpoint — only the in-memory snapshot is reachable. */
|
|
165
|
+
async fetch() { return []; }
|
|
166
|
+
saveOffset() {
|
|
167
|
+
try {
|
|
168
|
+
writeFileSync(this.offsetFile, String(this.pollOffset));
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
log.warn({ err: errMsg(err) }, 'telegram offset save failed');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async pollLoop() {
|
|
175
|
+
const signal = this.pollAbort?.signal;
|
|
176
|
+
const body = { timeout: 25, allowed_updates: ['message'] };
|
|
177
|
+
while (this.pollAbort && !this.pollAbort.signal.aborted) {
|
|
178
|
+
try {
|
|
179
|
+
const updates = await tg('getUpdates', { offset: this.pollOffset, ...body }, { timeoutMs: 60_000, signal });
|
|
180
|
+
for (const u of updates) {
|
|
181
|
+
this.pollOffset = u.update_id + 1;
|
|
182
|
+
this.dispatchUpdate(u);
|
|
183
|
+
}
|
|
184
|
+
if (updates.length)
|
|
185
|
+
this.saveOffset();
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
if (this.pollAbort?.signal.aborted)
|
|
189
|
+
break;
|
|
190
|
+
log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
|
|
191
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
dispatchUpdate(u) {
|
|
196
|
+
const m = u.message;
|
|
197
|
+
if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
|
|
198
|
+
return;
|
|
199
|
+
const attachmentNames = attachmentTags(m);
|
|
200
|
+
const text = m.text ?? m.caption ?? '';
|
|
201
|
+
if (!text && !attachmentNames.length)
|
|
202
|
+
return;
|
|
203
|
+
const topicId = m.is_topic_message ? m.message_thread_id : undefined;
|
|
204
|
+
const fromName = m.from?.username ? `@${m.from.username}` : m.from?.first_name;
|
|
205
|
+
const fromUri = Line.user('telegram', m.from?.id ?? 'unknown');
|
|
206
|
+
const bot = this.botUsername ? `@${this.botUsername}` : undefined;
|
|
207
|
+
log.info({ from: fromName, bot, chat: m.chat.id, topic: topicId, text: text.slice(0, 80) }, 'telegram: inbound');
|
|
208
|
+
if (this.recent.size >= 50) {
|
|
209
|
+
const first = this.recent.keys().next().value;
|
|
210
|
+
if (first)
|
|
211
|
+
this.recent.delete(first);
|
|
212
|
+
}
|
|
213
|
+
this.recent.set(`${m.chat.id}:${m.message_id}`, m);
|
|
214
|
+
this.messageHandler({
|
|
215
|
+
id: mintId(), ts: new Date((m.date ?? Math.floor(Date.now() / 1000)) * 1000).toISOString(),
|
|
216
|
+
station: 'telegram', line: Line.telegram(m.chat.id, topicId), messageId: String(m.message_id),
|
|
217
|
+
lineName: topicId === undefined ? (m.chat.title ?? m.chat.first_name ?? undefined) : undefined,
|
|
218
|
+
from: fromUri, fromName, text, attachmentNames, mentionsBot: this.detectMentionsBot(m),
|
|
219
|
+
meta: { isPrivate: m.chat.type === 'private', inForum: !!m.chat.is_forum, isForumTopic: !!m.is_topic_message },
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
detectMentionsBot(m) {
|
|
223
|
+
if (m.chat?.type === 'private')
|
|
224
|
+
return true;
|
|
225
|
+
const text = m.text ?? m.caption ?? '';
|
|
226
|
+
for (const e of m.entities ?? m.caption_entities ?? []) {
|
|
227
|
+
if (e.type === 'mention' && this.botUsername
|
|
228
|
+
&& text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${this.botUsername.toLowerCase()}`)
|
|
229
|
+
return true;
|
|
230
|
+
if (e.type === 'text_mention' && e.user?.id === this.botUserId)
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
}
|