March 3, 2023

TELEGRAM BOT QUICK START

миниатюра рубури и подпищек

с первым днем весны! (у рубури это так на момент написания этой строки)

предоставляю тебе альманах как написать бота в телеге простыми словами без заморочек

если ты давно хотел написать своего бота в телеге — щас самое время для того чтобы научиться это делать!


давай накатаю тебе план действий то есть что мы будем делать:

1. как читать эту статью?

2. изучим основы

2.1. создаем бота в BotFather

2.2. разворачиваем среду обитания бота

2.3. пишем первого простецкого бота

3. пишем славу бота

3.1. создаем бота в BotFather

3.2. создаем приветственное сообщение

3.3. пишем логику обработки ввода от юзера

3.4. пишем логику получения данных о монете

3.5. отвечаем юзеру котировками пон

3.6. поздравляю! ты написал почти клона славы!

4. пишем бот агрегатор каналов

4.1. получаем API ID и хэш

4.2. готовим окружение

4.3. коннектим бота к аккаунту

4.4. получаем инфу о постах в канале

4.5. пересылаем пост из канала в канал

4.6. настраиваем автоматическую пересылку из одного канала

4.7. пересылаем посты из нескольких каналов

4.8. поздравляю! ты написал бот агрегатор каналов!

5. как сделать так, чтобы бот работал всегда?

6. заключение

7. благодарности

8. отзыв

ниже я оставлю ссылочки для навигации по определенным частям статьи. надеюсь ты подхватишь какие нибудь интересные техники или любую другую интересующую тебя инфу из этой статьи! погнали!


как читать эту статью?

в целом все просто

ты можешь читать все разом, либо просто перейти к интересующему тебя разделу (например, слава бот или бот агрегатор)

в обоих разделах ты найдешь для себя что то новое, я постарался расписать какие то интересные техники и новые концепты


основы

создаем бота в BotFather

весь код будет на js. начнем с того что мы создадим самого бота через BotFather

нас интересует команда /newbot

вот таке команда

после чего вводим имя бота, в нашем случае — "Абузим гем"

и наконец юзернейм, необходимо чтобы юзернейм заканчивался словом bot

абузим гем?

после чего в сообщении мы получаем токен бота, который будет использован для того чтобы мы могли взаимодействовать с ботом по API

разворачиваем среду обитания бота

если ты не знаешь что такое nodejs или у тебя не установлен Node.js либо yarn: проходи сюда в раздел "Подготавливаем среду обитания нашего скрипта"

создаем папку, создаем файл index.js, заходим в нее в редакторе, открываем консоль и пишем:

yarn init -y
yarn add node-telegram-bot-api
yarn add --dev nodemon

для разработки бота мы будем использовать библиотеку node-telegram-bot-api

также мы будем использовать тулзу nodemon чтобы бот перезапускался за нас всякий раз когда мы обновляем код

заходим в package.json и вставляем туда следующее содержимое:

{
  "type": "module",
  "name": "telegram-bot",
  "private": true,
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "node-telegram-bot-api": "^0.61.0"
  },
  "scripts": {
    "dev": "nodemon ./index.js" // <<<<<
  }
}

в помеченной строке мы создаем скрипт dev который будет за нас вызывать команду nodemon ./index.js, мы будем включать бота так — yarn dev

в скрипте мы говорим nodemon чтобы он следил за обновлениями файла index.js

пишем первого простецкого бота

твой первый бот просто будет повторять за тобой сообщения, ниже я опубликую код для этого бота и мы с тобой разберем его по пунктам

import TelegramBot from 'node-telegram-bot-api';

const TELEGRAM_BOT_TOKEN = "...";
const bot = new TelegramBot(TELEGRAM_BOT_TOKEN, { polling: true });

bot.on('message', async (msg) => {
  const {
    chat: { id },
    text
  } = msg;
  
  await bot.sendMessage(id, text);
});

давай по порядку:

import TelegramBot from 'node-telegram-bot-api';

import — директива которая запрашивает доступ к какой-либо библиотеке. здесь мы запрашиваем библиотеку node-telegram-bot-api через import

имей ввиду что import и export не работают если не указать в package.json "type": "module"

const TELEGRAM_BOT_TOKEN = "...";

сюда тебе надо вставить токен который ты получил у BotFather. имена констант почти всегда пишутся капсом и используя snake_case

const bot = new TelegramBot(token, { polling: true });

инициализируем бота, включаем поллинг апдейтов, это нужно для того чтобы бот постоянно можно сказать был на связи и получал все сообщения которые к нему поступают

сам "конструктор" бота включает в себя 2 аргумента:

  1. токен бота
  2. различные опции. полный список опций можно посмотреть тут
bot.on('message', async (msg) => {});

с помощью метода on у бота ты можешь реагировать на различные обновления, будь-то обновления аватарки в канале, новый мембер в канале, новая гифка в личку бота и так далее. обновление в последующем я буду называть ивент. щас мы с тобой будем обрабатывать каждое сообщение которое приходит боту в лс с помощью ивента message. ивент message будет срабатывать каждый раз когда пишут боту в лс либо пишут в канал где есть бот

метдо on принимает два аргумента:

  1. ивент на который мы хотим реагировать
  2. колбек функция (она будет вызвана как только мы получим какой то апдейт по нашему ивенту, например, юзер написал сообщение - выполняется колбек функция). в моем случае она асинхронная (async), об этом позже

колбек функция хранит в себе один аргумент — это само сообщение юзера msg с дополнительной информацией

const {
  chat: { id },
  text
} = msg;

деструктуризируем объект msg и получаем оттуда chat id и текст сообщения

await bot.sendMessage(id, text);

и наконец отправляем сообщение, повторяющее текст юзера используя метод sendMessage

метод sendMessage принимает три аргумента:

  1. chat id — идентификатор чата которому бот отправит сообщение. когда юзер пишет боту, и на бота навесили обработчик сообщений, в объекте msg из функции обработчика всегда есть chat id, то есть идентификатор юзера который написал это сообщение
  2. текст сообщения
  3. опции отправки сообщений от бота, но об этом позже

