Создание Telegram бота на примере поиска недвижимости

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

Бота я реализовал с помощью фреймворка telegraf.js для node.js. Бот называется UKRentBot доступен для всех желающих. Исходный код находится на GitHub по ссылке https://github.com/VeXell/UKRentHomeHunter. В этой статье я хотел бы рассказать как создавался этот бот и как по аналогии можно создавать других ботов.

Telegram предоставляет прекрасное api которое позволяет создавать ботов с разным функционалом. К этому api уже написано множество разных фреймфорков которые позволяют с ним взаимодействовать и упрощают работу. Я взял telegraf.js из-за того, что он один из самых популярных, имеет поддержку Typescript и постоянно обновляется под новое api Telegram. В качестве базы данных я решил попробовать использовать Google Firebase Database которая отлично подходит для создания MVP проектов или небольших приложений как у меня. Она доступна из коробки и не требует какой-то установки на собственный сервер.

Прежде всего, перед созданием бота, необходимо обратится к BotFather боту https://t.me/botfather который отвечает за создание новых ботов. После заполнения минимального количества полей вам будет доступен api ключ для выполнения запросов к Telegram api.

Теперь можно приступить к разработке. Ниже представлен листинг файла инициализации бота и подключение библиотеки локализации.

import './env';
import {Telegraf, session} from 'telegraf';
import TelegrafI18n from 'telegraf-i18n';
import {TelegrafContext} from 'types';

// Read ENV variables
import {DATABASE, FIREBASE_AUTH, BOT_TOKEN} from 'config';
import enLocale from './locales/en';
import ruLocale from './locales/ru';
import {initActions} from 'actions';
import {initWizards} from 'wizards';
import {initJobs} from 'jobs';
import {initDatabase} from 'services/db';

initDatabase(FIREBASE_AUTH, DATABASE);

const i18n = new TelegrafI18n({
    defaultLanguage: 'en',
    allowMissing: true,
    useSession: true,
    defaultLanguageOnMissing: true,
});

i18n.loadLocale('en', enLocale);
i18n.loadLocale('ru', ruLocale);

const bot = new Telegraf<TelegrafContext>(BOT_TOKEN);

bot.use(session());
bot.use(i18n.middleware());
initWizards(bot);
initActions(bot);

// Start bot
bot.launch();

Telegram api поддерживает различные команды, действия и сцены которые можно реализовать как форму с шагами (визарды). Например, в функции initActions я сделал начальную инициализацию всех действий.

export function initActions(bot: Telegraf<TelegrafContext>) {
  
  // Две обязательные команды для бота
  bot.start(actionStart);
  bot.help(actionHelp);
  
  // Быстрое меню
  bot.settings(async (ctx) => {
    await ctx.setMyCommands([
      {
        command: GLOBAL_ACTIONS.search,
        description: ctx.i18n.t(`actions.${GLOBAL_ACTIONS.search}`),
      },
      {
        command: GLOBAL_ACTIONS.searches,
        description: ctx.i18n.t(`actions.${GLOBAL_ACTIONS.searches}`),
      },
      {
        command: GLOBAL_ACTIONS.share,
        description: ctx.i18n.t(`actions.${GLOBAL_ACTIONS.share}`),
      },
    ]);
  });
  
  bot.command(GLOBAL_ACTIONS.search, actionSearch);
  
  // Остальные команды описаны ниже
  // Кнопки можно делать указывая RegExp формат
  // Я использую такие действия для удаления заданного поиска 
  bot.action(new RegExp(`${GLOBAL_ACTIONS.remove}_(?<id>.*)?$`), actionRemove);
}

При команде /start я вывожу основную информацию по боту, а также основные действия с ним.

Начальный экран телеграм бота UKRent

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

export default async function actionSearch(ctx: TelegrafContext) {
  const message = ctx.i18n.t("wizardSearch.intro");
  const chatId = ctx.from?.id;
  
  if (chatId) {
    // Сохраняем данные в БД
    updateChat(chatId, {
      firstName: ctx.from?.first_name || "",
      lastName: ctx.from?.last_name || "",
      username: ctx.from?.username || "",
      language: ctx.from?.language_code || "",
  });
        
  try {
      const activeSearches = await getSearches(chatId);
      ctx.session.activeSearches = activeSearches;
      if (
        activeSearches &&
        Object.keys(activeSearches).length >= MAX_SEARCHES
      ) {
        // Не больше 3 поисков за раз
        return ctx.replyWithMarkdown(
          ctx.i18n.t("error.maxSearchesReached", {
            maxSearches: MAX_SEARCHES,
          }),
          Markup.inlineKeyboard([
            Markup.button.callback("📝 My Searches", GLOBAL_ACTIONS.searches),
          ])
        );
      } else {
        await ctx.replyWithMarkdown(message, Markup.removeKeyboard());
        // Входим в визард
        return ctx.scene.enter(SEARCH_WIZARD_TYPE);
      }
    } catch (error) {
      console.log("error");
    }
  } else {
    return ctx.replyWithMarkdown(
      ctx.i18n.t("error.emptyChatId"),
      Markup.removeKeyboard()
    );
  }
}

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

export function initWizards(bot: Telegraf<TelegrafContext>)
{
  // Инициализация сцен
  const stage = new Scenes.Stage<TelegrafContext>([searchWizard]);
    
  // Глобальная команда для отменя визарда поиска
  stage.action(ACTIONS.CANCEL, (ctx) => {
    ctx.reply(ctx.i18n.t("operationCanceled"));
    return ctx.scene.leave();
  });
    
  stage.command(ACTIONS.CANCEL, (ctx) => {
    ctx.reply(ctx.i18n.t("operationCanceled"));
    return ctx.scene.leave();
  });
    
  bot.use(stage.middleware());
}

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

