How to write UK Rent Telegram Bot

It is tricky time when you start looking for a new property to rent in the UK. You should spend a lot of time to monitor many different websites every day. Good flats might gone in the same day when new property has been published, especially if you are looking for a new flat in the busiest time of the year — summer.

I had the same problem and had decided to write a small Telegram bot which could help me stop wasting time on websites. I use Telegram every day and really like this platform because it is really fast and powerful. So, let me introduce UKRentBot. Follow this link and try it.

Telegram has good api to work with messenger and a lot of frameworks to simplify development. I prefer to use node.js and decided to start with telegraf.js framework. This is widely using framework with good documentation, Typescript codebase and also it supports latest Telegram api with a lot of different features. As database I use free version of Google Firebase Database. It is the first time when I use it and this is a good product to create MVP versions of your app or for a simple, small application.

Before make requests to the Telegram api you need to create a new bot on Telegram platform and to get bot token. You just need to go to BotFather bot https://t.me/botfather and use his commands to create you own bot, set description or about text. To be honest this bot is a good example how every bot should work.

Now I can start development. Have a look on my index.ts file. Bot initialization is not difficult. Also I use i18n middleware to support 2 languages.

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

With Telegram api every bot can support different commands, actions, scenes or wizard actions. Have a look on initActions function.

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

Let’s have a look on actionSearch. In this action I start wizard after few command validations. On this step I also try to save chatId because I use it later to send new updates. First time I save it on /start command (this is first comment when user start working with bot).

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

Wizard is step by step form which I use to ask a user about desired property. And from my point of view it is the most difficult part in the bot.

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

For example, wizard code. In the wizard we should describe actions step by step to get data from user input.

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

Every new step you should validate user input and then send a new message to chat with a new form question. For example, have a look on the function processLocation below. To go to the next step method wizard.next() should be executed.

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

You can see the full wizard form on the image above. Always have possibility (command or action) to return from wizard. For example, you can have wizard command /cancel.

It is really cool when for every question you can add special button actions or show specific keyboard when user can just click a button and a form would be completed. It is good UI experience especially for mobile users.

So, that is it. On the last step I save form data to Firebase. Have a look on few functions to operate with user data in database. When you work with Firebase you should a little bit change your mind, especially if before you worked only with SQL based databases because data structure would be different.

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

This is my database structure. I am not 100% sure if this structure is correct, but it is fast to access to important data.

Now all data stored in database and user can have access to searches with /searches command. And job actions is going to start find new properties. I have 3 different jobs — search new properties (every 4 hours for every chat), send new properties to user chats (every 15 minutes) and remove expired chats. For all jobs i use setInterval function.

I like that Firebase has observer which reduce requests to database on every timer event. For example you can have only 2 subscribers for specific path and when new or updated data would be available you will get new event.

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

When new results would be found bot will send message to user chat. Instead of one message i should send 2 messaged because with mediaGroup message Telegram does not support Markdown and custom keyboard. That is sad.

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

And result you can see on this image

Of course there are a lot of things which i can improve and add to this bot, but at this moment it covers most of my cases. If you interesting in writing telegrams bots, have an idea or feature request you can have a look on my code on github https://github.com/VeXell/UKRentHomeHunter.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Viacheslav Volkov

Viacheslav Volkov

1 Follower

JS Developer from London, UK. Working with React, React Native and Typescript. My russian blog https://vexell.ru