запускаем бота с помощью команды yarn dev и идем писать боту

оп

вуаля! бот должен повторять все сообщения которые ты ему пишешь


пишем славу бот

итак, давай набросаем логику для бота:

  1. юзер будет запускать бота и бот будет выдавать приветственное сообщение с информацией о том как им пользоваться
  2. юзер будет писать условно 1 eth и бот будет выдавать ему курс в долларах на введенную монету
  3. предотвратим потенциальные ошибочные сценарии

погнали!


создаем бота в BotFather

здесь все дефолтно и уже знакомо. просто создаем бота и получаем токен

создаем приветственное сообщение

как только юзер вводит команду /start мы будем показывать ему приветственное сообщение о том как пользоваться ботом

import TelegramBot from 'node-telegram-bot-api';

const TELEGRAM_BOT_TOKEN = '...'; // токен
const bot = new TelegramBot(TELEGRAM_BOT_TOKEN, { polling: true });

const welcomeMessage = `привет\! я бот для чека курсов криптовалют \- клон славы
как это работает?
\> ты пишешь <code>1 eth</code>
\> бот отвечает
Ethereum <code>(ETH)</code>:
<code>$ 1,641</code>
<code>₽ 124,716</code>`;

bot.onText(/\/start/, (msg) => {
  const {
    chat: { id }
  } = msg;
  
  bot.sendMessage(id, welcomeMessage, {
    parse_mode: 'HTML'
  });
});

не забудь вставить свой токен в переменную token

welcomeMessage

в эту переменную я кладу приветственное сообщение. давай разберем его

я использую кривые кавычки `` — это нужно для того чтобы я не трахал мозги с тем как конкатенировать строки и не было необходимости писать каждый раз \n для пропуска строки на следующую так как кривые кавычки сохраняют все отступы и line breaks

все специальные знаки которые пишет бот в телеграм должны быть написаны с обратным слэшем (\) перед ними. это нужно грубо говоря для того чтобы парсер библиотеки не путался в символах и понимал какой символ где и что мы действительно имели ввиду, так как символы во первых могут повторяться, например как в js кавычки в кавычках ("qwe "lolol"" <- ты не можешь так написать, парсер запутается где нужные кавычки и почему строка в строке). поэтому ты "предохраняешь" нужный символ слэшем, давая понять парсеру что это ты специально кавычки поставил, а не случайно, то есть -> "qwe \"lolol\"", так будет работать

также если ты знаешь хтмл ты мог заметить использование тега code. дело в том, что в сообщениях можно применять два метода форматирования, а именно Markdown и HTML. то есть ты можешь делать текст жирным, курсивным и так далее с помощью Markdown или HTML

-> что такое Markdown?

с помощью тега code я меняю шрифт в некоторых местах (с тикером и с цифрами) на monospace. так нагляднее видно цифры и остальные "технические" детали

двигаем дальше

bot.onText(/\/start/, (msg) => {
  //...
});

тут я говорю боту — поставь обработчик если юзер написал текст /start

как видишь здесь я тоже ставлю обратный слэш, это еще один из примеров использования escape character

bot.sendMessage(id, welcomeMessage, {
  parse_mode: 'HTML'
});

затем я просто отправляю сообщение с единственной помаркой. я указал в опциях parse_mode: 'HTML'

это нужно для того чтобы бот отформатировал сообщение используя HTML, так как мы используем HTML форматирование в нашем сообщении. если не указать эту опцию получится вот это:

вообще ничего не отформатировано

теперь если указать обратно:

красота

пишем логику обработки ввода от юзера

нам нужно сделать так чтоб бот умел преобразовывать пользовательский ввод в вид данных с помощью которого мы сможем получать информацию из биржи, а точнее:

если юзер написал 1 eth, мы должны дать понять боту где количество монет, а где символ монеты

bot.on('message', (msg) => {
  const {
    text
  } = msg;
  
  const [strAmount, symbol] = text.split(' ');
  const isAmountValid = !/\D/g.test(strAmount);
  if (isAmountValid) {
    const amount = parseFloat(amount);
    // amount - количество монет, symbol - тикер монеты
  }
});

процедура обработчика ничем не поменялась, все знакомо. давай дальше

const [strAmount, symbol] = text.split(' ');

здесь мы используем метод split у строки. работает это следующим образом:

Array.split

юзер вводит 420 eth

количество монет (420) и символ (eth) разделяет пробел. метод split работает так, что он "нарезает" строку по разделителю на массив строк. пример:

'a b o b a'.split(' ')

даст нам следующий результат

['a', 'b', 'o', 'b', 'a']

и также:

'a-b-o-b-a'.split('-') -> ['a', 'b', 'o', 'b', 'a']


в нашем случае:

'420 eth'.split(' ') -> ['420', 'eth']

const isAmountValid = !/\D/.test(strAmount);

разве мы можем доверять юзеру? конечно нет. он может ввести туда случайно не цифру, а что то другое. и чтобы наш бот не ломался, нам нужно определить точно ли юзер ввел цифру в начале. здесь получилась на первый взгляд страшная конструкция, но ща я разберу тебе ее и все прояснится

давай начнем с конструкции

/\D/g.test(strAmount)

здесь я использую регулярное выражение

говоря кратко, регулярки нужны для того чтобы искать комплексные и необязательно комплексные конструкции в строках и что то с ними делать, как то их преобразовывать и тд

в нашем случае я применил регулярное выражение "есть ли в строке не цифра"

чтобы написать регулярное выражение тебе нужно два слэша /aboba/ <- в данном случае регулярное выражение способно искать сочетание символов aboba в строках

конструкция \D отвечает за то чтобы искать все символы кроме цифр (то есть не цифра). со всеми конструкциями и в целом регулярками можно ознакомиться по ссылке выше. регулярки — мастхев!

