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

У меня была та же проблема, и я решил написатьнебольшого бота для Telegram, который помог бы мне перестать тратить время на веб-сайты. Я использую Telegram каждый день, и мне очень нравится эта платформа, потому что она действительно быстрая и мощная. Итак, позвольте представить вам UKRentBot. Перейдите по этой ссылке и попробуйте.

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

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

Теперь я могу приступить к разработке. Взгляните на мой файл index.ts. Инициализация бота не сложная. Также я использую промежуточное ПО i18n для поддержки двух языков.

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();

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

export function initActions(bot: Telegraf<TelegrafContext>) {
  // Two important commands for bot
  bot.start(actionStart);
  bot.help(actionHelp);
  // Set bot quick menu
  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);
  // ...Other commands and actions here
  // With actions you can have specials buttons with RegExp format
  // I use it when user wants to remove search from list
  bot.action(new RegExp(`${GLOBAL_ACTIONS.remove}_(?<id>.*)?$`), actionRemove);
}

Давайте посмотрим на actionSearch. В этом действии я запускаю мастер после нескольких проверок команд. На этом шаге я также пытаюсь сохранить chatId, потому что я использую его позже для отправки новых обновлений. Первый раз сохраняю в команду /start (это первый комментарий, когда пользователь начинает работать с ботом).

export default async function actionSearch(ctx: TelegrafContext) {
  const message = ctx.i18n.t("wizardSearch.intro");
  const chatId = ctx.from?.id;
  if (chatId) {
    // Save new chat to database
    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
      ) {
        // Do not allow more searches
        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());
        // Enter to wizard
        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>) {
  // Initilization of all scenes in the bot 
  const stage = new Scenes.Stage<TelegrafContext>([searchWizard]);
  // Global command and actions in wizard to exit from it
  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,
        })
      );
    }
    // When we finished with data validation and store required values we send new message to the chat.
    await askForDistance(ctx);
    return ctx.wizard.next();
  } catch (error) {
    return cancelSearchReply(ctx, error.message);
  }
}

Вы можете увидеть полную форму мастера на изображении выше. Всегда есть возможность (команда или действие) вернуться из мастера. Например, у вас может быть команда мастера /cancel.

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

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

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);
}

Это моя структура базы данных. Я не уверен на 100%, что эта структура верна, но доступ к важным данным быстрый.

Теперь все данные, хранящиеся в базе данных, и пользователь может получить доступ к поиску с помощью команды /searchs. И рабочие действия начнут обретать новые свойства. У меня есть 3 разные задачи — поиск новых свойств (каждые 4 часа для каждого чата), отправка новых свойств в пользовательские чаты (каждые 15 минут) и удаление просроченных чатов. Для всех заданий я использую функцию setInterval.

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

function getAllSearchesRef() {
  return getDB().ref(`${PATH}`);
}
let searches: ISearchEntries | null = null;
getAllSearchesRef().on("value", (snapshot) => {
  if (snapshot.exists()) {
    searches = snapshot.val();
  }
});

Когда будут найдены новые результаты, бот отправит сообщение в чат пользователя. Вместо одного сообщения я должен отправить 2 сообщения, потому что с сообщением mediaGroup Telegram не поддерживает Markdown и пользовательскую клавиатуру. Это печально.

function formatTgMessage(
  area: string,
  searchResult: ISearchResult
): {
  media: { type: "photo"; media: string; caption?: string }[];
  text: string;
} {
  // const caption = `Property at ${area}.`;
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}`,
  };
}
// Other function ...
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) {
    // Chat not found
  }
  if (error.response?.error_code === 403) {
    // Chat has been blocked. Remove search and data
    await removeSearch(chatId, searchId);
    await removeSearchResults(chatId, searchId);
  }
  break;
}

И результат вы можете увидеть на этом изображении

Конечно, есть много вещей, которые я могу улучшить и добавить к этому боту, но на данный момент он охватывает большинство моих случаев. Если вы заинтересованы в написании ботов для телеграмм, у вас есть идея или запрос функции, вы можете посмотреть мой код на github https://github.com/VeXell/UKRentHomeHunter.