@stage-labs/metro 0.1.0-beta.2 → 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.
@@ -1,8 +1,6 @@
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.
4
- // Send path: raw REST against discord.com/api — no gateway login required,
5
- // so cli.ts subcommands stay one-shot and fast.
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;
@@ -10,7 +8,7 @@ function token() {
10
8
  throw new Error('DISCORD_BOT_TOKEN is not set');
11
9
  return t;
12
10
  }
13
- async function rest(method, path, body, timeoutMs = 30_000) {
11
+ async function rest(method, path, body, timeoutMs = 30_000, retriesLeft = 2) {
14
12
  const res = await fetch(`${API_BASE}${path}`, {
15
13
  method,
16
14
  headers: {
@@ -21,36 +19,34 @@ async function rest(method, path, body, timeoutMs = 30_000) {
21
19
  body: body !== undefined ? JSON.stringify(body) : undefined,
22
20
  signal: AbortSignal.timeout(timeoutMs),
23
21
  });
22
+ /** 429: honor `retry_after` (seconds) and retry, up to a few hops. */
23
+ if (res.status === 429 && retriesLeft > 0) {
24
+ const retryAfter = Number(res.headers.get('retry-after')) || 1;
25
+ log.debug({ path, retryAfter }, 'discord 429; backing off');
26
+ await new Promise(r => setTimeout(r, Math.max(retryAfter * 1000, 250)));
27
+ return rest(method, path, body, timeoutMs, retriesLeft - 1);
28
+ }
24
29
  if (!res.ok) {
25
30
  const text = await res.text().catch(() => '');
26
31
  throw new Error(`discord ${method} ${path}: ${res.status} ${text}`);
27
32
  }
28
- // 204 No Content for typing/reactions/clear.
29
33
  if (res.status === 204)
30
34
  return undefined;
31
35
  return (await res.json());
32
36
  }
33
- // ---------- Receive path (gateway, discord.js) -----------------------------
34
37
  let client = null;
35
38
  function getClient() {
36
39
  if (client)
37
40
  return client;
38
41
  client = new Client({
39
- intents: [
40
- GatewayIntentBits.DirectMessages,
41
- GatewayIntentBits.Guilds,
42
- GatewayIntentBits.GuildMessages,
43
- GatewayIntentBits.MessageContent,
44
- ],
45
- // 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. */
46
44
  partials: [Partials.Channel],
47
45
  });
48
46
  return client;
49
47
  }
50
48
  let onInboundHandler = () => { };
51
- export function onInbound(handler) {
52
- onInboundHandler = handler;
53
- }
49
+ export const onInbound = (handler) => { onInboundHandler = handler; };
54
50
  export async function shutdownGateway() {
55
51
  if (!client)
56
52
  return;
@@ -62,26 +58,20 @@ export async function startGateway() {
62
58
  c.on(Events.MessageCreate, m => {
63
59
  if (m.author.bot)
64
60
  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;
72
- const tags = [...m.attachments.values()]
73
- .map(a => {
74
- if (a.contentType?.startsWith('image/'))
75
- return '[image]';
76
- if (a.contentType?.startsWith('audio/'))
77
- return `[audio: ${a.name}]`;
78
- return `[file: ${a.name}]`;
79
- })
80
- .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(' ');
81
65
  const text = [m.content, tags].filter(Boolean).join(' ').trim();
82
66
  if (!text)
83
67
  return;
84
- onInboundHandler({ channel_id: m.channelId, message_id: m.id, text });
68
+ onInboundHandler({
69
+ channel_id: m.channelId,
70
+ message_id: m.id,
71
+ text,
72
+ in_guild: !!m.guildId,
73
+ mentions_bot: c.user ? m.mentions.has(c.user.id) : false,
74
+ });
85
75
  });
86
76
  c.on(Events.Error, err => log.error({ err: errMsg(err) }, 'discord error'));
87
77
  await c.login(process.env.DISCORD_BOT_TOKEN);
@@ -91,58 +81,24 @@ export async function getMe() {
91
81
  const me = await rest('GET', '/users/@me');
92
82
  return { username: me.username };
93
83
  }
84
+ /** SUPPRESS_EMBEDS (1 << 2) — strip link unfurls from every bot message. */
85
+ const SUPPRESS_EMBEDS = 1 << 2;
94
86
  export async function sendMessage(channelId, text) {
95
- const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text });
87
+ const sent = await rest('POST', `/channels/${channelId}/messages`, { content: text, flags: SUPPRESS_EMBEDS });
96
88
  return sent.id;
97
89
  }
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 },
102
- });
103
- return sent.id;
90
+ /** Public thread anchored on `messageId`. Returns the new thread channel id. */
91
+ export async function createThreadFromMessage(channelId, messageId, name) {
92
+ const created = await rest('POST', `/channels/${channelId}/messages/${messageId}/threads`, { name, auto_archive_duration: 1440 });
93
+ return created.id;
104
94
  }