далее за регуляркой следует метод test который ищет все совпадения регулярки в строке. в нашем случае это звучит так — есть ли не цифры в строке? метод test возвращает булево значение, то есть true или false


и наконец, символ ! обозначает "не". то есть если наша регулярка нашла не цифру, значит то что ввел юзер не является корректным числом так как там есть не цифра. нам же нужно что то делать только если регулярка не нашла не цифру (false), мы преобразуем false в true используя !

!false = true

!true = false

if (isAmountValid) {
  const amount = parseFloat(amount);
  // amount - количество монет, symbol - тикер монеты
}

если цифра корректная, мы преобразуем строку в число с которым можно проводить математические операции используя parseFloat

почему parseFloat, а не parseInt? parseFloat учитывает знаки после запятой (например, 3.5 sol), тогда как parseInt округляет число до целого

оп, вот мы и получили нужные данные от юзера!

пишем логику получения данных о монете

мы с тобой будем использовать CoinMarketCap API для получения инфы о цене. как это вообще работает? ну, твоему боту нужно получать данные из внешнего мира, хранящиеся на чьих-то серверах, да не просто на чьих-то, а на серверах тех, кто еще и предоставляет API для доступа к данным. API — это то благодаря чему ты можешь взаимодействовать с серверами, также API описывает как эти данные получать и в каком виде ты их будешь получать. часто для API бывает нужен API ключ, который ты получаешь допустим на нужном сайте, который принадлежит владельцу API. с помощью API ключа сервер дает тебе возможность делать запросы, без него это было бы невозможно

гуглим CoinMarketCap API, переходим по этой ссылке, жмем GET YOUR API KEY NOW

The world's cryptocurrency data authority has a professional API made for you. Ок...

получаем ключ (он должен быть в твоем дашборде после регистрации бемс демс) и едем дальше

нам нужно разобраться как пользоваться этим API и как получить цену на нужную нам крипту используя документацию

открыв ссылку и пролистав до Quick Start Guide мы видим примеры запросов на разных языках программирования

пример запроса на js

здесь в примере используется библиотека axios, поверхностно у нее мало отличий от got, который мы будем использовать. эти библиотеки нужны для того чтобы связываться с сервером, проще говоря HTTP клиент (типа fetch в js, только круче)

структура запроса следующая:

await axios.get('https://sandbox-api.coinmarketcap.com/v1/cryptocurrency/listings/latest', {
  headers: {
    'X-CMC_PRO_API_KEY': 'b54bcf4d-1bca-4e8e-9a24-22ff2c3d462c',
  }
});

первым аргументом указывается URL по которому мы будем стучать к серверу, а точнее эндпоинт

что такое эндпоинт?

API может иметь несколько эндпоинтов. благодаря эндпоинтам происходит разделение данных по "коробкам", типа по "местам". например, получить категории можно по одной ссылке, а вот цена на монету уже по другой ссылке. это сделано для того чтобы разработчик не получал в одном эндпоинте большую кипу ненужной ему информации, а подтягивал лишь то что нужно

в примере выше эндпоинтом является cryptocurrency/listings/latest

этот эндпоинт позволяет нам получить данные о последних листингах монет


вторым аргументом указываются опции, среди которых единственная опция headers

что такое headers?

headers — заголовки, которые клиент отправляет серверу. клиент — это наш бот в этом случае. заголовки нужны для того чтобы передавать серверу периферийную информацию не относяющуюся к эндпоинту (к эндпоинту непосредственно относится такая информация как символ монеты, цену на которую мы хотим получить)

в данном случае в заголовках указан один X-CMC_PRO_API_KEY — сюда мы вставим наш апи ключ, иначе сервер нас не пустит

в большинстве своем апи ключи вставляются в headers

окей с тем как делать запрос разобрались, давай теперь найдем нужный нам эндпоинт!

полистав немного вниз в документации API, мы можем найти вкладку Cryptocurrencies, а внутри эндпоинт Quotes Latest v2 — то что нам нужно!

небольшое примечание

на самом деле можно и правильнее будет использовать эндпоинт OHLCV Latest v2, так как нам нужны лишь цены, но дело в том что он заблокирован для использования простыми смертными

булщит

внутри мы видим URL по которому мы можем обратиться к эндпоинту, а также пролистав ниже мы видим параметры которые мы можем передать ему

параметры эндпоинта

среди них мы видим параметр symbol — это то что нам нужно для того чтобы получать цену на определенную крипту

создавай файл api.js где ты будешь хранить всю логику связанную с API

затем открывай консоль и давай подтянем нашу библиотеку got

yarn add got

с помощью этого зверька мы сможем делать запросы к любому API

пишем код:

import got from 'got';

const COINMARKETCAP_API_TOKEN = '...'; // твой токен

const client = got.extend({
  prefixUrl: 'https://pro-api.coinmarketcap.com/v2',
  headers: {
    'X-CMC_PRO_API_KEY': COINMARKETCAP_API_TOKEN
  }
});

export const getCoinQuotes = async (symbol) => {
  const { data } = await client
    .get('cryptocurrency/quotes/latest', {
      searchParams: new URLSearchParams({
        symbol
      })
    })
    .json();

  const quote = data[symbol.toUpperCase()][0].quote;
  return quote;
}

здесь мы инициализируем нашу библиотеку для запросов к серверу используя метод extend

const client = got.extend({
  prefixUrl: 'https://pro-api.coinmarketcap.com/v2',
  headers: {
    'X-CMC_PRO_API_KEY': COINMARKETCAP_API_TOKEN
  }
});

-> документация got на github

extend принимает объект с опциями, из которых мы используем:

prefixUrl — здесь мы прописываем ссылку на API CoinMarketCap, чтобы не вставлять ее постоянно каждый раз когда мы хотим сделать запрос (библиотека будет вставлять его за нас). к примеру если эндпоинт выглядит так:

https://pro-api.coinmarketcap.com/v2/cryptocurrency/listings/latest

то prefixUrl будет являться https://pro-api.coinmarketcap.com/v2

headers — наши заголовки

