@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.
@@ -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, used by tail.ts only.
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 subcommands stay one-shot and fast.
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
- // Guild messages: only forward when the bot is mentioned. DMs always pass.
66
- // The bot's own @-mention is preserved in `m.content` stripping it would
67
- // lose mid-sentence position ("Wdyt @Metro is this good?" "Wdyt is this
68
- // good?") and silently drop bare-mention pings. The agent recognizes
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({ channel_id: m.channelId, message_id: m.id, text });
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
- export async function replyToMessage(channelId, messageId, text) {
99
- const sent = await rest('POST', `/channels/${channelId}/messages`, {
100
- content: text,
101
- message_reference: { message_id: messageId, fail_if_not_exists: false },
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 sent.id;
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
- export async function sendTyping(channelId) {
110
- await rest('POST', `/channels/${channelId}/typing`);
111
- }
112
- export async function setReaction(channelId, messageId, emoji) {
113
- if (emoji) {
114
- await rest('PUT', `/channels/${channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`);
115
- return;
116
- }
117
- // Clear only the bot's own reactions (matches Telegram's clear semantics).
118
- const msg = await rest('GET', `/channels/${channelId}/messages/${messageId}`);
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
- timestamp: m.timestamp,
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
- export async function tg(method, body, timeoutMs = 30_000) {
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: AbortSignal.timeout(timeoutMs),
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
- export function buildSendBody(chatId, text, opts) {
25
- const body = { chat_id: chatId, text };
26
- if (opts.parseMode)
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
- return tg('getMe', {});
33
+ const me = await tg('getMe', {});
34
+ botUsername = me.username;
35
+ botUserId = me.id;
36
+ return me;
36
37
  }
37
- const CACHE_MAX = 200;
38
- const cacheFile = join(STATE_DIR, 'telegram-attachments.json');
39
- function readCache() {
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
- return existsSync(cacheFile) ? JSON.parse(readFileSync(cacheFile, 'utf8')) : {};
47
+ if (!existsSync(offsetFile))
48
+ return 0;
49
+ return Number(readFileSync(offsetFile, 'utf8').trim()) || 0;
42
50
  }
43
- catch (err) {
44
- log.warn({ err: errMsg(err) }, 'telegram attachment cache read failed');
45
- return {};
51
+ catch {
52
+ return 0;
46
53
  }
47
54
  }
48
- function cacheAttachment(chatId, messageId, att) {
55
+ function saveOffset(o) {
49
56
  try {
50
- const data = readCache();
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 attachment cache write failed');
60
+ log.warn({ err: errMsg(err) }, 'telegram offset save failed');
60
61
  }
61
62
  }
62
- export function getCachedAttachments(chatId, messageId) {
63
- return readCache()[`${chatId}:${messageId}`] ?? [];
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
- export async function downloadAttachment(fileId, mime) {
66
- const file = await tg('getFile', { file_id: fileId });
67
- const res = await fetch(`${API_BASE}/file/bot${token()}/${file.file_path}`, {
68
- signal: AbortSignal.timeout(30_000),
69
- });
70
- if (!res.ok)
71
- throw new Error(`download failed: ${res.status}`);
72
- const buf = Buffer.from(await res.arrayBuffer());
73
- // Trust the cached mime — it's the authoritative one from the message
74
- // metadata (`image/jpeg` for photos, the document's mime_type for files).
75
- // The Telegram CDN often returns `application/octet-stream` as Content-Type,
76
- // which would otherwise wipe out our extension classification downstream.
77
- return { data: buf.toString('base64'), mime };
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
- async function messageToText(m, chatId) {
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
- let onInboundHandler = () => { };
99
- export function onInbound(handler) {
100
- onInboundHandler = handler;
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 startPolling() {
103
- // A registered webhook short-circuits getUpdates clear it defensively.
104
- await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
105
- let offset = 0;
106
- const initial = await tg('getUpdates', { timeout: 0 });
107
- if (initial.length)
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 dispatchUpdate(u) {
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
- const text = await messageToText(m, m.chat.id);
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
- onInboundHandler({ ...base, text: `[message processing failed: ${errMsg(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
+ if (errMsg(err).includes('message is not modified'))
206
+ return;
207
+ throw err;
136
208
  }
137
209
  }