105
95
  export async function editMessage(channelId, messageId, text) {
106
- 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 });
107
97
  return sent.id;
108
98
  }
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.
142
- return [...msgs].reverse().map(m => ({
143
- message_id: m.id,
144
- author: m.author.username,
145
- text: m.content,
146
- timestamp: m.timestamp,
147
- }));
99
+ /** Catchup-on-restart: fetch messages newer than `afterMessageId` (cap 100). */
100
+ export async function fetchMessagesSince(channelId, afterMessageId) {
101
+ const msgs = await rest('GET', `/channels/${channelId}/messages?after=${afterMessageId}&limit=100`);
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 }));
148
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;
@@ -9,129 +10,180 @@ function token() {
9
10
  throw new Error('TELEGRAM_BOT_TOKEN is not set');
10
11
  return t;
11
12
  }
12
- export async function tg(method, body, timeoutMs = 30_000) {
13
+ async function tg(method, body, opts = {}) {
14
+ const signals = [AbortSignal.timeout(opts.timeoutMs ?? 30_000)];
15
+ if (opts.signal)
16
+ signals.push(opts.signal);
13
17
  const res = await fetch(`${API_BASE}/bot${token()}/${method}`, {
14
18
  method: 'POST',
15
19
  headers: { 'Content-Type': 'application/json' },
16
20
  body: JSON.stringify(body),
17
- signal: AbortSignal.timeout(timeoutMs),
21
+ signal: AbortSignal.any(signals),
18
22
  });
19
23
  const json = (await res.json());
20
24
  if (!json.ok)
21
25
  throw new Error(`telegram ${method}: ${json.description ?? 'unknown error'}`);
22
26
  return json.result;
23
27
  }
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
- }
28
+ let botUsername = null;
29
+ let botUserId = null;
34
30
  export async function getMe() {
35
- return tg('getMe', {});
31
+ const me = await tg('getMe', {});
32
+ botUsername = me.username;
33
+ botUserId = me.id;
34
+ return me;
36
35
  }
37
- const CACHE_MAX = 200;
38
- const cacheFile = join(STATE_DIR, 'telegram-attachments.json');
39
- function readCache() {
36
+ let onInboundHandler = () => { };
37
+ export const onInbound = (handler) => { onInboundHandler = handler; };
38
+ const offsetFile = join(STATE_DIR, 'telegram-offset.json');
39
+ let pollOffset = 0;
40
+ let pollAbort = null;
41
+ function loadOffset() {
40
42
  try {
41
- return existsSync(cacheFile) ? JSON.parse(readFileSync(cacheFile, 'utf8')) : {};
43
+ return existsSync(offsetFile) ? Number(readFileSync(offsetFile, 'utf8').trim()) || 0 : 0;
42
44
  }
43
- catch (err) {
44
- log.warn({ err: errMsg(err) }, 'telegram attachment cache read failed');
45
- return {};
45
+ catch {
46
+ return 0;
46
47
  }
47
48
  }
48
- function cacheAttachment(chatId, messageId, att) {
49
+ function saveOffset(o) {
49
50
  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));
51
+ writeFileSync(offsetFile, String(o));
57
52
  }
58
53
  catch (err) {
59
- log.warn({ err: errMsg(err) }, 'telegram attachment cache write failed');
54
+ log.warn({ err: errMsg(err) }, 'telegram offset save failed');
60
55
  }
61
56
  }