export const getCoinQuotes = async (symbol) => {
  const { data: usd } = await client
    .get('cryptocurrency/quotes/latest', {
      searchParams: new URLSearchParams({
        symbol
      })
    })
    .json();
  const coin = usd[symbol.toUpperCase()][0];
  const usdQuote = coin.quote.USD;

  const { data: rub } = await client
    .get('cryptocurrency/quotes/latest', {
      searchParams: new URLSearchParams({
        symbol,
        convert: 'RUB'
      })
    })
    .json();
  const rubQuote = rub[symbol.toUpperCase()][0].quote.RUB;

  const name = coin.name;
  return {
    name: 
    usd: usdQuote,
    rub: rubQuote
  };
}

здесь мы объявляем функцию с помощью которой мы будем получать цены на введенный пользователем символ монеты

немного слов про async/await

асинхронная функция — функция которая может выполняться не блокируя основной поток скрипта (то есть какие либо другие конструкции)

в данном случае я объявил функцию асинхронной чтобы воспользоваться возможностью ждать до тех пор пока я не получу ответ с сервера

для того чтобы объявить функцию асинхронной достаточно добавить ключевое слово async перед аргументами

-> про Promise API в JavaScript

метод get возвращает Promise объект. объект Promise можно либо дождаться либо оставить его выполняться в потоке не блокируя другой код

например, конструкция:

const data = client.get('...');
console.log('aboba');

не будет ждать ответа сервера и сразу выведет aboba в консоль, а в переменной data будет храниться Promise

в свою очередь, конструкция:

const data = await client.get('...');
console.log('aboba');

дождется ответа сервера и лишь потом выведет лог aboba, а в переменной data будет ответ сервера, а не Promise

учти что ты не можешь использовать await просто так. await можно использовать лишь в асинхронной функции

const { data } = await client
  .get('cryptocurrency/quotes/latest', {
    searchParams: new URLSearchParams({
      symbol
    })
  })
  .json();

конструкция совсем не отличается от той что была в примере. единственное отличие — опция searchParams и вызов функции json после метода get

мы используем searchParams для того чтобы указать те самые параметры которые ты видел в доке (id, symbol и тд)

создать эти параметры в удобном виде нам поможет URLSearchParams

работает это просто, в качестве аргумента мы кладем объект с параметрами

затем мы по цепочке вызываем метод json для того чтобы преобразить ответ сервера в объект, который мы можем использовать для манипуляции с данными

так выглядит ответ сервера для эфира

из ответа сервера нам нужен объект data поэтому мы его сразу деструктуризируем

сохраним данные по монете в переменную coin, затем сохраняем подробности о цене, объеме и тд в долларах в переменную usdQuote

const coin = usd[symbol.toUpperCase()][0];
const usdQuote = coin.quote.USD;

здесь мы получаем информацию о монете в долларах, также ниже мы делаем еще один запрос, только в этот раз добавляем опцию в URLSearchParams:

convert: 'RUB'

для конвертации цены в рубли

у них странно работает API в этом плане, потому что я должен делать два запроса для того чтобы получить инфу в долларах и в рублях, но да ладно, не страшно

и наконец, сохраняем полное имя монеты в переменную name:

const name = coin.name;

надеюсь ты не запутался, потому что мы сделали два вызова, один для долларового эквивалента, другой для рублевого. информация по монете (то есть имя) хранится по сути в обоих объектах, я просто решил достать полное имя из первого вызова (coin.name)

в конечном итоге если мы вызовем нашу функцию с аргументом ETH, то получим следующий объект:

оп оп цены объемы все дела

ну теперь мы можем ответить юзеру!

отвечаем юзеру котировками пон

прежде чем мы допишем код для бота давай подумаем как форматировать цифры которые мы будем отображать юзеру, потому что числа в объекте с ценой и тд очень громоздкие (например,  122526.61682949228)

давай создадим файл utils.js и в нем напишем функцию форматирования наших чисел в более менее удобочитаемый вид:

const SMALL_NUMBER_DECIMALS = 4;

export const formatNumber = (number, decimals) =>
  parseFloat(
    number.toFixed(number < 10 ? SMALL_NUMBER_DECIMALS : decimals)
  ).toLocaleString('en-US');

внутри функции мы сначала приводим в порядок числа после запятой. если число меньше 10, нам нужно 4 (SMALL_NUMBER_DECIMALS) знака после запятой как минимум, если число больше, то приводим как будет указано при вызове функции

после того как мы привели в порядок числа после запятой, мы используем функцию parseFloat чтобы убрать лишние нули в конце, если они есть

как работает toFixed

если использовать toFixed с числом 1.1 желая получить 5 знаков после запятой то получится следующая картина:

(1.1).toFixed(5) // = "1.10000" Wtf?

нам не нужны пустые нули в конце, поэтому используем parseFloat который их уберет, заодно приведет строку в число

и наконец, мы используем toLocaleString('en-US') чтобы жиэс добавил за нас запятые после каждого числового разряда

выглядит это так:

(1500).toLocaleString('en-US') // "1,500" good

теперь возвращаемся в index.js

импортируем обе наши функции (иначе мы не сможем получить к ним доступ из другого файла)

в самом верху после импорта node-telegram-bot-api пишем

import { getCoinData } from './api.js'
import { formatNumber } from './utils.js'

теперь мы можем продолжить дальше

if (isAmountValid) {
  const amount = parseFloat(strAmount);
  
  if (amount > 0) {
    const {
      name,
      usd: { price: usdPrice, percent_change_24h: usdPercentChange24h },
      rub: { price: rubPrice, percent_change_24h: rubPercentChange24h }
    } = await getCoinData(symbol);
    
    await bot.sendMessage(
      id,
      `${amount} ${name} (<code>${symbol.toUpperCase()}</code>):
<code>$ ${formatNumber(usdPrice * amount, 1)} | ${usdPercentChange24h.toFixed(2)%</code>
<code>₽ ${formatNumber(rubPrice * amount, 0)} | ${rubPercentChange24h.toFixed(2)}%</code>
      `,
      {
        parse_mode: 'HTML'
      }
    );
  }
}

