@stage-labs/metro 0.1.0-beta.3 → 0.1.0-beta.4
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 +7 -4
- package/dist/agents/claude.js +72 -94
- package/dist/agents/codex.js +35 -110
- package/dist/agents/types.js +1 -2
- package/dist/channels/discord.js +18 -56
- package/dist/channels/telegram.js +54 -74
- package/dist/cli.js +49 -94
- package/dist/{lib → helpers}/scope-cache.js +4 -24
- package/dist/helpers/streaming.js +209 -0
- package/dist/helpers/telegram-format.js +39 -0
- package/dist/helpers/turn.js +40 -0
- package/dist/log.js +1 -3
- package/dist/orchestrator.js +120 -311
- package/dist/paths.js +53 -18
- package/package.json +1 -1
- package/dist/lib/dotenv.js +0 -31
- package/dist/lib/streaming.js +0 -207
package/dist/channels/discord.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Client, Events, GatewayIntentBits, Partials } from 'discord.js';
|
|
2
2
|
import { errMsg, log } from '../log.js';
|
|
3
|
-
|
|
4
|
-
// Send path: raw REST against discord.com/api — no gateway login required,
|
|
5
|
-
// so callers outside the orchestrator (e.g. cli.ts validation) stay one-shot.
|
|
3
|
+
/** Receive: discord.js gateway. Send: raw REST so non-orchestrator callers stay one-shot. */
|
|
6
4
|
const API_BASE = 'https://discord.com/api/v10';
|
|
7
5
|
function token() {
|
|
8
6
|
const t = process.env.DISCORD_BOT_TOKEN;
|
|
@@ -21,9 +19,7 @@ async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
|
|
|
21
19
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
22
20
|
signal: AbortSignal.timeout(timeoutMs),
|
|
23
21
|
});
|
|
24
|
-
|
|
25
|
-
// hops. Common during streaming edits when the agent emits deltas faster
|
|
26
|
-
// than the per-channel edit cap.
|
|
22
|
+
/** 429: honor `retry_after` (seconds) and retry, up to a few hops. */
|
|
27
23
|
if (res.status === 429 && retriesLeft > 0) {
|
|
28
24
|
const retryAfter = Number(res.headers.get('retry-after')) || 1;
|
|
29
25
|
log.debug({ path, retryAfter }, 'discord 429; backing off');
|
|
@@ -34,32 +30,23 @@ async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
|
|
|
34
30
|
const text = await res.text().catch(() => '');
|
|
35
31
|
throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
|
|
36
32
|
}
|
|
37
|
-
// 204 No Content for typing/reactions/clear.
|
|
38
33
|
if (res.status === 204)
|
|
39
34
|
return undefined;
|
|
40
35
|
return (await res.json());
|
|
41
36
|
}
|
|
42
|
-
// ---------- Receive path (gateway, discord.js) -----------------------------
|
|
43
37
|
let client = null;
|
|
44
38
|
function getClient() {
|
|
45
39
|
if (client)
|
|
46
40
|
return client;
|
|
47
41
|
client = new Client({
|
|
48
|
-
intents: [
|
|
49
|
-
|
|
50
|
-
GatewayIntentBits.Guilds,
|
|
51
|
-
GatewayIntentBits.GuildMessages,
|
|
52
|
-
GatewayIntentBits.MessageContent,
|
|
53
|
-
],
|
|
54
|
-
// DM channels arrive partial; without this messageCreate never fires.
|
|
42
|
+
intents: [GatewayIntentBits.DirectMessages, GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
|
43
|
+
/** DM channels arrive partial; without this messageCreate never fires. */
|
|
55
44
|
partials: [Partials.Channel],
|
|
56
45
|
});
|
|
57
46
|
return client;
|
|
58
47
|
}
|
|
59
48
|
let onInboundHandler = () => { };
|
|
60
|
-
export
|
|
61
|
-
onInboundHandler = handler;
|
|
62
|
-
}
|
|
49
|
+
export const onInbound = (handler) => { onInboundHandler = handler; };
|
|
63
50
|
export async function shutdownGateway() {
|
|
64
51
|
if (!client)
|
|
65
52
|
return;
|
|
@@ -71,19 +58,10 @@ export async function startGateway() {
|
|
|
71
58
|
c.on(Events.MessageCreate, m => {
|
|
72
59
|
if (m.author.bot)
|
|
73
60
|
return;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const tags = [...m.attachments.values()]
|
|
79
|
-
.map(a => {
|
|
80
|
-
if (a.contentType?.startsWith('image/'))
|
|
81
|
-
return '[image]';
|
|
82
|
-
if (a.contentType?.startsWith('audio/'))
|
|
83
|
-
return `[audio: ${a.name}]`;
|
|
84
|
-
return `[file: ${a.name}]`;
|
|
85
|
-
})
|
|
86
|
-
.join(' ');
|
|
61
|
+
/** Forward every human message; orchestrator decides routing. @-mention stays in content. */
|
|
62
|
+
const tags = [...m.attachments.values()].map(a => a.contentType?.startsWith('image/') ? '[image]'
|
|
63
|
+
: a.contentType?.startsWith('audio/') ? `[audio: ${a.name}]`
|
|
64
|
+
: `[file: ${a.name}]`).join(' ');
|
|
87
65
|
const text = [m.content, tags].filter(Boolean).join(' ').trim();
|
|
88
66
|
if (!text)
|
|
89
67
|
return;
|
|
@@ -103,40 +81,24 @@ export async function getMe() {
|
|
|
103
81
|
const me = await rest('GET', '/users/@me');
|
|
104
82
|
return { username: me.username };
|
|
105
83
|
}
|
|
84
|
+
/** SUPPRESS_EMBEDS (1 << 2) — strip link unfurls from every bot message. */
|
|
85
|
+
const SUPPRESS_EMBEDS = 1 << 2;
|
|
106
86
|
export async function sendMessage(channelId, text) {
|
|
107
|
-
const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text });
|
|
87
|
+
const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text, flags: SUPPRESS_EMBEDS });
|
|
108
88
|
return sent.id;
|
|
109
89
|
}
|
|
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
|
-
*/
|
|
90
|
+
/** Public thread anchored on `messageId`. Returns the new thread channel id. */
|
|
115
91
|
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,
|
|
119
|
-
});
|
|
92
|
+
const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, { name, auto_archive_duration: 1440 });
|
|
120
93
|
return created.id;
|
|
121
94
|
}
|
|
122
95
|
export async function editMessage(channelId, messageId, text) {
|
|
123
|
-
const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text });
|
|
96
|
+
const sent = await rest('PATCH', `/channels/${channelId}/messages/${messageId}`, { content: text, flags: SUPPRESS_EMBEDS });
|
|
124
97
|
return sent.id;
|
|
125
98
|
}
|
|
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
|
-
*/
|
|
99
|
+
/** Catchup-on-restart: fetch messages newer than `afterMessageId` (cap 100). */
|
|
132
100
|
export async function fetchMessagesSince(channelId, afterMessageId) {
|
|
133
101
|
const msgs = await rest('GET', `/channels/${channelId}/messages?after=${afterMessageId}&limit=100`);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return [...msgs].reverse().map(m => ({
|
|
137
|
-
message_id: m.id,
|
|
138
|
-
text: m.content,
|
|
139
|
-
author_id: m.author.id,
|
|
140
|
-
author_is_bot: !!m.author.bot,
|
|
141
|
-
}));
|
|
102
|
+
/** Discord returns newest-first; flip to chronological for replay. */
|
|
103
|
+
return [...msgs].reverse().map(m => ({ message_id: m.id, text: m.content, author_id: m.author.id, author_is_bot: !!m.author.bot }));
|
|
142
104
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { STATE_DIR } from '../paths.js';
|
|
4
4
|
import { errMsg, log } from '../log.js';
|
|
5
|
+
import { mdToTelegramHtml } from '../helpers/telegram-format.js';
|
|
5
6
|
const API_BASE = 'https://api.telegram.org';
|
|
6
7
|
function token() {
|
|
7
8
|
const t = process.env.TELEGRAM_BOT_TOKEN;
|
|
@@ -10,23 +11,20 @@ function token() {
|
|
|
10
11
|
return t;
|
|
11
12
|
}
|
|
12
13
|
async function tg(method, body, opts = {}) {
|
|
13
|
-
const
|
|
14
|
-
const signals = [AbortSignal.timeout(timeout)];
|
|
14
|
+
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
|
|
15
15
|
if (opts.signal)
|
|
16
16
|
signals.push(opts.signal);
|
|
17
|
-
const signal = AbortSignal.any(signals);
|
|
18
17
|
const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
|
|
19
18
|
method: 'POST',
|
|
20
19
|
headers: { 'Content-Type': 'application/json' },
|
|
21
20
|
body: JSON.stringify(body),
|
|
22
|
-
signal,
|
|
21
|
+
signal: AbortSignal.any(signals),
|
|
23
22
|
});
|
|
24
23
|
const json = (await res.json());
|
|
25
24
|
if (!json.ok)
|
|
26
25
|
throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
|
|
27
26
|
return json.result;
|
|
28
27
|
}
|
|
29
|
-
// ---------- Identity (cached after getMe) ----------------------------------
|
|
30
28
|
let botUsername = null;
|
|
31
29
|
let botUserId = null;
|
|
32
30
|
export async function getMe() {
|
|
@@ -36,17 +34,13 @@ export async function getMe() {
|
|
|
36
34
|
return me;
|
|
37
35
|
}
|
|
38
36
|
let onInboundHandler = () => { };
|
|
39
|
-
export
|
|
40
|
-
onInboundHandler = handler;
|
|
41
|
-
}
|
|
37
|
+
export const onInbound = (handler) => { onInboundHandler = handler; };
|
|
42
38
|
const offsetFile = join(STATE_DIR, 'telegram-offset.json');
|
|
43
39
|
let pollOffset = 0;
|
|
44
40
|
let pollAbort = null;
|
|
45
41
|
function loadOffset() {
|
|
46
42
|
try {
|
|
47
|
-
|
|
48
|
-
return 0;
|
|
49
|
-
return Number(readFileSync(offsetFile, 'utf8').trim()) || 0;
|
|
43
|
+
return existsSync(offsetFile) ? Number(readFileSync(offsetFile, 'utf8').trim()) || 0 : 0;
|
|
50
44
|
}
|
|
51
45
|
catch {
|
|
52
46
|
return 0;
|
|
@@ -61,18 +55,14 @@ function saveOffset(o) {
|
|
|
61
55
|
}
|
|
62
56
|
}
|
|
63
57
|
export async function startPolling() {
|
|
64
|
-
// A registered webhook short-circuits getUpdates — clear it defensively.
|
|
65
58
|
await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
|
|
66
59
|
const persisted = loadOffset();
|
|
67
60
|
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
61
|
pollOffset = persisted;
|
|
71
62
|
log.info({ offset: pollOffset }, 'telegram polling: resuming from persisted offset');
|
|
72
63
|
}
|
|
73
64
|
else {
|
|
74
|
-
|
|
75
|
-
// update id (-1 returns just the most recent one without consuming it).
|
|
65
|
+
/** First run: anchor on latest update id (-1 returns most recent without consuming). */
|
|
76
66
|
const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
|
|
77
67
|
pollOffset = initial.length ? initial[0].update_id + 1 : 0;
|
|
78
68
|
saveOffset(pollOffset);
|
|
@@ -102,26 +92,15 @@ async function pollLoop() {
|
|
|
102
92
|
}
|
|
103
93
|
log.info('telegram polling stopped');
|
|
104
94
|
}
|
|
105
|
-
export async function shutdownPolling() {
|
|
106
|
-
pollAbort?.abort();
|
|
107
|
-
pollAbort = null;
|
|
108
|
-
}
|
|
95
|
+
export async function shutdownPolling() { pollAbort?.abort(); pollAbort = null; }
|
|
109
96
|
async function dispatchUpdate(u) {
|
|
110
97
|
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');
|
|
98
|
+
if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
|
|
113
99
|
return;
|
|
114
|
-
|
|
115
|
-
if (
|
|
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');
|
|
100
|
+
const text = messageToText(m);
|
|
101
|
+
if (!text)
|
|
122
102
|
return;
|
|
123
|
-
|
|
124
|
-
const msg = {
|
|
103
|
+
onInboundHandler({
|
|
125
104
|
chat_id: m.chat.id,
|
|
126
105
|
message_id: m.message_id,
|
|
127
106
|
message_thread_id: m.is_topic_message ? m.message_thread_id : undefined,
|
|
@@ -130,42 +109,27 @@ async function dispatchUpdate(u) {
|
|
|
130
109
|
is_forum_topic: !!m.is_topic_message,
|
|
131
110
|
in_forum: !!m.chat.is_forum,
|
|
132
111
|
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);
|
|
112
|
+
});
|
|
143
113
|
}
|
|
144
114
|
function detectMentionsBot(m) {
|
|
145
|
-
// In DMs, every message to the bot is implicitly addressed to it.
|
|
146
115
|
if (m.chat?.type === 'private')
|
|
147
116
|
return true;
|
|
148
117
|
const text = m.text ?? m.caption ?? '';
|
|
149
|
-
const
|
|
150
|
-
for (const e of entities) {
|
|
118
|
+
for (const e of m.entities ?? m.caption_entities ?? []) {
|
|
151
119
|
if (e.type === 'mention' && botUsername) {
|
|
152
|
-
|
|
153
|
-
if (slice.toLowerCase() === `@${botUsername.toLowerCase()}`)
|
|
120
|
+
if (text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`)
|
|
154
121
|
return true;
|
|
155
122
|
}
|
|
156
|
-
else if (e.type === 'text_mention' && e.user?.id === botUserId)
|
|
123
|
+
else if (e.type === 'text_mention' && e.user?.id === botUserId)
|
|
157
124
|
return true;
|
|
158
|
-
}
|
|
159
125
|
}
|
|
160
126
|
return false;
|
|
161
127
|
}
|
|
162
|
-
|
|
128
|
+
function messageToText(m) {
|
|
163
129
|
if (m.text)
|
|
164
130
|
return m.text;
|
|
165
131
|
const caption = m.caption ?? '';
|
|
166
|
-
if (m.photo?.length)
|
|
167
|
-
return [caption, '[image]'].filter(Boolean).join(' ');
|
|
168
|
-
if (m.document?.mime_type?.startsWith('image/'))
|
|
132
|
+
if (m.photo?.length || m.document?.mime_type?.startsWith('image/'))
|
|
169
133
|
return [caption, '[image]'].filter(Boolean).join(' ');
|
|
170
134
|
if (m.voice)
|
|
171
135
|
return [caption, '[voice]'].filter(Boolean).join(' ');
|
|
@@ -173,37 +137,53 @@ async function messageToText(m) {
|
|
|
173
137
|
return [caption, '[audio]'].filter(Boolean).join(' ');
|
|
174
138
|
return caption || null;
|
|
175
139
|
}
|
|
176
|
-
|
|
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
|
-
*/
|
|
140
|
+
/** Create a forum topic; requires `can_manage_topics` admin permission. */
|
|
182
141
|
export async function createForumTopic(chatId, name) {
|
|
183
|
-
|
|
184
|
-
const trimmedName = name.slice(0, 128) || 'metro';
|
|
185
|
-
const r = await tg('createForumTopic', {
|
|
186
|
-
chat_id: chatId,
|
|
187
|
-
name: trimmedName,
|
|
188
|
-
});
|
|
142
|
+
const r = await tg('createForumTopic', { chat_id: chatId, name: name.slice(0, 128) || 'metro' });
|
|
189
143
|
return r.message_thread_id;
|
|
190
144
|
}
|
|
191
|
-
|
|
192
|
-
|
|
145
|
+
/** Deep link `t.me/c/<id>/<topic>`; strips supergroup's `-100` prefix from chat id. */
|
|
146
|
+
export function topicLink(chatId, topicId) {
|
|
147
|
+
const id = String(Math.abs(chatId)).replace(/^100/, '');
|
|
148
|
+
return `https://t.me/c/${id}/${topicId}`;
|
|
149
|
+
}
|
|
150
|
+
const isParseError = (err) => errMsg(err).includes("can't parse entities");
|
|
151
|
+
const NO_PREVIEW = { link_preview_options: { is_disabled: true } };
|
|
152
|
+
/** Send agent-style markdown as Telegram HTML, falling back to plain text on parse errors. */
|
|
153
|
+
export async function sendMessage(chatId, threadId, text, replyToMessageId) {
|
|
154
|
+
const base = { chat_id: chatId, ...NO_PREVIEW };
|
|
193
155
|
if (threadId !== undefined)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
156
|
+
base.message_thread_id = threadId;
|
|
157
|
+
if (replyToMessageId !== undefined)
|
|
158
|
+
base.reply_parameters = { message_id: replyToMessageId };
|
|
159
|
+
try {
|
|
160
|
+
return (await tg('sendMessage', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' })).message_id;
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (!isParseError(err))
|
|
164
|
+
throw err;
|
|
165
|
+
log.warn({ err: errMsg(err) }, 'telegram: HTML rejected, sending plain');
|
|
166
|
+
return (await tg('sendMessage', { ...base, text })).message_id;
|
|
167
|
+
}
|
|
197
168
|
}
|
|
198
169
|
export async function editMessageText(chatId, messageId, text) {
|
|
170
|
+
const base = { chat_id: chatId, message_id: messageId, ...NO_PREVIEW };
|
|
171
|
+
const swallow = (err) => { if (!errMsg(err).includes('message is not modified'))
|
|
172
|
+
throw err; };
|
|
199
173
|
try {
|
|
200
|
-
await tg('editMessageText', {
|
|
174
|
+
await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
|
|
201
175
|
}
|
|
202
176
|
catch (err) {
|
|
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
177
|
if (errMsg(err).includes('message is not modified'))
|
|
206
178
|
return;
|
|
207
|
-
|
|
179
|
+
if (!isParseError(err))
|
|
180
|
+
throw err;
|
|
181
|
+
log.warn({ err: errMsg(err) }, 'telegram: HTML edit rejected, retrying plain');
|
|
182
|
+
try {
|
|
183
|
+
await tg('editMessageText', { ...base, text });
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
swallow(e);
|
|
187
|
+
}
|
|
208
188
|
}
|
|
209
189
|
}
|
package/dist/cli.js
CHANGED
|
@@ -5,28 +5,27 @@ import { join } from 'node:path';
|
|
|
5
5
|
import pkg from '../package.json' with { type: 'json' };
|
|
6
6
|
import * as discord from './channels/discord.js';
|
|
7
7
|
import * as telegram from './channels/telegram.js';
|
|
8
|
-
import { readDotenv, writeDotenv } from './lib/dotenv.js';
|
|
9
8
|
import { errMsg } from './log.js';
|
|
10
|
-
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, STATE_DIR } from './paths.js';
|
|
9
|
+
import { CONFIG_ENV_FILE, configuredPlatforms, loadMetroEnv, readDotenv, STATE_DIR, writeDotenv } from './paths.js';
|
|
11
10
|
const USAGE = `metro — Telegram + Discord bridge for your Claude Code / Codex agent
|
|
12
11
|
|
|
13
12
|
Usage:
|
|
14
13
|
metro Run the orchestrator daemon.
|
|
15
|
-
|
|
16
|
-
codex session, with streaming responses.
|
|
17
|
-
metro setup Show config status (tokens).
|
|
18
|
-
metro setup telegram <token> Save TELEGRAM_BOT_TOKEN (validated via getMe).
|
|
19
|
-
metro setup discord <token> Save DISCORD_BOT_TOKEN (validated via getMe).
|
|
14
|
+
metro setup [telegram|discord <token>] Save token, or show status with no args.
|
|
20
15
|
metro setup clear [telegram|discord|all] Remove tokens.
|
|
21
|
-
metro doctor Health check
|
|
22
|
-
metro update Upgrade in place
|
|
23
|
-
metro --version
|
|
24
|
-
metro --help, -h This help.
|
|
16
|
+
metro doctor Health check.
|
|
17
|
+
metro update Upgrade in place.
|
|
18
|
+
metro --version | --help
|
|
25
19
|
|
|
26
|
-
Exit codes:
|
|
27
|
-
0 success · 1 usage · 2 config (no tokens — run \`metro setup\`) · 3 upstream
|
|
20
|
+
Exit codes: 0 success · 1 usage · 2 config · 3 upstream
|
|
28
21
|
`;
|
|
29
|
-
|
|
22
|
+
const exitErr = (msg, code) => Object.assign(new Error(msg), { code });
|
|
23
|
+
const isJson = (f) => f.json === true;
|
|
24
|
+
const emit = (f, human, structured) => {
|
|
25
|
+
process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
26
|
+
};
|
|
27
|
+
const maskToken = (t) => !t ? '' : t.length <= 8 ? '••••' : `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
28
|
+
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
30
29
|
function parseArgs(argv) {
|
|
31
30
|
const positional = [];
|
|
32
31
|
const flags = {};
|
|
@@ -52,18 +51,6 @@ function parseArgs(argv) {
|
|
|
52
51
|
}
|
|
53
52
|
return { positional, flags };
|
|
54
53
|
}
|
|
55
|
-
const isJson = (f) => f.json === true;
|
|
56
|
-
const emitResult = (f, human, structured) => {
|
|
57
|
-
process.stdout.write(isJson(f) ? JSON.stringify(structured) + '\n' : human + '\n');
|
|
58
|
-
};
|
|
59
|
-
function maskToken(t) {
|
|
60
|
-
if (!t)
|
|
61
|
-
return '';
|
|
62
|
-
if (t.length <= 8)
|
|
63
|
-
return '••••';
|
|
64
|
-
return `${t.slice(0, 6)}…${t.slice(-2)}`;
|
|
65
|
-
}
|
|
66
|
-
const TOKEN_KEYS = { telegram: 'TELEGRAM_BOT_TOKEN', discord: 'DISCORD_BOT_TOKEN' };
|
|
67
54
|
async function cmdSetup(positional, flags) {
|
|
68
55
|
const [sub, value] = positional;
|
|
69
56
|
if (!sub)
|
|
@@ -86,10 +73,8 @@ async function cmdSetup(positional, flags) {
|
|
|
86
73
|
const env = readDotenv(CONFIG_ENV_FILE);
|
|
87
74
|
env[TOKEN_KEYS[sub]] = trimmed;
|
|
88
75
|
writeDotenv(CONFIG_ENV_FILE, env);
|
|
89
|
-
const human = identity
|
|
90
|
-
|
|
91
|
-
: `saved ${TOKEN_KEYS[sub]} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
92
|
-
emitResult(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
|
|
76
|
+
const human = `saved ${TOKEN_KEYS[sub]}${identity ? ` (verified as ${identity})` : ''} to ${CONFIG_ENV_FILE} (chmod 0600)`;
|
|
77
|
+
emit(flags, human, { ok: true, saved: TOKEN_KEYS[sub], verified_as: identity ?? null });
|
|
93
78
|
return;
|
|
94
79
|
}
|
|
95
80
|
if (sub === 'clear') {
|
|
@@ -104,7 +89,7 @@ async function cmdSetup(positional, flags) {
|
|
|
104
89
|
else
|
|
105
90
|
throw new Error(`metro setup clear <telegram|discord|all> — got '${target}'`);
|
|
106
91
|
writeDotenv(CONFIG_ENV_FILE, env);
|
|
107
|
-
|
|
92
|
+
emit(flags, `cleared ${target === 'all' ? 'all metro tokens' : TOKEN_KEYS[target]}`, { ok: true, cleared: target });
|
|
108
93
|
return;
|
|
109
94
|
}
|
|
110
95
|
throw new Error(`unknown setup subcommand '${sub}' (try: telegram, discord, clear)`);
|
|
@@ -117,57 +102,43 @@ async function cmdSetupStatus(flags) {
|
|
|
117
102
|
process.stdout.write(JSON.stringify({
|
|
118
103
|
version: pkg.version,
|
|
119
104
|
config_env_file: CONFIG_ENV_FILE,
|
|
120
|
-
tokens: {
|
|
121
|
-
telegram: { set: !!tg, masked: maskToken(tg) },
|
|
122
|
-
discord: { set: !!dc, masked: maskToken(dc) },
|
|
123
|
-
},
|
|
105
|
+
tokens: { telegram: { set: !!tg, masked: maskToken(tg) }, discord: { set: !!dc, masked: maskToken(dc) } },
|
|
124
106
|
}) + '\n');
|
|
125
107
|
return;
|
|
126
108
|
}
|
|
127
|
-
|
|
128
|
-
|
|
109
|
+
const cfgState = existsSync(CONFIG_ENV_FILE) ? '' : ' (not yet written)';
|
|
110
|
+
process.stdout.write(`metro ${pkg.version}\n\nconfig: ${CONFIG_ENV_FILE}${cfgState}\n\n` +
|
|
129
111
|
` TELEGRAM_BOT_TOKEN ${tg ? `set (${maskToken(tg)})` : 'not set'}\n` +
|
|
130
112
|
` DISCORD_BOT_TOKEN ${dc ? `set (${maskToken(dc)})` : 'not set'}\n\n`);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
' metro setup discord <token> # https://discord.com/developers/applications\n' +
|
|
135
|
-
' 2. metro doctor # verify\n' +
|
|
136
|
-
' 3. metro # run the orchestrator\n');
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
process.stdout.write('Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
|
|
140
|
-
}
|
|
113
|
+
process.stdout.write(!tg && !dc
|
|
114
|
+
? 'Get started:\n 1. metro setup telegram <token> # https://t.me/BotFather\n metro setup discord <token> # https://discord.com/developers/applications\n 2. metro doctor\n 3. metro\n'
|
|
115
|
+
: 'Run `metro` to start the orchestrator, or `metro doctor` to verify.\n');
|
|
141
116
|
}
|
|
142
117
|
async function cmdDoctor(flags) {
|
|
143
118
|
loadMetroEnv();
|
|
144
|
-
const checks = [];
|
|
145
119
|
const cfg = configuredPlatforms();
|
|
146
|
-
checks
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!cfg[platform]) {
|
|
155
|
-
checks.push({ name: platform, ok: null, detail: 'not configured' });
|
|
120
|
+
const checks = [{
|
|
121
|
+
name: 'tokens',
|
|
122
|
+
ok: cfg.telegram || cfg.discord,
|
|
123
|
+
detail: cfg.telegram || cfg.discord ? `loaded from ${existsSync(CONFIG_ENV_FILE) ? CONFIG_ENV_FILE : 'process env'}` : 'no platform configured — run `metro setup telegram|discord <token>`',
|
|
124
|
+
}];
|
|
125
|
+
for (const [p, getMe] of [['telegram', telegram.getMe], ['discord', discord.getMe]]) {
|
|
126
|
+
if (!cfg[p]) {
|
|
127
|
+
checks.push({ name: p, ok: null, detail: 'not configured' });
|
|
156
128
|
continue;
|
|
157
129
|
}
|
|
158
130
|
try {
|
|
159
131
|
const me = await getMe();
|
|
160
|
-
checks.push({ name:
|
|
132
|
+
checks.push({ name: p, ok: true, detail: `getMe → ${p === 'telegram' ? '@' : ''}${me.username}` });
|
|
161
133
|
}
|
|
162
134
|
catch (err) {
|
|
163
|
-
checks.push({ name:
|
|
135
|
+
checks.push({ name: p, ok: false, detail: errMsg(err) });
|
|
164
136
|
}
|
|
165
137
|
}
|
|
166
138
|
const lockFile = join(STATE_DIR, '.tail-lock');
|
|
167
|
-
if (!existsSync(lockFile))
|
|
139
|
+
if (!existsSync(lockFile))
|
|
168
140
|
checks.push({ name: 'orchestrator', ok: null, detail: 'not running' });
|
|
169
|
-
|
|
170
|
-
else {
|
|
141
|
+
else
|
|
171
142
|
try {
|
|
172
143
|
const pid = Number(readFileSync(lockFile, 'utf8').trim());
|
|
173
144
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -178,16 +149,12 @@ async function cmdDoctor(flags) {
|
|
|
178
149
|
catch {
|
|
179
150
|
checks.push({ name: 'orchestrator', ok: null, detail: 'stale lockfile (will auto-reclaim on next start)' });
|
|
180
151
|
}
|
|
181
|
-
|
|
182
|
-
if (isJson(flags)) {
|
|
152
|
+
if (isJson(flags))
|
|
183
153
|
process.stdout.write(JSON.stringify({ checks }) + '\n');
|
|
184
|
-
}
|
|
185
154
|
else {
|
|
186
155
|
process.stdout.write('metro doctor\n\n');
|
|
187
|
-
for (const c of checks)
|
|
188
|
-
|
|
189
|
-
process.stdout.write(` ${icon} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
190
|
-
}
|
|
156
|
+
for (const c of checks)
|
|
157
|
+
process.stdout.write(` ${c.ok === true ? '✓' : c.ok === false ? '✗' : '–'} ${c.name.padEnd(15)} ${c.detail}\n`);
|
|
191
158
|
process.stdout.write('\n');
|
|
192
159
|
}
|
|
193
160
|
if (checks.some(c => c.ok === false))
|
|
@@ -198,28 +165,23 @@ async function cmdUpdate(flags) {
|
|
|
198
165
|
const res = await fetch('https://registry.npmjs.org/@stage-labs/metro', { signal: AbortSignal.timeout(15_000) });
|
|
199
166
|
if (!res.ok)
|
|
200
167
|
throw new Error(`npm registry: ${res.status}`);
|
|
201
|
-
const
|
|
202
|
-
const latest = data['dist-tags']?.[tag];
|
|
168
|
+
const latest = (await res.json())['dist-tags']?.[tag];
|
|
203
169
|
if (!latest)
|
|
204
170
|
throw new Error(`no '${tag}' dist-tag for @stage-labs/metro`);
|
|
205
|
-
if (latest === pkg.version)
|
|
206
|
-
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
171
|
+
if (latest === pkg.version)
|
|
172
|
+
return emit(flags, `already on ${pkg.version} (latest ${tag})`, { ok: true, current: pkg.version, latest, upgraded: false });
|
|
209
173
|
const argv1 = process.argv[1] ?? '';
|
|
210
174
|
const spec = `@stage-labs/metro@${tag}`;
|
|
211
175
|
const argv = argv1.includes('/.bun/') || argv1.includes('\\bun\\') ? ['bun', 'add', '-g', spec]
|
|
212
176
|
: argv1.includes('/pnpm/') || argv1.includes('\\pnpm\\') ? ['pnpm', 'add', '-g', spec]
|
|
213
177
|
: ['npm', 'install', '-g', spec];
|
|
214
|
-
if (isJson(flags))
|
|
178
|
+
if (isJson(flags))
|
|
215
179
|
process.stdout.write(JSON.stringify({ ok: true, current: pkg.version, latest, command: argv.join(' ') }) + '\n');
|
|
216
|
-
|
|
217
|
-
else {
|
|
180
|
+
else
|
|
218
181
|
process.stdout.write(`metro ${pkg.version} → ${latest}\n$ ${argv.join(' ')}\n`);
|
|
219
|
-
}
|
|
220
182
|
await new Promise((resolve, reject) => {
|
|
221
183
|
const child = spawn(argv[0], argv.slice(1), { stdio: isJson(flags) ? 'ignore' : 'inherit' });
|
|
222
|
-
child.on('exit', code =>
|
|
184
|
+
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${argv[0]} exited ${code}`)));
|
|
223
185
|
child.on('error', reject);
|
|
224
186
|
});
|
|
225
187
|
}
|
|
@@ -230,15 +192,10 @@ const COMMANDS = {
|
|
|
230
192
|
};
|
|
231
193
|
async function main() {
|
|
232
194
|
const cmd = process.argv[2];
|
|
233
|
-
if (cmd === '--version' || cmd === '-v')
|
|
234
|
-
process.stdout.write(`${pkg.version}\n`);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (cmd === '--help' || cmd === '-h') {
|
|
238
|
-
process.stdout.write(USAGE);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
// Bare `metro` runs the orchestrator (the daemon's the default).
|
|
195
|
+
if (cmd === '--version' || cmd === '-v')
|
|
196
|
+
return void process.stdout.write(`${pkg.version}\n`);
|
|
197
|
+
if (cmd === '--help' || cmd === '-h')
|
|
198
|
+
return void process.stdout.write(USAGE);
|
|
242
199
|
if (!cmd) {
|
|
243
200
|
await import('./orchestrator.js');
|
|
244
201
|
return;
|
|
@@ -254,12 +211,10 @@ async function main() {
|
|
|
254
211
|
}
|
|
255
212
|
catch (err) {
|
|
256
213
|
const code = err.code;
|
|
257
|
-
if (isJson(flags))
|
|
214
|
+
if (isJson(flags))
|
|
258
215
|
process.stdout.write(JSON.stringify({ ok: false, error: errMsg(err), code: code ?? 1 }) + '\n');
|
|
259
|
-
|
|
260
|
-
else {
|
|
216
|
+
else
|
|
261
217
|
process.stderr.write(`error: ${errMsg(err)}\n`);
|
|
262
|
-
}
|
|
263
218
|
process.exit(typeof code === 'number' ? code : 1);
|
|
264
219
|
}
|
|
265
220
|
}
|
|
@@ -1,16 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// Lets orchestrator restarts rejoin the same agent conversation in the
|
|
3
|
-
// same Discord thread instead of starting from scratch. JSON file at
|
|
4
|
-
// $STATE_DIR/scopes.json.
|
|
5
|
-
//
|
|
6
|
-
// One Discord thread can have up to one session per agent kind — when a
|
|
7
|
-
// user explicitly switches via "with claude" / "with codex", a fresh
|
|
8
|
-
// session is allocated for the new kind and stored alongside.
|
|
9
|
-
//
|
|
10
|
-
// Scope keys are platform-prefixed so the same store handles Discord and
|
|
11
|
-
// Telegram without collisions:
|
|
12
|
-
// discord:<thread_channel_id>
|
|
13
|
-
// telegram:<chat_id>:<topic_id>
|
|
1
|
+
/** Per-machine scope→{thread ids,last-used} cache. Keys: `discord:<id>` / `telegram:<chat>:<topic>`. */
|
|
14
2
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
3
|
import { join } from 'node:path';
|
|
16
4
|
import { errMsg, log } from '../log.js';
|
|
@@ -72,14 +60,6 @@ export function setLastSeen(scopeKey, messageId) {
|
|
|
72
60
|
export function listScopes() {
|
|
73
61
|
return Object.entries(read()).map(([scopeKey, entry]) => ({ scopeKey, entry }));
|
|
74
62
|
}
|
|
75
|
-
export
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
export function discordChannelFromScopeKey(scopeKey) {
|
|
79
|
-
return scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
|
|
80
|
-
}
|
|
81
|
-
// Telegram scope: chat + optional forum-topic id. Use a sentinel ('main')
|
|
82
|
-
// for non-topic chats so the key shape stays predictable.
|
|
83
|
-
export function telegramScopeKey(chatId, topicId) {
|
|
84
|
-
return `telegram:${chatId}:${topicId ?? 'main'}`;
|
|
85
|
-
}
|
|
63
|
+
export const discordScopeKey = (threadChannelId) => `discord:${threadChannelId}`;
|
|
64
|
+
export const discordChannelFromScopeKey = (scopeKey) => scopeKey.startsWith('discord:') ? scopeKey.slice('discord:'.length) : null;
|
|
65
|
+
export const telegramScopeKey = (chatId, topicId) => `telegram:${chatId}:${topicId ?? 'main'}`;
|