@stage-labs/metro 0.1.0-beta.1 → 0.1.0-beta.3
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 +74 -15
- package/dist/agents/claude.js +229 -0
- package/dist/agents/codex.js +282 -0
- package/dist/agents/types.js +3 -0
- package/dist/channels/discord.js +45 -51
- package/dist/channels/telegram.js +157 -85
- package/dist/cli.js +49 -344
- package/dist/lib/scope-cache.js +85 -0
- package/dist/lib/streaming.js +207 -0
- package/dist/log.js +7 -1
- package/dist/orchestrator.js +399 -0
- package/dist/paths.js +2 -6
- package/package.json +5 -4
- package/dist/lib/address.js +0 -21
- package/dist/tail.js +0 -145
- package/skills/metro/SKILL.md +0 -99
package/dist/channels/discord.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
|
|
2
2
|
import { errMsg, log } from '../log.js';
|
|
3
|
-
// Receive path: discord.js gateway,
|
|
3
|
+
// Receive path: discord.js gateway, owned by the orchestrator.
|
|
4
4
|
// Send path: raw REST against discord.com/api — no gateway login required,
|
|
5
|
-
// so cli.ts
|
|
5
|
+
// so callers outside the orchestrator (e.g. cli.ts validation) stay one-shot.
|
|
6
6
|
const API_BASE = 'https://discord.com/api/v10';
|
|
7
7
|
function token() {
|
|
8
8
|
const t = process.env.DISCORD_BOT_TOKEN;
|
|
@@ -10,7 +10,7 @@ function token() {
|
|
|
10
10
|
throw new Error('DISCORD_BOT_TOKEN is not set');
|
|
11
11
|
return t;
|
|
12
12
|
}
|
|
13
|
-
async function rest(method, path, body, timeoutMs = 30_000) {
|
|
13
|
+
async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
|
|
14
14
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
15
15
|
method,
|
|
16
16
|
headers: {
|
|
@@ -21,6 +21,15 @@ async function rest(method, path, body, timeoutMs = 30_000) {
|
|
|
21
21
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
22
22
|
signal: AbortSignal.timeout(timeoutMs),
|
|
23
23
|
});
|
|
24
|
+
// 429: honor Discord's retry_after (seconds) and try again, up to a few
|
|
25
|
+
// hops. Common during streaming edits when the agent emits deltas faster
|
|
26
|
+
// than the per-channel edit cap.
|
|
27
|
+
if (res.status === 429 && retriesLeft > 0) {
|
|
28
|
+
const retryAfter = Number(res.headers.get('retry-after')) || 1;
|
|
29
|
+
log.debug({ path, retryAfter }, 'discord 429; backing off');
|
|
30
|
+
await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, 250)));
|
|
31
|
+
return rest(method, path, body, timeoutMs, retriesLeft - 1);
|
|
32
|
+
}
|
|
24
33
|
if (!res.ok) {
|
|
25
34
|
const text = await res.text().catch(() => '');
|
|
26
35
|
throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
|
|
@@ -62,13 +71,10 @@ export async function startGateway() {
|
|
|
62
71
|
c.on(Events.MessageCreate, m => {
|
|
63
72
|
if (m.author.bot)
|
|
64
73
|
return;
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// `<@bot_id>` and acts on the request as a whole.
|
|
70
|
-
if (m.guildId && c.user && !m.mentions.has(c.user.id))
|
|
71
|
-
return;
|
|
74
|
+
// Forward every human message; the orchestrator decides what to route
|
|
75
|
+
// where (scoped thread vs new bootstrap mention vs ignore). The bot's
|
|
76
|
+
// own @-mention is preserved in `m.content` so the orchestrator can
|
|
77
|
+
// make that decision from the same payload.
|
|
72
78
|
const tags = [...m.attachments.values()]
|
|
73
79
|
.map(a => {
|
|
74
80
|
if (a.contentType?.startsWith('image/'))
|
|
@@ -81,7 +87,13 @@ export async function startGateway() {
|
|
|
81
87
|
const text = [m.content, tags].filter(Boolean).join(' ').trim();
|
|
82
88
|
if (!text)
|
|
83
89
|
return;
|
|
84
|
-
onInboundHandler({
|
|
90
|
+
onInboundHandler({
|
|
91
|
+
channel_id: m.channelId,
|
|
92
|
+
message_id: m.id,
|
|
93
|
+
text,
|
|
94
|
+
in_guild: !!m.guildId,
|
|
95
|
+
mentions_bot: c.user ? m.mentions.has(c.user.id) : false,
|
|
96
|
+
});
|
|
85
97
|
});
|
|
86
98
|
c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
|
|
87
99
|
await c.login(process.env.DISCORD_BOT_TOKEN);
|
|
@@ -95,54 +107,36 @@ export async function sendMessage(channelId, text) {
|
|
|
95
107
|
const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text });
|
|
96
108
|
return sent.id;
|
|
97
109
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Create a public thread anchored to an existing message. The user's
|
|
112
|
+
* @-mention becomes the thread's starter, so the thread is visually
|
|
113
|
+
* tied to the request that opened it. Returns the new thread's channel id.
|
|
114
|
+
*/
|
|
115
|
+
export async function createThreadFromMessage(channelId, messageId, name) {
|
|
116
|
+
const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, {
|
|
117
|
+
name,
|
|
118
|
+
auto_archive_duration: 1440,
|
|
102
119
|
});
|
|
103
|
-
return
|
|
120
|
+
return created.id;
|
|
104
121
|
}
|
|
105
122
|
export async function editMessage(channelId, messageId, text) {
|
|
106
123
|
const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text });
|
|
107
124
|
return sent.id;
|
|
108
125
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
for (const r of msg.reactions ?? []) {
|
|
120
|
-
if (!r.me || !r.emoji.name)
|
|
121
|
-
continue;
|
|
122
|
-
await rest('DELETE', `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(r.emoji.name)}/@me`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
export async function fetchAttachments(channelId, messageId) {
|
|
126
|
-
const msg = await rest('GET', `/channels/${channelId}/messages/${messageId}`);
|
|
127
|
-
const out = [];
|
|
128
|
-
for (const a of msg.attachments) {
|
|
129
|
-
if (!a.content_type?.startsWith('image/'))
|
|
130
|
-
continue;
|
|
131
|
-
const res = await fetch(a.url, { signal: AbortSignal.timeout(30_000) });
|
|
132
|
-
if (!res.ok)
|
|
133
|
-
throw new Error(`discord: download ${a.url}: ${res.status}`);
|
|
134
|
-
out.push({ data: Buffer.from(await res.arrayBuffer()).toString('base64'), mime: a.content_type });
|
|
135
|
-
}
|
|
136
|
-
return out;
|
|
137
|
-
}
|
|
138
|
-
export async function fetchRecentMessages(channelId, limit) {
|
|
139
|
-
const n = Math.min(Math.max(limit, 1), 100);
|
|
140
|
-
const msgs = await rest('GET', `/channels/${channelId}/messages?limit=${n}`);
|
|
141
|
-
// Discord returns newest-first; reverse for chronological.
|
|
126
|
+
/**
|
|
127
|
+
* Fetch all messages newer than `afterMessageId` in a channel. Used for
|
|
128
|
+
* catchup-on-restart so messages sent while metro was down still get a
|
|
129
|
+
* reply once it comes back. Caps at 100 messages — anything older than
|
|
130
|
+
* that during downtime is dropped on the floor.
|
|
131
|
+
*/
|
|
132
|
+
export async function fetchMessagesSince(channelId, afterMessageId) {
|
|
133
|
+
const msgs = await rest('GET', `/channels/${channelId}/messages?after=${afterMessageId}&limit=100`);
|
|
134
|
+
// Discord returns newest-first; flip to chronological so replay order
|
|
135
|
+
// matches what the user actually typed.
|
|
142
136
|
return [...msgs].reverse().map(m => ({
|
|
143
137
|
message_id: m.id,
|
|
144
|
-
author: m.author.username,
|
|
145
138
|
text: m.content,
|
|
146
|
-
|
|
139
|
+
author_id: m.author.id,
|
|
140
|
+
author_is_bot: !!m.author.bot,
|
|
147
141
|
}));
|
|
148
142
|
}
|
|
@@ -9,129 +9,201 @@ function token() {
|
|
|
9
9
|
throw new Error('TELEGRAM_BOT_TOKEN is not set');
|
|
10
10
|
return t;
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
async function tg(method, body, opts = {}) {
|
|
13
|
+
const timeout = opts.timeoutMs ?? 30_000;
|
|
14
|
+
const signals = [AbortSignal.timeout(timeout)];
|
|
15
|
+
if (opts.signal)
|
|
16
|
+
signals.push(opts.signal);
|
|
17
|
+
const signal = AbortSignal.any(signals);
|
|
13
18
|
const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
|
|
14
19
|
method: 'POST',
|
|
15
20
|
headers: { 'Content-Type': 'application/json' },
|
|
16
21
|
body: JSON.stringify(body),
|
|
17
|
-
signal
|
|
22
|
+
signal,
|
|
18
23
|
});
|
|
19
24
|
const json = (await res.json());
|
|
20
25
|
if (!json.ok)
|
|
21
26
|
throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
|
|
22
27
|
return json.result;
|
|
23
28
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
body.parse_mode = opts.parseMode;
|
|
28
|
-
if (opts.disableLinkPreview)
|
|
29
|
-
body.link_preview_options = { is_disabled: true };
|
|
30
|
-
if (opts.buttons?.length)
|
|
31
|
-
body.reply_markup = { inline_keyboard: opts.buttons };
|
|
32
|
-
return body;
|
|
33
|
-
}
|
|
29
|
+
// ---------- Identity (cached after getMe) ----------------------------------
|
|
30
|
+
let botUsername = null;
|
|
31
|
+
let botUserId = null;
|
|
34
32
|
export async function getMe() {
|
|
35
|
-
|
|
33
|
+
const me = await tg('getMe', {});
|
|
34
|
+
botUsername = me.username;
|
|
35
|
+
botUserId = me.id;
|
|
36
|
+
return me;
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
let onInboundHandler = () => { };
|
|
39
|
+
export function onInbound(handler) {
|
|
40
|
+
onInboundHandler = handler;
|
|
41
|
+
}
|
|
42
|
+
const offsetFile = join(STATE_DIR, 'telegram-offset.json');
|
|
43
|
+
let pollOffset = 0;
|
|
44
|
+
let pollAbort = null;
|
|
45
|
+
function loadOffset() {
|
|
40
46
|
try {
|
|
41
|
-
|
|
47
|
+
if (!existsSync(offsetFile))
|
|
48
|
+
return 0;
|
|
49
|
+
return Number(readFileSync(offsetFile, 'utf8').trim()) || 0;
|
|
42
50
|
}
|
|
43
|
-
catch
|
|
44
|
-
|
|
45
|
-
return {};
|
|
51
|
+
catch {
|
|
52
|
+
return 0;
|
|
46
53
|
}
|
|
47
54
|
}
|
|
48
|
-
function
|
|
55
|
+
function saveOffset(o) {
|
|
49
56
|
try {
|
|
50
|
-
|
|
51
|
-
const key = `${chatId}:${messageId}`;
|
|
52
|
-
data[key] = [...(data[key] ?? []), att];
|
|
53
|
-
const keys = Object.keys(data);
|
|
54
|
-
for (const stale of keys.slice(0, Math.max(0, keys.length - CACHE_MAX)))
|
|
55
|
-
delete data[stale];
|
|
56
|
-
writeFileSync(cacheFile, JSON.stringify(data, null, 2));
|
|
57
|
+
writeFileSync(offsetFile, String(o));
|
|
57
58
|
}
|
|
58
59
|
catch (err) {
|
|
59
|
-
log.warn({ err: errMsg(err) }, 'telegram
|
|
60
|
+
log.warn({ err: errMsg(err) }, 'telegram offset save failed');
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
|
-
export function
|
|
63
|
-
|
|
63
|
+
export async function startPolling() {
|
|
64
|
+
// A registered webhook short-circuits getUpdates — clear it defensively.
|
|
65
|
+
await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
|
|
66
|
+
const persisted = loadOffset();
|
|
67
|
+
if (persisted > 0) {
|
|
68
|
+
// Resume from where we left off — telegram queues updates for ~24h, so
|
|
69
|
+
// any messages sent while metro was down come back through this poll.
|
|
70
|
+
pollOffset = persisted;
|
|
71
|
+
log.info({ offset: pollOffset }, 'telegram polling: resuming from persisted offset');
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// First-ever run: skip historical backlog by anchoring on the latest
|
|
75
|
+
// update id (-1 returns just the most recent one without consuming it).
|
|
76
|
+
const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
|
|
77
|
+
pollOffset = initial.length ? initial[0].update_id + 1 : 0;
|
|
78
|
+
saveOffset(pollOffset);
|
|
79
|
+
log.info({ offset: pollOffset }, 'telegram polling: starting fresh');
|
|
80
|
+
}
|
|
81
|
+
pollAbort = new AbortController();
|
|
82
|
+
void pollLoop();
|
|
64
83
|
}
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
84
|
+
async function pollLoop() {
|
|
85
|
+
const abortSignal = pollAbort?.signal;
|
|
86
|
+
while (pollAbort && !pollAbort.signal.aborted) {
|
|
87
|
+
try {
|
|
88
|
+
const updates = await tg('getUpdates', { offset: pollOffset, timeout: 25 }, { timeoutMs: 60_000, signal: abortSignal });
|
|
89
|
+
for (const u of updates) {
|
|
90
|
+
pollOffset = u.update_id + 1;
|
|
91
|
+
await dispatchUpdate(u);
|
|
92
|
+
}
|
|
93
|
+
if (updates.length)
|
|
94
|
+
saveOffset(pollOffset);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (pollAbort?.signal.aborted)
|
|
98
|
+
break;
|
|
99
|
+
log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
|
|
100
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
log.info('telegram polling stopped');
|
|
104
|
+
}
|
|
105
|
+
export async function shutdownPolling() {
|
|
106
|
+
pollAbort?.abort();
|
|
107
|
+
pollAbort = null;
|
|
108
|
+
}
|
|
109
|
+
async function dispatchUpdate(u) {
|
|
110
|
+
const m = u.message;
|
|
111
|
+
if (!m?.chat?.id || typeof m.message_id !== 'number') {
|
|
112
|
+
log.trace({ update_id: u.update_id }, 'telegram: non-message update');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (m.from?.is_bot) {
|
|
116
|
+
log.trace({ chat: m.chat.id }, 'telegram: skipping bot author');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const text = await messageToText(m);
|
|
120
|
+
if (!text) {
|
|
121
|
+
log.trace({ chat: m.chat.id }, 'telegram: no text/caption');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const msg = {
|
|
125
|
+
chat_id: m.chat.id,
|
|
126
|
+
message_id: m.message_id,
|
|
127
|
+
message_thread_id: m.is_topic_message ? m.message_thread_id : undefined,
|
|
128
|
+
text,
|
|
129
|
+
is_private: m.chat.type === 'private',
|
|
130
|
+
is_forum_topic: !!m.is_topic_message,
|
|
131
|
+
in_forum: !!m.chat.is_forum,
|
|
132
|
+
mentions_bot: detectMentionsBot(m),
|
|
133
|
+
};
|
|
134
|
+
log.debug({
|
|
135
|
+
chat: msg.chat_id,
|
|
136
|
+
topic: msg.message_thread_id,
|
|
137
|
+
is_private: msg.is_private,
|
|
138
|
+
is_forum_topic: msg.is_forum_topic,
|
|
139
|
+
in_forum: msg.in_forum,
|
|
140
|
+
mentions_bot: msg.mentions_bot,
|
|
141
|
+
}, 'telegram: inbound');
|
|
142
|
+
onInboundHandler(msg);
|
|
78
143
|
}
|
|
79
|
-
|
|
144
|
+
function detectMentionsBot(m) {
|
|
145
|
+
// In DMs, every message to the bot is implicitly addressed to it.
|
|
146
|
+
if (m.chat?.type === 'private')
|
|
147
|
+
return true;
|
|
148
|
+
const text = m.text ?? m.caption ?? '';
|
|
149
|
+
const entities = m.entities ?? m.caption_entities ?? [];
|
|
150
|
+
for (const e of entities) {
|
|
151
|
+
if (e.type === 'mention' && botUsername) {
|
|
152
|
+
const slice = text.substring(e.offset, e.offset + e.length);
|
|
153
|
+
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`)
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
else if (e.type === 'text_mention' && e.user?.id === botUserId) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
async function messageToText(m) {
|
|
80
163
|
if (m.text)
|
|
81
164
|
return m.text;
|
|
82
165
|
const caption = m.caption ?? '';
|
|
83
|
-
if (m.photo?.length)
|
|
84
|
-
const photo = m.photo[m.photo.length - 1];
|
|
85
|
-
cacheAttachment(chatId, m.message_id, { file_id: photo.file_id, mime: 'image/jpeg' });
|
|
166
|
+
if (m.photo?.length)
|
|
86
167
|
return [caption, '[image]'].filter(Boolean).join(' ');
|
|
87
|
-
|
|
88
|
-
if (m.document?.mime_type?.startsWith('image/')) {
|
|
89
|
-
cacheAttachment(chatId, m.message_id, { file_id: m.document.file_id, mime: m.document.mime_type });
|
|
168
|
+
if (m.document?.mime_type?.startsWith('image/'))
|
|
90
169
|
return [caption, '[image]'].filter(Boolean).join(' ');
|
|
91
|
-
}
|
|
92
170
|
if (m.voice)
|
|
93
171
|
return [caption, '[voice]'].filter(Boolean).join(' ');
|
|
94
172
|
if (m.audio)
|
|
95
173
|
return [caption, '[audio]'].filter(Boolean).join(' ');
|
|
96
174
|
return caption || null;
|
|
97
175
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
176
|
+
// ---------- Outbound (REST) ------------------------------------------------
|
|
177
|
+
/**
|
|
178
|
+
* Create a new forum topic in a supergroup. Returns the topic's
|
|
179
|
+
* message_thread_id. Requires the bot to be an admin with the
|
|
180
|
+
* `can_manage_topics` privilege.
|
|
181
|
+
*/
|
|
182
|
+
export async function createForumTopic(chatId, name) {
|
|
183
|
+
// Telegram caps topic names at 128 chars.
|
|
184
|
+
const trimmedName = name.slice(0, 128) || 'metro';
|
|
185
|
+
const r = await tg('createForumTopic', {
|
|
186
|
+
chat_id: chatId,
|
|
187
|
+
name: trimmedName,
|
|
188
|
+
});
|
|
189
|
+
return r.message_thread_id;
|
|
101
190
|
}
|
|
102
|
-
export async function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
offset = initial[initial.length - 1].update_id + 1;
|
|
109
|
-
while (true) {
|
|
110
|
-
try {
|
|
111
|
-
const updates = await tg('getUpdates', { offset, timeout: 50 }, 60_000);
|
|
112
|
-
for (const u of updates) {
|
|
113
|
-
offset = u.update_id + 1;
|
|
114
|
-
void dispatchUpdate(u);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
catch (err) {
|
|
118
|
-
log.error({ err: errMsg(err) }, 'telegram poll error');
|
|
119
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
120
|
-
}
|
|
121
|
-
}
|
|
191
|
+
export async function sendMessage(chatId, threadId, text) {
|
|
192
|
+
const body = { chat_id: chatId, text };
|
|
193
|
+
if (threadId !== undefined)
|
|
194
|
+
body.message_thread_id = threadId;
|
|
195
|
+
const r = await tg('sendMessage', body);
|
|
196
|
+
return r.message_id;
|
|
122
197
|
}
|
|
123
|
-
async function
|
|
124
|
-
const m = u.message;
|
|
125
|
-
if (!m?.chat?.id || typeof m.message_id !== 'number')
|
|
126
|
-
return;
|
|
127
|
-
const base = { chat_id: m.chat.id, message_id: m.message_id };
|
|
198
|
+
export async function editMessageText(chatId, messageId, text) {
|
|
128
199
|
try {
|
|
129
|
-
|
|
130
|
-
if (text === null)
|
|
131
|
-
return;
|
|
132
|
-
onInboundHandler({ ...base, text });
|
|
200
|
+
await tg('editMessageText', { chat_id: chatId, message_id: messageId, text });
|
|
133
201
|
}
|
|
134
202
|
catch (err) {
|
|
135
|
-
|
|
203
|
+
// Telegram rejects edits that match the existing content — ignore that
|
|
204
|
+
// specific case so debounced no-op flushes don't surface as errors.
|
|
205
|
+
if (errMsg(err).includes('message is not modified'))
|
|
206
|
+
return;
|
|
207
|
+
throw err;
|
|
136
208
|
}
|
|
137
209
|
}
|