прежде чем делать какие то конвертации, надо убедиться что юзер не ввел 0 eth или -1 eth

основное правило разработки чего либо

когда ты разрабатываешь какую то штуку, смотри на код с позиции того как можно его сломать

в связи с этим я добавил правило if (amount > 0) в код для того чтобы проверять что юзер вводит натуральное число

const {
  name,
  usd: { price: usdPrice, percent_change_24h: usdPercentChange24h },
  rub: { price: rubPrice, percent_change_24h: rubPercentChange24h }
} = await getCoinData(symbol);

здесь мы вызываем нашу функцию из api.js и передаем туда символ который ввел юзер, после чего деструктуризируем результат функции и получаем имя и рублевые/долларовые цены на данный момент + изменения цены за сутки

не забываем await иначе бот не дождется результата вызова API и возникнет ошибка

await bot.sendMessage(
  id,
  `${amount} ${name} (<code>${symbol.toUpperCase()}</code>):
<code>$ ${formatNumber(usdPrice * amount, 1)} | ${usdPercentChange24h.toFixed(2)%</code>
<code>₽ ${formatNumber(rubPrice * amount, 0)} | ${rubPercentChange24h.toFixed(2)}%</code>
  `,
  {
    parse_mode: 'HTML'
  }
);

и здесь мы наконец пишем ответ юзеру, используем нашу утилиту formatNumber, преобразовываем введеный юзером символ в капс методом toUpperCase() и прибираем порядок числа после запятой у процентных соотношений. все это мы уже с тобой прошли. естественно, не забываем указать parse_mode

тестируем!

тест тест тест

все збс работает, но есть одно но. юзер может ввести не только не натуральное число, но еще и несуществующий символ типа запросто

если щас попробовать с ботом что то в духе 1 dolbaeb то хорошим ничем для него это не закончится

поэтому давай сделаем так что если пользователь вводит несуществующую монету то просто не будем ничего выводить. нам нужно добавить обработчик потенциальной ошибки в то место где мы запрашиваем данные о символе

if (amount > 0) {
  try {
    const {
      name,
      usd: { price: usdPrice, percent_change_24h: usdPercentChange24h },
      rub: { price: rubPrice, percent_change_24h: rubPercentChange24h }
    } = await getCoinData(symbol);
    
    await bot.sendMessage(
      id,
      `${amount} ${name} (<code>${symbol.toUpperCase()}</code>):
<code>$ ${formatNumber(usdPrice * amount, 1)} | ${usdPercentChange24h.toFixed(2)%</code>
<code>₽ ${formatNumber(rubPrice * amount, 0)} | ${rubPercentChange24h.toFixed(2)}%</code>
      `,
      {
        parse_mode: 'HTML'
      }
    );
  } catch (e) {
    console.error(e);
  }
}

используем try...catch конструкцию (подробнее про это читать тут) для того чтобы отловить ошибку в момент ее возникновения тем самым предотвратив падение бота. ошибка просто высветится в консоль

так то лучше

поздравляю! ты написал почти клона славы!

в целом бота можно даже добавить в свой чат, конечно же его надо будет подправить чтоб он не сьедал и не пытался конвертировать каждое попавшееся сообщение типа "20 бананов" или "я гей" в общем ты понял

мои большие поздравления если у тебя удалось все написать! 🎉

весь код доступен здесь -> https://file.io/LonWaVbNBHQ5

отдохнем, посозерцаем на ахуенный пиксельартик

пишем бот агрегатор каналов

так, вот тут задача не менее интересная. начнем с того что телеграм бот не может переслать сообщения из какого либо канала так как у него просто напросто нет возможностей читать посты каналов, также как и сажать обычно бота в какие либо паблики с целью прочесть что то оттуда

для таких задач используется более "умная" если можно так выразиться версия бота, проще говоря — просто телеграм клиент в жиэс. то есть мы будем поднимать прям дефолтный телеграм клиент используя жиэс и библиотеку gramjs

план следующий:

  1. получаем API ID и хэш (для того чтобы бот мог дергать аккаунт)
  2. логинимся в нашем боте в телеграм используя телефон + пароль + код из уведомления при входе
  3. подписываемся на обновления какого либо канала (мы создадим канал чисто для этого)
  4. аккаунт будет репостить сообщения в другой канал агрегатор
  5. профит

приступим!

получаем API ID и хэш

переходим на https://my.telegram.org и логинимся

жмем API Development tools

сидит лыбит

устанавливаем App title (название бота) и Short name (типа никнейм)

оп оп

выше будут твой id и hash, ты будешь использовать их чтобы бот получил доступ к аккаунту

готовим окружение

в целом все то же самое как и с клоном славы, единственное что вместо node-telegram-bot-api мы установим telegram и input

yarn add telegram input

telegram — это сама библиотека gramjs

input — будет использоваться для того чтобы залогиниться в аккаунт, а точнее чтобы мы могли ввести номер, пароль, код для аутентификации

коннектим бота к аккаунту

в файле index.js прописываем:

import { TelegramClient } from 'telegram'
import { StringSession } from 'telegram/sessions/index.js'
import input from 'input'

const apiId = 666666; // сюда пишешь API ID
const apiHash = 'hash'; // сюда API hash
const stringSession = new StringSession(''); // это объясню ниже

(async () => {
  const client = new TelegramClient(stringSession, apiId, apiHash, {
    connectionRetries: 5
  });

  await client.start({
    phoneNumber: async () => await input.text('Введи номер телефона телеги: '),
    password: async () => await input.text('Введи пароль: '),
    phoneCode: async () => await input.text('Введи полученный код от телеги: '),
    onError: (err) => console.log(err)
  });
  console.log(client.session.save());
})();

начнем с того как мы инициализируем клиент

const client = new TelegramClient(stringSession, apiId, apiHash, {
  connectionRetries: 5
});