export default new Scenes.WizardScene<TelegrafContext>(
  WIZARD_TYPE,
  async (ctx) => {
    const chatId = ctx.chat?.id;
    ctx.scene.session.search = {
      chatId,
    };
      
    await ctx.replyWithMarkdown(
      ctx.i18n.t("wizardSearch.actions.location")
    );
      
    return ctx.wizard.next();
  },
  processLocation,
  // Other actions...
);

В функции processLocation ниже как раз и происходит обработка ввода. Если все прошло успешно, то необходимо вызывать следующую сцену с помощью метода wizard.next().

export default async function processLocation(ctx: TelegrafContext) {
  try {
      
    if (
      !ctx.message ||
      !("text" in ctx.message) ||
      ctx.message.text.length <= 2
    ) {
      throw new IncorrectMessageError(
        ctx.i18n.t("wizardSearch.errors.location")
      );
    }
      
    try {
      const location = await detectLocation(ctx.message.text);
      ctx.scene.session.search.area = location.locationName;
      ctx.scene.session.search.searchAreaId = location.locationId;
    } catch (error) {}
      
    if (!ctx.scene.session.search.area) {
      throw new NoLocationFoundError(
        ctx.i18n.t("wizardSearch.errors.locationNotFound")
      );
    }
      
    let locationAlreadyInSearch = false;
      
    if (ctx.session.activeSearches) {
      const searches = ctx.session.activeSearches;
      Object.keys(searches).forEach((key) => {
        const searchObject = searches[key];
        if (
          searchObject.searchAreaId === ctx.scene.session.search.searchAreaId
        ) {
          locationAlreadyInSearch = true;
        }
      });
    }
      
    if (locationAlreadyInSearch) {
      throw new LocationAlreadyInSearchError(
        ctx.i18n.t("wizardSearch.errors.locationAlreadyInSearch", {
          location: ctx.scene.session.search.area,
        })
      );
    }
      
    // После все валидации данных переходим на след шаг.
    await askForDistance(ctx);
    return ctx.wizard.next();
  } catch (error) {
    return cancelSearchReply(ctx, error.message);
  }
}

Ниже показан результат визарда бота. Пользователь в любой момент может отменить поиск с помощью команды /cancel

Визард телеграм бота UKRent

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

export function saveSearch(
  searchRequest: ISearchRequestInput
): Promise<ISearchRequestRecord> {
  const searchesListRef = getDB().ref(`${PATH}/${searchRequest.chatId}`);
  const searchRef = searchesListRef.push();
  return searchRef.set({
    ...searchRequest,
    ...{
      createdAt: moment.utc().format(),
      expiredAt: moment.utc().add(30, "days").format(),
      lastSearchAt: null,
    },
  });
}

export async function getSearches(
  chatId: number
): Promise<ISearchRecords | null> {
  const searchesList = await getDB().ref(`${PATH}/${chatId}`).get();
  if (searchesList.exists()) {
    return searchesList.toJSON() as ISearchRecords;
  }
  return null;
}

export async function removeSearch(
  chatId: number,
  index: string
): Promise<boolean> {
  await getDB().ref(`${PATH}/${chatId}/${index}`).remove();
  return true;
}

type IUpdateSearchRecord = Partial<ISearchRequestRecord>;

export function updateSearch(
  chatId: number,
  index: string,
  search: IUpdateSearchRecord
) {
  return getDB().ref(`${PATH}/${chatId}/${index}`).update(search);
}

При работе с Firebase приходится отходить немного от мышления сохранения данных в SQL формате, да и сама Firebase имеет свои особенности с её работой. В итоге структура моей базы данных получилось как на изображении ниже.

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

Очень здорово, что Firebase позволяет подписываться на изменение структуры вместо постоянных опросов БД. Делается достаточно просто. Теперь как только изменятся данные по указанному пути, они автоматически загрузятся в бота.

function getAllSearchesRef() {
  return getDB().ref(`${PATH}`);
}

let searches: ISearchEntries | null = null;

getAllSearchesRef().on("value", (snapshot) => {
  if (snapshot.exists()) {
    searches = snapshot.val();
  }
});

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

function formatTgMessage(
  area: string,
  searchResult: ISearchResult
): {
  media: { type: "photo"; media: string; caption?: string }[];
  text: string;
} {
    
 
const images = Array.isArray(searchResult.images) ? searchResult.images : [];
return {
    media: images.map((imageUrl) => {
      return {
        type: "photo",
        media: imageUrl,
      };
    }),
text: `
🏠 ${searchResult.title} / 💷 *${searchResult.price}* 
🗓 Available from *${searchResult.availableFrom}*
📍 *${searchResult.address}*
Search in ${area}`,
  };
}
      

const message = formatTgMessage(area, searchResult);
const media = message.media.slice(0, 10);
let submitted = false;
      
try {
  if (media.length) {
    await telegramBot.telegram.sendMediaGroup(chatId, media);
  }
    
  await telegramBot.telegram.sendMessage(chatId, message.text, {
    parse_mode: "Markdown",
    reply_markup: {
      inline_keyboard: [[Markup.button.url("↗️ Open", searchResult.openUrl)]],
    },
  });
  submitted = true;
} catch (error) {
  if (error.response?.error_code === 400) {
    // Чат не найден
  }
  if (error.response?.error_code === 403) {
    // Чат был заблокирован. Удаляем поиск
    await removeSearch(chatId, searchId);
    await removeSearchResults(chatId, searchId);
  }
  break;
}

Результат работы бота можно посмотреть на изображении снизу

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

Как я писал выше - исходный код бота доступен по ссылке https://github.com/VeXell/UKRentHomeHunter и вы можете использовать мой пример для написания своих ботов. Если вам понравился мой бот - поставьте мне звезду на GitHub.