#!/usr/bin/env node // ───────────────────────────────────────────────────────────────────────────── // Пример бота Falcon Black: Node.js 20+ без библиотек (только встроенный fetch). // Эхо (текст, фото, файлы, видео и голосовые — обратно файлом), кнопки под // сообщением с нажатием, клавиатура-ответы, меню команд на трёх языках (в том // числе кириллические команды), длинный опрос getUpdates. // // Проверен против сервера Falcon Black 01.10.2026: эхо текста, фото и файлов, // кнопки (люди пока видят их списком с номерами и отвечают цифрой), меню команд. // // Запуск: // export FB_BOT_TOKEN='…ключ со страницы falconblack.org/bots/key…' // node full.mjs // Ключ — только в переменной окружения: не в коде, не в строке запроса (там он // сразу отзывается) и не в переписке (там тоже). // ───────────────────────────────────────────────────────────────────────────── import zlib from "node:zlib"; const API = (process.env.FB_API || "https://falconblack.org/bot-api/v1").replace(/\/+$/, ""); const TOKEN = (process.env.FB_BOT_TOKEN || "").trim(); // перевод строки из .env ломает ключ // ── Ключ: форма и сумма проверяются до первого запроса ──── // <номер бота>:fb1_<секрет 43><сумма 6>. Сумма — CRC32 (как в zlib) от всего, // что до неё, в UTF-8, записанная 6 знаками base62 (0-9A-Za-z), старшие слева. const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; function crc32(s) { if (zlib.crc32) return zlib.crc32(s); // Node 20.15+ и 22 let c = ~0; for (const b of Buffer.from(s, "utf8")) { c ^= b; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; } return ~c >>> 0; } function tokenProblem(t) { const m = /^(\d{10,16}:fb1_[0-9A-Za-z]{43})([0-9A-Za-z]{6})$/.exec(t); if (!m) return "form"; let n = crc32(m[1]); let sum = ""; for (let i = 0; i < 6; i++) { sum = B62[n % 62] + sum; n = Math.floor(n / 62); } return sum === m[2] ? null : "checksum"; } { const p = tokenProblem(TOKEN); if (p === "form") { console.error("Нет ключа или он не той формы. Положите ключ бота в переменную FB_BOT_TOKEN."); process.exit(2); } if (p === "checksum") { console.error("В ключе опечатка: не сходится его контрольная сумма. Скопируйте ключ заново " + "(или выпустите новый в Гнезде ботов: /token)."); process.exit(2); } } const UPDATE_TYPES = ["message", "edited_message", "callback_query", "my_chat_member"]; // 4.4 const POLL_TIMEOUT_S = 50; // больше 50 сервер молча урежет const MAX_WAIT_S = 60; // дольше минуты внутри вызова не ждём: это суточный предел (quota) const MAX_TEXT = 4096; // в единицах UTF-16 — как длина строки в JS const log = (...a) => console.log(new Date().toISOString(), ...a); const warn = (...a) => console.warn(new Date().toISOString(), "!", ...a); const stop = new AbortController(); // ── Вызов Bot API ──────────────────────────────────────────────────────────── class ApiError extends Error { constructor(httpStatus, body, requestId, retryAfterHeader) { super(body?.description || `HTTP ${httpStatus}, ответ не JSON`); this.code = body?.error_code ?? httpStatus; this.key = body?.error_key ?? null; // наш машинный код ошибки this.params = body?.parameters ?? {}; this.retryAfter = this.params.retry_after ?? (Number(retryAfterHeader) || null); this.requestId = requestId; // X-Request-Id: его видно и в /status this.notJson = !body; // страница прокси: временный сбой } } function sleep(ms, signal = stop.signal) { return new Promise((resolve) => { if (signal.aborted) return resolve(); const t = setTimeout(resolve, ms); signal.addEventListener("abort", () => { clearTimeout(t); resolve(); }, { once: true }); }); } // Один запрос. Рекомендуемый вход: ключ в заголовке, адрес без ключа. async function rawCall(method, params = {}, { idempotencyKey, timeoutMs = 30_000, signal } = {}) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(new Error(`нет ответа ${timeoutMs / 1000} с`)), timeoutMs); const onStop = () => ctrl.abort(new Error("остановка")); signal?.addEventListener("abort", onStop, { once: true }); try { const headers = { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" }; // Ключ повтора: [A-Za-z0-9_-], до 64 знаков. Можно и параметром тела // falcon_idempotency_key — так его передают через библиотеки. if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const res = await fetch(`${API}/${method}`, { method: "POST", headers, body: JSON.stringify(params), signal: ctrl.signal, }); const text = await res.text(); let body = null; try { body = JSON.parse(text); } catch { /* не JSON — разберёмся ниже */ } if (body?.ok === true) return body.result; throw new ApiError(res.status, body, res.headers.get("x-request-id"), res.headers.get("retry-after")); } finally { clearTimeout(timer); signal?.removeEventListener("abort", onStop); } } // Что можно повторить после обрыва связи, не боясь двойного действия. // Отправку (send*) повторяем только с ключом повтора: второго сообщения не будет. const REPEATABLE = new Set([ "getMe", "getWebhookInfo", "getMyCommands", "setMyCommands", "editMessageText", "editMessageReplyMarkup", "sendChatAction", ]); async function call(method, params = {}, opts = {}) { for (let attempt = 1; ; attempt++) { try { return await rawCall(method, params, opts); } catch (e) { if (stop.signal.aborted) throw e; const api = e instanceof ApiError; if (api && e.code === 429 && e.key !== "quota") { const wait = e.retryAfter ?? 1; if (wait > MAX_WAIT_S || attempt > 5) throw e; await sleep(wait * 1000); continue; // тот же ключ повтора — дубля не будет } const transient = !api || e.notJson || e.code >= 500; const canRepeat = REPEATABLE.has(method) || Boolean(opts.idempotencyKey); if (transient && canRepeat && attempt <= 5) { await sleep(Math.min(2 ** attempt, 30) * 1000); continue; } throw e; } } } const isErr = (e, key, text) => e instanceof ApiError && (e.key === key || (text && e.message.includes(text))); const REVOKE_REASON = { reissue: "выпущен новый ключ", owner: "хозяин отозвал ключ", leak_message: "ключ попал в переписку", leak_url: "ключ попал в строку запроса (?token=…)", transfer: "бота передали другому хозяину", bot_deleted: "бот удалён", owner_deleted: "учётка хозяина удалена", admin: "решение команды Falcon Black", }; function explain(e) { if (!(e instanceof ApiError)) return `нет связи с ${API}: ${e.cause?.code || e.message}`; const id = e.requestId ? ` [X-Request-Id ${e.requestId}]` : ""; switch (e.key) { case "token_malformed": return `Сервер не узнал форму ключа. Скопируйте его заново.${id}`; case "token_checksum": return `В ключе опечатка (не сходится сумма). Скопируйте его заново.${id}`; case "token_unknown": return `Такого ключа нет. Выпустите новый: Гнездо ботов → /token.${id}`; case "key_in_url": case "token_revoked": { const why = REVOKE_REASON[e.params.revoke_reason] ?? (e.key === "key_in_url" ? REVOKE_REASON.leak_url : "ключ отозван"); return `Ключ больше не действует: ${why}. Выпустите новый: Гнездо ботов → /token.${id}`; } case "poll_replaced": return "Запущена вторая копия бота с тем же ключом, она забрала опрос. Эта копия выходит."; case "webhook_active": return "У бота включён вебхук, поэтому длинный опрос не работает. Снимите его: deleteWebhook или Гнездо ботов → /status."; case "bot_frozen": return `Бот отключён командой Falcon Black. Подробности — в Гнезде ботов.${id}`; case "quota": return `${e.message} (суточный предел; сброс в 00:00 UTC).${id}`; default: if (e.code === 401) return `Ключ не подходит (${e.message}).${id}`; return `${e.code} ${e.message}${e.key ? ` (${e.key})` : ""}${id}`; } } function fatal(e) { console.error(explain(e)); process.exit(1); } // ── Тексты на трёх языках (язык человека приходит в from.language_code) ────── const TEXT = { ru: { hello: (name) => `Здравствуйте${name ? `, ${name}` : ""}! Я пример бота: повторяю ваши сообщения и показываю кнопки.`, fromLink: (p) => `Вы пришли по ссылке с меткой «${p}».`, help: "Что я умею:\n/кнопки — кнопки под сообщением\n/клавиатура — кнопки вместо клавиатуры\n/убрать — убрать их\nЛюбой другой текст я повторю.", showButtons: "Показать кнопки", showKeyboard: "Показать клавиатуру", counter: (n) => `Счётчик: ${n}`, less: "‹ Меньше", more: "Больше ›", copy: "Скопировать код", site: "Сайт для авторов ботов", keyboardOn: "Нажмите кнопку внизу — или напишите то же самое руками.", kbDice: "Кубик", kbTime: "Который час", kbHide: "Убрать клавиатуру", kbHint: "Выберите кнопку", keyboardOff: "Клавиатуру убрал.", dice: (n) => `Выпало ${n}.`, time: (s) => `У бота сейчас ${s}.`, echo: (t) => `Вы написали: ${t}`, edited: (t) => `Вы исправили сообщение: ${t}`, photo: "Ваша картинка — возвращаю.", doc: "Ваш файл — возвращаю.", media: "Возвращаю файлом.", noContent: (k) => `Получил «${k}». Такие сообщения приходят ботам без содержимого.`, place: (la, lo) => `Вы прислали место: ${la}, ${lo}.`, unknown: "Такое сообщение я не понимаю. /помощь", unknownCommand: (c) => `Команды /${c} у меня нет. /помощь`, stale: "Эта кнопка больше не работает.", }, en: { hello: (name) => `Hello${name ? `, ${name}` : ""}! I am an example bot: I repeat your messages and show buttons.`, fromLink: (p) => `You came via a link tagged "${p}".`, help: "What I can do:\n/buttons — buttons under a message\n/keyboard — buttons instead of the keyboard\n/hide — hide them\nAnything else, I repeat.", showButtons: "Show buttons", showKeyboard: "Show keyboard", counter: (n) => `Counter: ${n}`, less: "‹ Less", more: "More ›", copy: "Copy the code", site: "Site for bot authors", keyboardOn: "Tap a button below — or type the same text yourself.", kbDice: "Dice", kbTime: "What time is it", kbHide: "Hide keyboard", kbHint: "Choose a button", keyboardOff: "Keyboard hidden.", dice: (n) => `You rolled ${n}.`, time: (s) => `It is ${s} where the bot runs.`, echo: (t) => `You wrote: ${t}`, edited: (t) => `You edited a message: ${t}`, photo: "Your picture — sending it back.", doc: "Your file — sending it back.", media: "Sending it back as a file.", noContent: (k) => `Got "${k}". Such messages reach bots without content.`, place: (la, lo) => `You sent a location: ${la}, ${lo}.`, unknown: "I don't understand this kind of message. /help", unknownCommand: (c) => `I have no /${c} command. /help`, stale: "This button no longer works.", }, uk: { hello: (name) => `Вітаю${name ? `, ${name}` : ""}! Я приклад бота: повторюю ваші повідомлення й показую кнопки.`, fromLink: (p) => `Ви прийшли за посиланням з міткою «${p}».`, help: "Що я вмію:\n/кнопки — кнопки під повідомленням\n/клавіатура — кнопки замість клавіатури\n/прибрати — прибрати їх\nБудь-який інший текст я повторю.", showButtons: "Показати кнопки", showKeyboard: "Показати клавіатуру", counter: (n) => `Лічильник: ${n}`, less: "‹ Менше", more: "Більше ›", copy: "Скопіювати код", site: "Сайт для авторів ботів", keyboardOn: "Натисніть кнопку внизу — або напишіть те саме вручну.", kbDice: "Кубик", kbTime: "Котра година", kbHide: "Прибрати клавіатуру", kbHint: "Оберіть кнопку", keyboardOff: "Клавіатуру прибрав.", dice: (n) => `Випало ${n}.`, time: (s) => `У бота зараз ${s}.`, echo: (t) => `Ви написали: ${t}`, edited: (t) => `Ви виправили повідомлення: ${t}`, photo: "Ваша картинка — повертаю.", doc: "Ваш файл — повертаю.", media: "Повертаю файлом.", noContent: (k) => `Отримав «${k}». Такі повідомлення приходять ботам без вмісту.`, place: (la, lo) => `Ви надіслали місце: ${la}, ${lo}.`, unknown: "Такого повідомлення я не розумію. /допомога", unknownCommand: (c) => `Команди /${c} у мене немає. /допомога`, stale: "Ця кнопка більше не працює.", }, }; const lang = (code) => { const c = String(code || "").slice(0, 2); return c === "ru" || c === "uk" ? c : "en"; }; // Меню «/»: по умолчанию — английское, для ru и uk — свои (setMyCommands с language_code, 4.3). // Кириллица в командах разрешена, «start» в меню не кладём — его заменяет «Начать». // Через API команды не «исправляются»: /всё сервер не превратит в /все — пишите ровно так, // как люди будут набирать (иначе 400 command_invalid). const MENU = { "": [ { command: "help", description: "What this bot can do" }, { command: "buttons", description: "Buttons under a message" }, { command: "keyboard", description: "Buttons instead of the keyboard" }, { command: "hide", description: "Hide those buttons" }, ], ru: [ { command: "помощь", description: "Что умеет бот" }, { command: "кнопки", description: "Кнопки под сообщением" }, { command: "клавиатура", description: "Кнопки вместо клавиатуры" }, { command: "убрать", description: "Убрать эти кнопки" }, ], uk: [ { command: "допомога", description: "Що вміє бот" }, { command: "кнопки", description: "Кнопки під повідомленням" }, { command: "клавіатура", description: "Кнопки замість клавіатури" }, { command: "прибрати", description: "Прибрати ці кнопки" }, ], }; const COMMANDS = { start: "start", help: "help", помощь: "help", допомога: "help", buttons: "buttons", кнопки: "buttons", keyboard: "keyboard", клавиатура: "keyboard", клавіатура: "keyboard", hide: "hide", убрать: "hide", прибрати: "hide", }; // ── Отправка ───────────────────────────────────────────────────────────────── const stopped = new Set(); // кто остановил бота или удалил учётку: писать им бесполезно (403) let me = null; // getMe // ctx = { chatId, updateId, n }. Ключ повтора = номер события + номер ответа на него: // если программа упадёт и получит то же событие снова, сервер не пришлёт второй ответ. async function send(ctx, method, params) { if (stopped.has(ctx.chatId)) return null; const idempotencyKey = `u${ctx.updateId}-${ctx.n++}`; try { return await call(method, { chat_id: ctx.chatId, ...params }, { idempotencyKey }); } catch (e) { // 403: человек остановил бота («bot was blocked by the user») или удалил учётку // («user is deactivated»). Вычёркиваем, пока он сам не напишет снова. if (isErr(e, "blocked", "bot was blocked by the user") || isErr(e, "user_deleted", "user is deactivated")) { stopped.add(ctx.chatId); return null; } if (isErr(e, "chat_not_found", "chat not found")) return null; // Сообщение, на которое отвечаем, уже удалено — отвечаем без цитаты. if (isErr(e, "reply_not_found") && params.reply_parameters) { const { reply_parameters, ...rest } = params; return send(ctx, method, rest); } throw e; } } const say = (ctx, text, extra = {}) => send(ctx, "sendMessage", { text, ...extra }); // До 4 096 единиц UTF-16 (как длина строки в JS), считается после снятия разметки. // Не режем посередине пары суррогатов (эмодзи). function clip(s, max = MAX_TEXT) { if (s.length <= max) return s; let cut = max - 1; if (/[\uD800-\uDBFF]/.test(s[cut - 1])) cut--; return s.slice(0, cut) + "…"; } function counterMarkup(n, L, withLink) { const T = TEXT[L]; const rows = [ [{ text: T.less, callback_data: `cnt:${n - 1}` }, { text: T.more, callback_data: `cnt:${n + 1}` }], [{ text: T.copy, copy_text: { text: "FB-2026" } }], ]; if (withLink) rows.push([{ text: T.site, url: "https://falconblack.org/bots" }]); return { inline_keyboard: rows }; } async function showCounter(ctx, n, L) { try { return await say(ctx, TEXT[L].counter(n), { reply_markup: counterMarkup(n, L, true) }); } catch (e) { // Первые 3 дня бот шлёт ссылки только хозяину (3.5; до какого числа — в getMe.falcon_trust_until). if (isErr(e, "links_new_bot")) return say(ctx, TEXT[L].counter(n), { reply_markup: counterMarkup(n, L, false) }); throw e; } } function showKeyboard(ctx, L) { const T = TEXT[L]; return say(ctx, T.keyboardOn, { reply_markup: { keyboard: [[T.kbDice, T.kbTime], [T.kbHide]], resize_keyboard: true, input_field_placeholder: T.kbHint }, }); } // Нажатие клавиатуры-ответов приходит обычным текстом — узнаём надпись на любом из трёх языков. function keyboardWord(text) { const t = text.trim().toLowerCase(); for (const T of Object.values(TEXT)) { if (t === T.kbDice.toLowerCase()) return "dice"; if (t === T.kbTime.toLowerCase()) return "time"; if (t === T.kbHide.toLowerCase()) return "hide"; } return null; } // Команда в начале сообщения. Сервер ставит entities bot_command и у кириллических // команд, смещения — в единицах UTF-16 (как строки JS). Свой разбор — только запасной. function commandOf(msg) { const text = msg.text ?? ""; let head = null; const ent = (msg.entities ?? []).find((e) => e.type === "bot_command" && e.offset === 0); if (ent) head = text.slice(0, ent.length); else { const m = /^\/[\p{L}\p{N}_]{1,32}(?:@[A-Za-z0-9_]{5,32})?(?=\s|$)/u.exec(text); if (m) head = m[0]; } if (!head) return null; const [name, at] = head.slice(1).split("@"); if (at && at.toLowerCase() !== me.username.toLowerCase()) return null; // команда другому боту return { name: name.toLowerCase(), args: text.slice(head.length).trim() }; } // ── Обработка событий ──────────────────────────────────────────────────────── async function onCommand(ctx, msg, cmd, L) { const T = TEXT[L]; switch (COMMANDS[cmd.name]) { case "start": { // «Начать» (и первое сообщение из старой версии приложения — сервер сам // ставит перед ним «/start») присылает «/start» или «/start ПАРАМЕТР». const lines = [T.hello(msg.from?.first_name)]; if (cmd.args) lines.push(T.fromLink(cmd.args)); return say(ctx, lines.join("\n"), { reply_markup: { inline_keyboard: [[ { text: T.showButtons, callback_data: "menu:buttons" }, { text: T.showKeyboard, callback_data: "menu:keyboard" }, ]] }, }); } case "help": return say(ctx, T.help); case "buttons": return showCounter(ctx, 0, L); case "keyboard": return showKeyboard(ctx, L); case "hide": return say(ctx, T.keyboardOff, { reply_markup: { remove_keyboard: true } }); default: return say(ctx, T.unknownCommand(cmd.name)); } } async function onMessage(u, msg, edited) { const ctx = { chatId: msg.chat.id, updateId: u.update_id, n: 0 }; stopped.delete(ctx.chatId); // человек пишет — значит, бот снова запущен const L = lang(msg.from?.language_code); const T = TEXT[L]; const replyTo = { reply_parameters: { message_id: msg.message_id } }; if (edited) { if (typeof msg.text === "string") return say(ctx, clip(T.edited(msg.text)), replyTo); return; } // Любое сообщение обрабатываем сразу, не требуя /start: так бот не зависит от // того, как человек пришёл. if (typeof msg.text === "string") { const cmd = commandOf(msg); if (cmd) return onCommand(ctx, msg, cmd, L); const kb = keyboardWord(msg.text); if (kb === "dice") return say(ctx, T.dice(1 + Math.floor(Math.random() * 6))); if (kb === "time") { const tag = L === "en" ? "en-GB" : L === "uk" ? "uk-UA" : "ru-RU"; return say(ctx, T.time(new Date().toLocaleTimeString(tag, { hour: "2-digit", minute: "2-digit" }))); } if (kb === "hide") return say(ctx, T.keyboardOff, { reply_markup: { remove_keyboard: true } }); return say(ctx, clip(T.echo(msg.text))); } if (Array.isArray(msg.photo) && msg.photo.length) { // Размеры идут по возрастанию, как принято в Bot API: последний — самый большой. return send(ctx, "sendPhoto", { photo: msg.photo.at(-1).file_id, caption: T.photo, ...replyTo }); } if (msg.document) return send(ctx, "sendDocument", { document: msg.document.file_id, caption: T.doc, ...replyTo }); // sendDocument принимает любой свой file_id — видео, голосовое, аудио уйдут файлом. const media = msg.video ?? msg.voice ?? msg.audio; if (media) return send(ctx, "sendDocument", { document: media.file_id, caption: T.media, ...replyTo }); if (msg.location) return say(ctx, T.place(msg.location.latitude, msg.location.longitude)); if (msg.falcon_type) return say(ctx, T.noContent(msg.falcon_type)); // стикер, опрос… return say(ctx, T.unknown); } async function answer(id, text) { try { await call("answerCallbackQuery", text ? { callback_query_id: id, text } : { callback_query_id: id }); } catch (e) { // Нажатию больше 15 минут (программа долго лежала) — ответить уже нельзя. Не беда. if (isErr(e, "callback_expired", "query is too old")) return; throw e; } } async function onCallback(u, cq) { // В личной переписке номер переписки = номер человека. const chatId = cq.message?.chat?.id ?? cq.from.id; const ctx = { chatId, updateId: u.update_id, n: 0 }; const L = lang(cq.from.language_code); const T = TEXT[L]; const data = cq.data ?? ""; // cq.falcon_via: "digit" | "label" — человек со старой версией приложения ответил // цифрой или надписью. Для этого бота разницы нет. const m = /^cnt:(-?\d{1,6})$/.exec(data); if (m && cq.message) { const n = Number(m[1]); await answer(cq.id, T.counter(n)); // сначала ответ: у человека гаснет колёсико const hadLink = (cq.message.reply_markup?.inline_keyboard ?? []).flat().some((b) => b.url); try { await call("editMessageText", { chat_id: chatId, message_id: cq.message.message_id, text: T.counter(n), reply_markup: counterMarkup(n, L, hadLink), }); } catch (e) { // Двойное нажатие даёт ту же правку — «not modified», это нормально. if (["not_modified", "edit_not_found", "edit_forbidden"].includes(e?.key)) return; if (isErr(e, "blocked", "bot was blocked by the user") || isErr(e, "user_deleted")) { stopped.add(chatId); return; } throw e; } return; } if (data === "menu:buttons") { await answer(cq.id); return showCounter(ctx, 0, L); } if (data === "menu:keyboard") { await answer(cq.id); return showKeyboard(ctx, L); } await answer(cq.id, T.stale); } function onMember(m) { // Первый приход — left → member; «Остановить» или «Заблокировать» — member → kicked; // запуск снова — kicked → member. Это событие всегда приходит раньше сообщения. const to = m.new_chat_member?.status; if (to === "kicked") stopped.add(m.chat.id); if (to === "member") stopped.delete(m.chat.id); log(`человек ${m.chat.id}: ${m.old_chat_member?.status ?? "?"} → ${to}`); } async function handle(u) { if (u.message) return onMessage(u, u.message, false); if (u.edited_message) return onMessage(u, u.edited_message, true); if (u.callback_query) return onCallback(u, u.callback_query); if (u.my_chat_member) return onMember(u.my_chat_member); // Незнакомый вид события — пропускаем: так требует 1.3. } // События одного человека — строго по очереди, разных людей — параллельно: // пауза по 429 в одной переписке не держит остальные. const chains = new Map(); function dispatch(u) { const chatId = u.message?.chat?.id ?? u.edited_message?.chat?.id ?? u.callback_query?.message?.chat?.id ?? u.callback_query?.from?.id ?? u.my_chat_member?.chat?.id; const report = (e) => warn(`событие ${u.update_id}: ${explain(e)}`); if (chatId == null) return handle(u).catch(report); const next = (chains.get(chatId) ?? Promise.resolve()).then(() => handle(u)).catch(report); chains.set(chatId, next); next.then(() => { if (chains.get(chatId) === next) chains.delete(chatId); }); return next; } // ── Запуск ─────────────────────────────────────────────────────────────────── // Меню ставим, только если оно отличается. Правка без изменений в суточный предел // не считается (у программы свои 50 правок в сутки, у Гнезда — свои 20), но лишний // вызов незачем. Лучше всего ставить меню при выкладке, а не на каждом запуске. async function ensureMenu() { for (const [code, commands] of Object.entries(MENU)) { const where = code ? { language_code: code } : {}; const now = await call("getMyCommands", where); const same = Array.isArray(now) && now.length === commands.length && now.every((c, i) => c.command === commands[i].command && c.description === commands[i].description); if (same) continue; try { await call("setMyCommands", { commands, ...where }); log(`меню «${code || "по умолчанию"}» обновлено`); } catch (e) { if (e instanceof ApiError && e.code === 429) { warn(`меню не обновлено: ${explain(e)} Бот работает дальше.`); return; } throw e; } } } async function pollLoop() { let offset = 0; let pause = 1; while (!stop.signal.aborted) { let updates; try { updates = await rawCall( "getUpdates", { offset, limit: 100, timeout: POLL_TIMEOUT_S, allowed_updates: UPDATE_TYPES }, { timeoutMs: (POLL_TIMEOUT_S + 15) * 1000, signal: stop.signal }, ); pause = 1; } catch (e) { if (stop.signal.aborted) break; if (e instanceof ApiError && e.code === 429) { await sleep((e.retryAfter ?? 5) * 1000); continue; } // 401 (ключ; отзыв сразу обрывает висящий опрос), 403 (бот отключён), // 409 (вторая копия или вебхук), прочие 4xx — повтор не поможет. if (e instanceof ApiError && !e.notJson && e.code >= 400 && e.code < 500) fatal(e); warn(`getUpdates: ${explain(e)} — повтор через ${pause} с`); await sleep(pause * 1000); pause = Math.min(pause * 2, 30); continue; } for (const u of updates) offset = Math.max(offset, u.update_id + 1); // Следующий опрос с новым offset подтверждает эти события, поэтому сначала // дожидаемся их обработки: упадём посередине — получим их снова, а повторных // ответов не будет благодаря ключу повтора. await Promise.all(updates.map(dispatch)); } // Подтвердить обработанное, чтобы после перезапуска оно не пришло снова. if (offset) await rawCall("getUpdates", { offset, limit: 1, timeout: 0 }).catch(() => {}); log("остановлен"); } for (const sig of ["SIGINT", "SIGTERM"]) process.once(sig, () => { log("останавливаюсь…"); stop.abort(); }); try { me = await call("getMe"); log(`бот @${me.username} («${me.first_name}») на связи, адрес ${API}`); if (me.falcon_trust === "new" && me.falcon_trust_until) { log(`бот новый: ссылки в сообщениях видит только хозяин до ${new Date(me.falcon_trust_until * 1000).toISOString()}`); } const hook = await call("getWebhookInfo"); if (hook.url) fatal(new ApiError(409, { error_code: 409, error_key: "webhook_active", description: "webhook is active" })); await ensureMenu(); } catch (e) { fatal(e); } await pollLoop();