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