мы вызываем конструктор TelegramClient с аргументами сессии, API ID, API hash и объектом опций

немного выше я прописал переменную stringSession. она нам понадобится как только мы залогинимся для того чтобы вставить туда токен сессии, чтобы не логиниться каждый раз заново. переменная stringSession будет использоваться в инициализации клиента

теперь разберемся что дальше

await client.start({
  phoneNumber: async () => await input.text('Введи номер телефона телеги: '),
  password: async () => await input.text('Введи пароль: '),
  phoneCode: async () => await input.text('Введи полученный код от телеги: '),
  onError: (err) => console.log(err)
});

используя метод start мы можем законнектиться к аккаунту. метод start принимает объект свойств, а именно:

  1. phoneNumber — номер телефона по которому логиниться
  2. password — пароль от аккаунта
  3. phoneCode — код который приходит на телефон (обычно в телеграм, можно сделать также код в смс)
  4. onError — просто колбек чтобы повесить обработчик в случае если произошла ошибка

чтобы получить номер телефона, пароль и код уведомления мы используем библиотеку input чтобы мы могли вводить что то в консоль, то есть получать пользовательский ввод извне. также сразу бросается в глаза то, что перед input.text стоит await, что означает что скрипт должен ждать до тех пор пока юзер что то не введет

console.log(client.session.save());

в этой строке мы отображаем в консоли токен сессии, который мы вставим в переменную stringSession после первого логина, чтобы не заходить в аккаунт по 500 раз

запускай код, тебе будет предложено ввести номер телефона, вводи все данные, логинься. после успешного входа тебе выйдет сообщение что то типа "Signed in successfully", в телегу придет уведомление о новом логине и в девайсе можно будет увидеть название твоего бота

нас интересует строка которая отобразилась в консоли после всего, а именно длиннющая строка обозначающая токен сессии. копируй ее и вставляй в переменную stringSession следующим образом:

const stringSession = new StringSession('...token');

после чего мы можем убрать конструкцию с client.start и client.session.save и написать логику подключения проще, тк у нас есть уже токен сессии по которому мы можем входить в аккаунт

import { TelegramClient } from 'telegram'
import { StringSession } from 'telegram/sessions/index.js'

const apiId = 666666;
const apiHash = 'hash';
const stringSession = new StringSession('...token');

(async () => {
  const client = new TelegramClient(stringSession, apiId, apiHash, {
    connectionRetries: 5
  });

  await client.connect();
})();

и о чудо! мы изи коннектимся к акку и готовы теперь манипулировать канальчиками и постами

если у тебя возник вопрос откуда я взял эти функции, методы и тд, то этот пример можно свободно посмотреть в гитхабе библиотеки, в самом начале README, раздел How to get started, так что если что, читай внимательно и будь внимателен вообще — экономит время

получаем инфу о постах в канале

для того чтобы получить какую то инфу нужно покопаться в документации какой либо штуки, помнишь? почти у всех технических штучек есть документации, поэтому не стесняйся гуглить. гуглить — мастхев. старайся уменьшать усилия, мы живем в мире где в интернете есть почти все что ты хочешь написать!

листаем гитхаб gramjs ниже и видим раздел с документацией

то что нужно!

переходим в документацию, бетка нам не нужна, тк там все может быть нестабильно и коряво, а мы тут не делаем единственного уникального в мире бота, поэтому хватит и стабильной

задачка

найди метод, отвечающий за сообщения в каналах. ответ — под картинкой

красата да и только

правильный ответ — channels.GetMessages

если получилось — ты крут менчик! а если нет, то не парься, ведь все еще впереди, главное опыта набираться!

давай разберемся как работает метод channels.GetMessages

параметры для метода

в доке мы видим что метод принимает свойство channel где будет находиться ник канала, а также массив id, в который мы будем класть ID сообщения, контент которого хотим получить

давай создадим тестовый канал, где будем делать посты, которые бот будет пересылать в другой канал

вместо нашего новосозданного канала позже мы будем парсить другие паблики

возвращаемся к методу channels.GetMessages, а точнее параметру id

прежде чем что то переслать, нам нужно получить айди сообщения. сделать это можно скопировав ссылку на сообщение, в моем примере ссылка будет выглядеть так: https://t.me/testruburibotchannel/3, где testruburibotchannel — ник паблика, 3 — айди последнего сообщения

давай попробуем получить контент поста, используя метод channels.GetMessages

заметка

для наглядности я не буду переписывать весь предыдущий код подключения к аккаунту

import { Api, TelegramClient } from 'telegram' // импортируем Api
// ...код
const result = await client.invoke(
  new Api.channels.GetMessages({
    channel: 'testruburibotchannel',
    id: [3]
  })
);
console.log(result);

сначала импортируем модуль Api, там хранятся методы для работы с аккаунтом, затем вызываем метод GetMessages как описано в документации, только с измененными параметрами и выводим результат в консоль

вот такой результат мы должны получить, и это еще не все

выводится большой объект с сообщениями, юзерами, чатами, топиками (это для супергрупп, где есть треды и тд)

нас интересует только массив messages в котором мы видим текст, время и остальная инфа о первом посте

также есть поле media, в котором хранятся картинки, гифки прикрепленные к посту

давай вытащим наше сообщение из массива messages

// ...код
const result = await client.invoke(
  new Api.channels.GetMessages({
    channel: 'testruburibotchannel',
    id: [3]
  })
);
const post = result.messages[0].message;
console.log(post);
получаем текст поста

супер! мы получили текст нужного нам поста!

пересылаем пост из канала в канал

давай создадим канал агрегатор, в моем случае это — https://t.me/aggregatorruburi

у бота (или аккаунта) обязательно должен быть доступ к постам в канале, иначе переслать сообщение не получится

ищем в документации gramjs что то связанное с forward message и находим следующий метод:

нам подходит