62
- export function getCachedAttachments(chatId, messageId) {
63
- return readCache()[`${chatId}:${messageId}`] ?? [];
57
+ export async function startPolling() {
58
+ await tg('deleteWebhook', { drop_pending_updates: false }).catch(() => { });
59
+ const persisted = loadOffset();
60
+ if (persisted > 0) {
61
+ pollOffset = persisted;
62
+ log.info({ offset: pollOffset }, 'telegram polling: resuming from persisted offset');
63
+ }
64
+ else {
65
+ /** First run: anchor on latest update id (-1 returns most recent without consuming). */
66
+ const initial = await tg('getUpdates', { offset: -1, timeout: 0 });
67
+ pollOffset = initial.length ? initial[0].update_id + 1 : 0;
68
+ saveOffset(pollOffset);
69
+ log.info({ offset: pollOffset }, 'telegram polling: starting fresh');
70
+ }
71
+ pollAbort = new AbortController();
72
+ void pollLoop();
64
73
  }
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),
74
+ async function pollLoop() {
75
+ const abortSignal = pollAbort?.signal;
76
+ while (pollAbort && !pollAbort.signal.aborted) {
77
+ try {
78
+ const updates = await tg('getUpdates', { offset: pollOffset, timeout: 25 }, { timeoutMs: 60_000, signal: abortSignal });
79
+ for (const u of updates) {
80
+ pollOffset = u.update_id + 1;
81
+ await dispatchUpdate(u);
82
+ }
83
+ if (updates.length)
84
+ saveOffset(pollOffset);
85
+ }
86
+ catch (err) {
87
+ if (pollAbort?.signal.aborted)
88
+ break;
89
+ log.warn({ err: errMsg(err) }, 'telegram poll error; backing off');
90
+ await new Promise(r => setTimeout(r, 2000));
91
+ }
92
+ }
93
+ log.info('telegram polling stopped');
94
+ }
95
+ export async function shutdownPolling() { pollAbort?.abort(); pollAbort = null; }
96
+ async function dispatchUpdate(u) {
97
+ const m = u.message;
98
+ if (!m?.chat?.id || typeof m.message_id !== 'number' || m.from?.is_bot)
99
+ return;
100
+ const text = messageToText(m);
101
+ if (!text)
102
+ return;
103
+ onInboundHandler({
104
+ chat_id: m.chat.id,
105
+ message_id: m.message_id,
106
+ message_thread_id: m.is_topic_message ? m.message_thread_id : undefined,
107
+ text,
108
+ is_private: m.chat.type === 'private',
109
+ is_forum_topic: !!m.is_topic_message,
110
+ in_forum: !!m.chat.is_forum,
111
+ mentions_bot: detectMentionsBot(m),
69
112
  });
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 };
78
113
  }
79
- async function messageToText(m, chatId) {
114
+ function detectMentionsBot(m) {
115
+ if (m.chat?.type === 'private')
116
+ return true;
117
+ const text = m.text ?? m.caption ?? '';
118
+ for (const e of m.entities ?? m.caption_entities ?? []) {
119
+ if (e.type === 'mention' && botUsername) {
120
+ if (text.substring(e.offset, e.offset + e.length).toLowerCase() === `@${botUsername.toLowerCase()}`)
121
+ return true;
122
+ }
123
+ else if (e.type === 'text_mention' && e.user?.id === botUserId)
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ function messageToText(m) {
80
129
  if (m.text)
81
130
  return m.text;
82
131
  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' });
86
- 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 });
132
+ if (m.photo?.length || m.document?.mime_type?.startsWith('image/'))
90
133
  return [caption, '[image]'].filter(Boolean).join(' ');
91
- }
92
134
  if (m.voice)
93
135
  return [caption, '[voice]'].filter(Boolean).join(' ');
94
136
  if (m.audio)
95
137
  return [caption, '[audio]'].filter(Boolean).join(' ');
96
138
  return caption || null;
97
139
  }
98
- let onInboundHandler = () => { };
99
- export function onInbound(handler) {
100
- onInboundHandler = handler;
140
+ /** Create a forum topic; requires `can_manage_topics` admin permission. */
141
+ export async function createForumTopic(chatId, name) {
142
+ const r = await tg('createForumTopic', { chat_id: chatId, name: name.slice(0, 128) || 'metro' });
143
+ return r.message_thread_id;
101
144
  }
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
- }
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 };
155
+ if (threadId !== undefined)
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;
121
167
  }
122
168
  }
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 };
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; };
128
173
  try {
129
- const text = await messageToText(m, m.chat.id);
130
- if (text === null)
131
- return;
132
- onInboundHandler({ ...base, text });
174
+ await tg('editMessageText', { ...base, text: mdToTelegramHtml(text), parse_mode: 'HTML' });
133
175
  }
134
176
  catch (err) {
135
- onInboundHandler({ ...base, text: `[message processing failed: ${errMsg(err)}]` });
177
+ if (errMsg(err).includes('message is not modified'))
178
+ return;
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
+ }
136
188
  }
137
189
  }