@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.
- package/README.md +70 -31
- package/dist/agents/claude.js +207 -0
- package/dist/agents/codex.js +207 -0
- package/dist/agents/types.js +2 -0
- package/dist/channels/discord.js +36 -80
- package/dist/channels/telegram.js +136 -84
- package/dist/cli.js +68 -420
- package/dist/helpers/scope-cache.js +65 -0
- 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 +208 -0
- package/dist/paths.js +52 -29
- package/package.json +2 -3
- package/dist/lib/address.js +0 -21
- package/dist/lib/codex-rc.js +0 -274
- package/dist/lib/dotenv.js +0 -31
- package/dist/tail.js +0 -161
- package/skills/metro/SKILL.md +0 -122
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 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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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({
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
+
const me = await tg('getMe', {});
|
|
32
|
+
botUsername = me.username;
|
|
33
|
+
botUserId = me.id;
|
|
34
|
+
return me;
|
|
36
35
|
}
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
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(
|
|
43
|
+
return existsSync(offsetFile) ? Number(readFileSync(offsetFile, 'utf8').trim()) || 0 : 0;
|
|
42
44
|
}
|
|
43
|
-
catch
|
|
44
|
-
|
|
45
|
-
return {};
|
|
45
|
+
catch {
|
|
46
|
+
return 0;
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
|
-
function
|
|
49
|
+
function saveOffset(o) {
|
|
49
50
|
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));
|
|
51
|
+
writeFileSync(offsetFile, String(o));
|
|
57
52
|
}
|
|
58
53
|
catch (err) {
|
|
59
|
-
log.warn({ err: errMsg(err) }, 'telegram
|
|
54
|
+
log.warn({ err: errMsg(err) }, 'telegram offset save failed');
|
|
60
55
|
}
|
|
61
56
|
}
|
|
62
|
-
export function
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
export function
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|