читаем пример и параметры. в целом все что нам нужно это параметры fromPeer, id, toPeer

  • fromPeer — ник канала откуда будем пересылать
  • id — массив айди сообщений которые пересылаем (можно указать несколько, в нашем случае один пост)
  • toPeer — ник канала куда пересылаем
  • dropAuthor — указывать ли из какого канала пересылка (мы не будем это использовать, но если не хотите палить откуда пересылаете, указываете dropAuthor на true)

в целом там есть много интересных параметров, советую почитать если интересно

пишем код для пересылки сообщения:

// ...код
await client.invoke(
  new Api.messages.ForwardMessages({
    fromPeer: 'testruburibotchannel',
    id: [3],
    toPeer: 'aggregatorruburi'
  })
);

запускаем бота, и вуаля:

збс

окей, мы научились пересылать сообщения, но теперь надо сделать так, чтобы всякий раз когда появляется новый пост в канале за которым мы следим, бот автоматически его пересылал

настраиваем автоматическую пересылку из одного канала

для того чтобы автоматически пересылать сообщения, боту нужно запоминать какой пост он переслал последним, и с какого вообще айди начинаются посты, которые бот начнет пересылать

помнишь, как мы копировали ссылку на сообщение? самый недавний пост был с айди 3, значит боту надо дать понять, что все посты начиная с 4 мы пересылаем

почему 4?

последний пост был с айди 3, бот не должен его пересылать так как он уже существует. он начнет следить за всеми постами начиная со следующего (то есть id = 4)

окей, тут разобрались

для того чтобы бот постоянно следил за каналом, нам нужно постоянно проверять есть ли новый пост в канале. но как мы можем проверить появился ли новый пост в канале?

мысль следующая: мы можем проверять не пустой ли пост с id последнего поста + 1

то есть, если крайний пост был 3, боту надо проверять появился ли пост с id 4

давай попробуем написать код, который проверяет несуществующий пост с id 4 в нашем примере

const result = await client.invoke(
  new Api.channels.GetMessages({
    channel: 'testruburibotchannel',
    id: [4]
  })
);
console.log(result);

получаем следующий вывод в консоли и смотрим на массив messages, где хранятся полученные сообщения:

MessageEmpty

смотри, в массиве messages действительно есть сообщение, хотя его вроде по факту нет. но если посмотреть внимательнее, можно увидеть свойство className со значением MessageEmpty. так телега помечает пустые сообщения (а если сообщение пусто, значит его нет)

таким образом мы можем понять появился ли новый пост в канале!

вернемся к тому, что бот должен следить и реагировать каждый раз когда пост все таки появляется. если есть какая то логика, связанная со "следить" в игру вступает setInterval

setInterval

setInterval используется для того чтобы выполнять какой либо код каждые N миллисекунд

setInterval(() => {
  console.log('RUBURI');
}, 1000);

этот код будет выводить в консоль RUBURI каждую секунду (1000 мс = 1 сек)

функция setInterval возвращает сам интервал. это нужно для того чтобы ты мог его остановить, когда тебе захочется

const interval = setInterval(() => {
  console.log('RUBURI');
}, 1000);

setTimeout(() => {
  clearInterval(interval);
}, 10000);

в этом коде мы остановим интервал через 10 секунд, используя функцию clearInterval, которая принимает в качестве аргумента интервал

видишь как я ниже использовал setTimeout? setTimeout работает также как setInterval, только он выполняет код единожды. его тоже можно остановить до тех пор пока он не выполнился, используя функцию clearTimeout

давай условимся, что бот будет проверять новый пост в канале каждые 2 секунды

логика у нас уже выходит такая:

  1. даем боту понять какой пост в канале был крайним (id)
  2. запускаем интервал каждые 2 секунды
  3. в интервале проверяем пуст ли пост с id + 1
  4. если не пуст, пост появился! а если пусто, то пока нет
  5. обновляем id на новый если появился новый пост (а то бот будет думать что пост появляется каждые 2 секунды, так как будет сверяться со старым id)

давай выведем текст в консоль, что появился новый пост, как только он появится

// ...код
let lastMessageId = 3;
setInterval(async () => {
  const result = await client.invoke(
    new Api.channels.GetMessages({
      channel: 'testruburibotchannel',
      id: [lastMessageId + 1]
    })
  );
  
  const message = result.messages[0];
  if (message.className !== 'MessageEmpty') {
    lastMessageId++;
    console.log('появился новый пост!');
  }
}, 2000);

все расписали как и хотели. проверяем не равно ли message.className строке MessageEmpty

запускаем код и постим что то в канал за которым следим:

абоба? мен?

а затем смотрим в консоль:

опачки а это мне?

постим еще раз, смотрим еще раз, все работает! то что нужно!

теперь давай сделаем так чтобы пост пересылался каждый раз как он появлятеся в наш канал агрегатор

если ты писал новые посты, помни что нужно обновить lastMessageId с id крайнего поста чтобы все работало

// ...код
let lastMessageId = 5;
setInterval(async () => {
  const result = await client.invoke(
    new Api.channels.GetMessages({
      channel: 'testruburibotchannel',
      id: [lastMessageId + 1]
    })
  );
  
  const message = result.messages[0];
  if (message.className !== 'MessageEmpty') {
    lastMessageId++;
    await client.invoke(
      new Api.messages.ForwardMessages({
        fromPeer: 'testruburibotchannel',
        id: [lastMessageId],
        toPeer: 'aggregatorruburi'
      })
    );
  }
}, 2000);

запускаем, постим, смотрим

сообщения в канале за которым следим
агрегатор

все кайфи работает йоу! бот успешно пересылает все новые посты из канала в агрегатор. но что если каналов не один, а два?

пересылаем посты из нескольких каналов

для того чтобы пересылать посты из нескольких каналов, нам надо хранить id крайнего сообщения нескольких каналов, тут нам поможет структура данных объект. объект будет выступать некой базой данных для нашего бота. выглядеть это будет так:

{
  "testruburibotchannel": 5,
  "kurilka": 1
}

то есть мы храним в ключе ник канала, а в значении — id крайнего поста в нем

в остальном логика абсолютно одинаковая, единственное что, в интервале нам нужно будет проходиться по всем каналам (по всем ключам в объекте) и чекать посты во всех каналах

прежде чем начнем писать код, создай второй канал. имей ввиду, что после создания нового канала, id первого поста будет 2, а не 1, так как сообщение Channel created тоже является своеобразным постом

ник второго канала в моем случае — @testruburos

теперь давай я напишу весь код заново с нововведениями, и объясню тебе непонятные моменты:

import { TelegramClient } from 'telegram'
import { StringSession } from 'telegram/sessions/index.js'

const apiId = 666666;
const apiHash = 'hash';
const stringSession = new StringSession('...token');

const AGGREGATOR_USERNAME = 'aggregatorruburi';
const db = {
  testruburibotchannel: 7,
  testruburos: 2
};

(async () => {
  const client = new TelegramClient(stringSession, apiId, apiHash, {
    connectionRetries: 5
  });

  await client.connect();
  setInterval(() => {
    Object.keys(db).forEach(async (channel) => {
      const lastMessageId = db[channel];
      const result = await client.invoke(
        new Api.channels.GetMessages({
          channel,
          id: [lastMessageId + 1]
        })
      );

      const message = result.messages[0];
      if (message.className !== 'MessageEmpty') {
        db[channel]++;
        await client.invoke(
          new Api.messages.ForwardMessages({
            fromPeer: channel,
            id: [lastMessageId + 1],
            toPeer: AGGREGATOR_USERNAME
          })
        );
      }
    });
  }, 2000);
})();

по сути это весь код для бота агрегатора, давай разбираться построчно

const AGGREGATOR_USERNAME = 'aggregatorruburi';

здесь я сохраняю в константу ник канала агрегатора для удобства

const db = {
  testruburibotchannel: 7,
  testruburos: 2
};

здесь я создаю нашу "базу данных", куда сохраняю каналы за которыми я хочу следить, и id их самых свежих постов

Object.keys(db).forEach(async (channel) => {

Object.keys

Object.keys используется для того, чтобы получить все ключи объекта в массиве

например:

const obj = {
  a: 1,
  b: 2
};
console.log(Object.keys(obj));
// выведет ["a", "b"]

используя Object.keys в нашем кейсе, я получаю в массиве ники всех каналов за которыми мы следим, затем идет метод массивов forEach

Array.forEach

благодаря этому методу ты можешь пройтись по всем значениям массива (например, если тебе нужно сделать что то используя эти значения)

например:

const arr = ["a", "b"];
arr.forEach((value, index, array) => {
  console.log(symbol, index, array);
});
// выведет:
// a 0 ["a", "b"]
// b 1 ["a", "b"]

метод forEach принимает аргументом колбек (функция которая будет вызываться для каждого значения в массиве)

в колбеке ты можешь получить доступ к следующей информации:

  1. само значение (первый аргумент колбека)
  2. индекс под которым это значение (второй аргумент колбека)
  3. сам массив (третий аргумент колбека)

подытоживая этот кусочек кода, я получаю массив ников всех каналов за которыми мы следим, и прохожусь по каждому нику используя forEach

мне нужно проходиться по каждому нику, чтобы я проверил есть ли новое сообщение в том или ином канале

Object.keys(db).forEach(async (channel) => {
  const lastMessageId = db[channel];

получаем id крайнего сообщения каждого канала из базы данных и сохраняем его в переменную lastMessageId

в конструкции db[channel] вместо channel будет подставляться каждый ник из тех за которыми мы следим

const message = result.messages[0];
if (message.className !== 'MessageEmpty') {
  db[channel]++;
  await client.invoke(
    new Api.messages.ForwardMessages({
      fromPeer: channel,
      id: [lastMessageId + 1],
      toPeer: AGGREGATOR_USERNAME
    })
  );
}

здесь уже знакомая тебе логика, единственное что, не забываем обновить id крайнего сообщения в канале, где появился новый пост

db[channel]++

запускаем, постим в каналах и смотрим на результат:

с кайфом!

грац! вот так мы с тобой написали бота агрегатора!

если ты хочешь добавить больше каналов, просто добавляй ник канала и id крайнего сообщения в объект db и бот будет за ними следить

поздравляю! ты написал бот агрегатор каналов!

не ну ты ваще молорик йомайо!

весь код доступен здесь -> https://file.io/s3sIXxURRiHC

чил раслабон теперь

как сделать так, чтобы бот работал всегда?

для этой цели тебе понадобится дедик или VPS. суть в том, чтобы запустить бота на дедике — он будет работать всегда (только если дедик не упадет)

единственное, бота надо будет запустить фоном. для того чтобы запускать какие то скрипты фоном (чтобы они не закрывались при дисконнекте с сервера) нужна ютилита типа screen


заключение

спасибо за то что прочитал статью!

это было немаленькое и надеюсь увлекательное для тебя путешествие! я постарался изложить максимум своих знаний в эту статью. мы написали клона славы и бот агрегатор, думаю получилось круто!

можно сказать что в эту статью я вложил также немного продолжения статьи по JS так как мы с тобой здесь разбирали больше различных концептов

я буду еще писать о разных технических штуках, так что подписывайся на рубури!

можешь не беспокоиться если чето не запомнилось, это нормально. не отступай и иди к цели. когда я учился я хотел бросить люто масштабно два раза... но не останавливался пон!!!!! я не остановился и ща работаю дворничком (шутка конечно мужики я во вкусно и точка)

ебашьте и все будет ахуенно

с кайфом

благодарности

спасибо тебе за прочтение, спасибо тем кто ставит реакции, подпищекам, рыжему, сс ресерч, моей девочке, френдли тагу 52 нгг и всем остальным кто пон мотивирует ебашить люто жоска

и удачи тебе в пути кодинга еба!

отзыв

если в каком то примере ошибка, или что то не получается вы всегда можете обратиться за вопросом в телегу к рубурику @rubyuroboros или написать прямо в комментариях


мой канал — https://t.me/ruburi