Telegram Bot with Cloud Function and Firestore

Kai Kok Chew
17 min readDec 27, 2023

--

Get a bot up and running rapidly. Transform and roll out!

Photo by Nabo Ghosh on Unsplash

In this exercise, we are going to do some rapid coding of a Telegram Bot to expand on what we have done earlier with Firebase and Firestore.

This Telegram bot is a Point Tracking system.

Our target users are busy parents and playful children who could use some incentive system to develop positive habits.

The Minimal Viable Product (MVP) of this system will do the following:

  • Allow administrator to record points scored by participants
  • Demerit could also be issued
  • The accumated points could be redeemed for some incentives
  • Interface to query accumated points of each participant
  • Interface to add, update items for scoring, demerit or redemption

Some technical activities and discussions involved are listed below:

  • Creating a Telegram bot account.
  • Setup Firebase, Cloud Function infrastructure.
  • Using Telegraf library to handle telegram message processing.
  • Express.js server to handle routing between telegram webhook and other diagnostic API calls.
  • Backend data design of a Point Tracking system and using FireStore to build it.
  • Experiment and evolve the various approach to manage conversations with the bot.
  • Develop this system under a week of effort.

Without further a do, we will start.

Create a Telegram Bot account

First we need to create a bot account on Telegram and acquire its token.

This is fairly easy as we could just follow the official instructions found at [1].

This token will be needed later when we develop the code which will be the brains of our bot.

Firebase and Cloud Function infrastructure

Primer tutorials can be found at [2]. We will start our work from the hello world cloud function.

Minimal Telegram Bot

We will make the following modifications to the code of index.ts to have a simple running bot.

  • Install and import, Express, Telegraf and Dotenv library.
  • Dotenv allows us to make environment variables available to the application. This allows us to inject sensitive variables from the environment and not keep them in our code.
  • Express.js is a web application server framework which will help us address routing needs and make available utilities for managing APIs.
  • For a start we will run our Telegram bot as a webhook service under the /telegram route.
  • The webhook waits for updates from the Telegram server when someone attempts to message our Telegram bot.
  • We will also use function logger to log out some info that helps jump start the use of our Telegram bot.
my-project/functions$ npm i express telegraf dotenv

Make sure esModuleInterop is set to true in tsconfig.json to import Express.js.

{
"compilerOptions": {
"esModuleInterop": true,
"module": "commonjs",
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"sourceMap": true,
"strict": true,
"target": "es2017"
},
"compileOnSave": true,
"include": [
"src"
]
}

The following code will start a Telegram bot that responds to a “hi” from any user with the current time while record down their user id into our cloud logs.

import * as functions from "firebase-functions";
import express from "express";
import "dotenv/config";

import {Telegraf} from "telegraf";

const app = express();
let telegramStatus = "Bot is not loaded";

if (!process.env.YOURTOKEN) {
functions.logger.info("YOURTOKEN is not defined.", {structuredData: true});
telegramStatus = "YOURTOKEN is not defined.";
} else {
const bot = new Telegraf(process.env.YOURTOKEN, {
telegram: {webhookReply: true},
});

bot.hears("hi", (ctx) => {
const message = `Hey there, it is ${(new Date()).toLocaleString()} now.`;
ctx.reply(message);
functions.logger.info(message, {structuredData: true});
functions.logger.info(
`${ctx.message.from.username}: ${ctx.from.id}: ${ctx.message.chat.id}`,
{structuredData: true});
});

app.use(bot.webhookCallback("/telegram",
{secretToken: process.env.API_TOKEN}));
telegramStatus = "Bot is loaded.";
}

app.get("/hello", async (req, res) => {
res.send("Hello, Firebase!");
functions.logger.log(`hello, ${telegramStatus}`);
return;
});

exports.api = functions.https.onRequest(app);

Update your .env file to contain the following.

YOURTOKEN=<Your telegram bot Token>
API_TOKEN=<Generate some unique token here, https://www.uuidgenerator.net/version4>

Notify Telegram about the webhook for your new Telegram Bot with the values in your .env file.

my-project/functions$ curl -F "url=https://us-central1-my-project.cloudfunctions.net/api/telegram" -F "secret_token=<API_TOKEN>" "https://api.telegram.org/bot<YOUR_TOKEN>/setWebhook"

After deployment of the cloud functions. Perform a test on the normal API.

my-project/functions$ curl  -k https://us-central1-my-project.cloudfunctions.net/api/hello
Hello, Firebase!

Try sending a “hi” to your own bot with your own Telegram. You should see it’s response below.

Now, let us go to the cloud logs and observe the following.

Record down your own numeric user id. We will use it to secure the usage of the Point Tracking system.

Backend data design and setup

With the basic Telegram bot interaction working we can now move to the design and setup of the Point Tracking system, starting with the backend data design.

We will persist the data on Firestore for convenience. Refer to [3] for an example on the setup and simple usage of Firestore.

Regarding authentication, we will rely on the telegram user ID to determine the identity of the user.

Given the identity, we determine if he or she is allowed to access the bot and the amount of authorisation to be given.

Hence for Authentication and Authorisation, we will require a Firestore collection named as users. In this collection, each document will represent a User.

For now, we will use the ID of the user as the document ID. This facilitates quick retrieval without need to make a query on a given field.

One of the data field will be a named string to represent the permission given and another will be a boolean flag to represent validity. The choice of a flag, makes it easier to disable or enable an existing user.

Next, we will have a collection of documents each containing a list for specific class of items in the system. Example of these classes are score, demerit and redemption items.

The list of items within each class are stored in a single a document with Array fields.

This assumes that the number of items created will be limited and we expect frequent queries to list all items belonging to a class.

This design allows a single query to pull out the full list quickly.

As for individual participants, they will be represented by individual documents within a collection called participants.

Each document will have a field to aggregate the total score and a sub-collection which contains events as indivdual documents.

Each event document captures the timestamp for the score, demerit or redemption along its name, item type and item id.

Entity Relationship Diagram (ERD)

Let us create the skeleton in the Firestore through the Firebase console to kick start the application for now.

Note, it is more peferable we have a programatic data creation, migration and reset mechanism in place but for now I will race ahead.

We will add items for score.

Adding score document to items collection.

Rinse and repeat for demerit and redemption.

Hierarchy of score, demerit and redemption document in items collection.

Then we add the participant, playerA.

Creating participants.

We will need to add a sub-collection for the participant. Click on start collection under playerA.

Click on Start collection under playerA to create a sub-collection
Creating events sub-collecfion
Creating a sample event within the collection.

And we repeat for playerB.

Finally, we will add our first user. Use the user ID captured in the log earlier as the document ID here.

Creating a user.

Retrieve data with Telegram Bot command

We will have some utility functions to reduce verbose API calls to retrieve information from Firebase in a separate file called data-access.ts.

import * as admin from "firebase-admin";


export const getData = async (
firestore: admin.firestore.Firestore,
path: string,
defaultData: admin.firestore.DocumentData):
Promise<admin.firestore.DocumentData> => {
let data: admin.firestore.DocumentData = defaultData;
const docRef = await firestore.doc(path).get();
if (docRef !== undefined) {
data = docRef.data() || defaultData;
}
return data;
};

Next we add code to implement some functionalities of the application.

Starting with retrieval of total points of the participants by modifying index.ts into the following.

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import express from "express";
import "dotenv/config";

import {Telegraf} from "telegraf";
import {getData} from "./data-access";

if (admin.apps.length === 0) {
admin.initializeApp();
}

const firestore = admin.firestore();
const app = express();
let telegramStatus = "Bot is not loaded";

if (!process.env.YOURTOKEN) {
functions.logger.info("YOURTOKEN is not defined.", {structuredData: true});
telegramStatus = "YOURTOKEN is not defined.";
} else {
const bot = new Telegraf(process.env.YOURTOKEN, {
telegram: {webhookReply: true},
});

bot.hears("hi", (ctx) => {
const message = `Hey there, it is ${(new Date()).toLocaleString()} now.`;
ctx.reply(message);
functions.logger.info(message, {structuredData: true});
functions.logger.info(
`${ctx.message.from.username}: ${ctx.from.id}: ${ctx.message.chat.id}`,
{structuredData: true});
});

bot.command("points", async (ctx) => {
const playerAPoints =
await getData(firestore, "participants/playerA", {total: 0});
const playerBPoints =
await getData(firestore, "participants/playerB", {total: 0});

let reply = "Participant : Score\n";
reply = reply.concat(`playerA : ${playerAPoints.total}\n`);
reply = reply.concat(`playerB : ${playerBPoints.total}\n`);
await ctx.reply(reply);
return;
});

app.use(bot.webhookCallback("/telegram",
{secretToken: process.env.API_TOKEN}));
telegramStatus = "Bot is loaded.";
}

app.get("/hello", async (req, res) => {
res.send("Hello, Firebase!");
functions.logger.log(`hello, ${telegramStatus}`);
return;
});

exports.api = functions.https.onRequest(app);

Note that we have hardcoded the participants name for this MVP.

The reason is we want to save time given we know how many participants we have up front. We will fix that post MVP.

With the above deployed to the cloud functions, we could put in through a test. Our Telegram Bot should return us something as follows.

Querying for points.

Subsequently, we could add a command to support scoring of points for the participants.

The first thing we would like to handle is to prevent unauthorised access to tamper with the score.

User Authorisation

Remember the collection that we created to keep documents that represent our user? We will now use it to authorise our users. Add the following helper functions.

const getUser = async (
firestore: admin.firestore.Firestore,
userId: number): Promise<admin.firestore.DocumentData> => {
const user = await getData(
firestore,
`users/${userId}`,
{valid: false, role: "none"});
return user;
};

const isAdmin = (
user: admin.firestore.DocumentData): boolean => {
if (user.role === "admin") return true;
return false;
};

And let us use it when our Telegram Bot receives a command.

  bot.command("score", async (ctx) => {
const user = await getUser(firestore, ctx.from.id);
if (!user.valid) return;
if (!isAdmin(user)) return;
await ctx.reply("Done!");
}

Telegram command handling

As for the interaction over chat, it is not preferred to rely on free text entry for fixed selections like chosing which player to score or what item to be attributed to the score.

This is because there could be typos.

We could address this with telegram in the MVP using Telegram’s built-in keyboard feature.

However we need 2 inputs here, the participant name and then the item to be scored.

We cannot dish out a list of keyboard buttons with different combinations of participant and scoring items.

Hence, we will need to break it into 2 step interaction. Firstly, the bot will prompt for choice of participant followed by the scoring item.

Intuitively, we would have realised that we needed to implement some of form of state machine to track conversation. From asking and listening for input to recording the score.

There is a few possible approach from here.

  • We could use an existing state management library and figure out how to interface apply it to a serverless bot interaction. Without a familiar state machine library on hand, it would consume a fair amount of time get acquainted with one and use it effectively.
  • Bespoke a minimal one. Given the time constrain, we probably would only develop a trivial state management with only the functionalities we need for now. There is lesser value to invest on yet another state management library if there are existing one in the open. Unless our application needs are very specialised or extensive such that it enables us develop a much more robust version.
  • Make do using some implicit state management until we validated our product features. As we need to scale with more commands and varied features, research about existing open source state management library and apply it on the next feature. If this works, refactor and move previous commands over.

Based on the analysis and context, we decided to go with the 3rd approach. We could do without having explicit state management for scoring points by embedding state context into the Telegram bot keyboard commands.

For example, the “/score” command without any other arguments will setup a keyboard with “/score playerA” and “/score playerB”.

When the user chooses one of them, the commands like “/score playerA” are sent to our Telegram bot.

This additional context trailing the command carries the implied current state.

The code will disambiguate the state of interaction based on available arguments trailing the command and varies the handling.

The following code illustrates how it will look like.

  bot.command("score", async (ctx) => {
const commandName = "score";
const participants = ["playerA", "playerB"];
const user = await getUser(firestore, ctx.from.id);
if (!user.valid) return;
if (!isAdmin(user)) return;

const tokens = ctx.message.text.split(" ");

if (tokens.length < 2) {
const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};
const numOfParticipants = participants.length;
for (let idx = 0; idx < numOfParticipants; idx++) {
builtinKeyboard["keyboard"].
push([`/${commandName} ${participants[idx]}`]);
}

functions.logger.info(ctx, {structuredData: true});
await ctx.reply(`Who do you want to ${commandName}?`,
{reply_markup: builtinKeyboard});
} else if (tokens.length === 2 ) {
const name = tokens[1];
const aggregate =
await getData(firestore, `items/${commandName}`,
{descriptions: [], values: []});

const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};
const length = aggregate.descriptions.length;
for (let idx = 0; idx < length; idx++) {
builtinKeyboard.keyboard.push(
[`/${commandName} ${name} ${idx} ${aggregate.descriptions[idx]}`]);
}

await ctx.reply("What item?", {reply_markup: builtinKeyboard});
} else {
const name = tokens[1];
const itemIdx = tokens[2];
const idx = ctx.message.text.search(name);
const itemName = ctx.message.text.substring(
idx + name.length + itemIdx.length + 2);

const aggregate =
await getData(firestore, `items/${commandName}`,
{descriptions: [], values: []});

const eventRef = admin.firestore().collection("participants").
doc(name).collection("events");
await eventRef.add({
type: `${commandName}`,
name: itemName,
item_id: itemIdx,
timestamp: admin.firestore.Timestamp.now()});
const pointsRef = admin.firestore().collection("participants").doc(name);
await pointsRef.update(
{total:
admin.firestore.FieldValue.
increment(aggregate.values[parseInt(itemIdx)])});

await ctx.reply(`Done! ${itemName}`, {reply_markup: removeKeyboard});
}
});

Note the less than intuitive design to decipher the states based on number of tokens in the user input.

Entire logic is bunched into a single if else chain which gets messy when conversation involves more steps.

There is also the magic of understanding the behavior about built-in keyboards.

All these affects readability and ease of maintenance in future. It cannot scale well if the application evolves to require many commands or complex conversations with branching states. But we are expecting to discard this later on when the product scales.

Now that we need to update or create new docs in a collection, we would need to add the following to data-access.ts too.

export const addDoc = async (
firestore: admin.firestore.Firestore,
path: string,
data: admin.firestore.DocumentData):
Promise<admin.firestore.DocumentData> => {
const collectionRef = await firestore.collection(path);
if (collectionRef === undefined) {
throw new Error("Collection not found");
}
return await collectionRef.add(data);
};

export const updateDoc = async (
firestore: admin.firestore.Firestore,
path: string,
data: {[x: string]: unknown;}):
Promise<admin.firestore.DocumentData> => {
const docRef = firestore.doc(path);
return await docRef.update(data);
};

To test this out, we need to add some items for scoring. We could update the data from the Firebase console for now.

We need to do this for now because the feature to manage items from the Telegram bot is not yet developed.

Click on the add field beside the descriptions array.

Documents within items collection.
Adding a description for a new item.

Repeat for the values array.

Entering its corresponding value.

At the end, we would like to see something like this. The index for both arrays is the item ID.

With the data setup done, lets start talking to the bot.

Observe the built-in keyboard.
Selecting a button from the keyboard sends the button value to our bot.
Feedback that update is complete.
We can query to check that the points are added correctly.

And everything looks good for now except that we will need to scale for demerit and redemption.

Note that at this point we could choose between copy and paste or generalising the code. Ideally, we should refactor the code and generalise the functionality.

Sometimes, it may not be obvious and we might start with a copy and paste followed with adaptions to make it work.

However, this creates a technical debt. We need to limit such actions. For example, at most one copy for exploratory purposes with a bounded time to get back and refactor.

What happened for me was I made a copy first and after it is working, I start to identify the differences between implementation and create a generalised helper function and replace them.

import {KeyboardButton, Message, ReplyKeyboardRemove, Update}
from "telegraf/typings/core/types/typegram";

...

const removeKeyboard : ReplyKeyboardRemove= {
"remove_keyboard": true,
"selective": true,
};

const cmdDocNameLookup : {[key: string]: string} = {
"score": "score",
"demerit": "demerit",
"redeem": "redemption",
};

...

const handlePointsUpdate = async (
ctx: Context<{
message: Update.New & Update.NonChannel & Message.TextMessage;
update_id: number;}>,
commandName: string,
participants: Array<string>): Promise<void> => {
const user = await getUser(firestore, ctx.from.id);
if (!user.valid) return;
if (!isAdmin(user)) return;

const tokens = ctx.message.text.split(" ");

if (tokens.length < 2) {
const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};
const numOfParticipants = participants.length;
for (let idx = 0; idx < numOfParticipants; idx++) {
builtinKeyboard["keyboard"].
push([`/${commandName} ${participants[idx]}`]);
}

functions.logger.info(ctx, {structuredData: true});
await ctx.reply(`Who do you want to ${commandName}?`,
{reply_markup: builtinKeyboard});
} else if (tokens.length === 2 ) {
const name = tokens[1];
const aggregate =
await getData(firestore,
`items/${cmdDocNameLookup[commandName]}`,
{descriptions: [], values: []});

const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};
const length = aggregate.descriptions.length;
for (let idx = 0; idx < length; idx++) {
builtinKeyboard.keyboard.push(
[`/${commandName} ${name} ${idx} ${aggregate.descriptions[idx]}`]);
}

await ctx.reply("What item?", {reply_markup: builtinKeyboard});
} else {
const name = tokens[1];
const itemIdx = tokens[2];
const idx = ctx.message.text.search(name);
const itemName = ctx.message.text.substring(
idx + name.length + itemIdx.length + 2);

const aggregate =
await getData(firestore, `items/${cmdDocNameLookup[commandName]}`,
{descriptions: [], values: []});

await addDoc(firestore, `participants/${name}/events`, {
type: `${cmdDocNameLookup[commandName]}`,
name: itemName,
item_id: itemIdx,
timestamp: admin.firestore.Timestamp.now()});
await updateDoc(firestore, `participants/${name}`, {
total: admin.firestore.FieldValue.
increment(aggregate.values[parseInt(itemIdx)])});
await ctx.reply(`Done! ${itemName}`, {reply_markup: removeKeyboard});
}
};

And adding the other point management commands are simple and clear.


if (!process.env.YOURTOKEN) {
...
} else {
...

bot.command("score", async (ctx) => {
const commandName = "score";
const participants = ["playerA", "playerB"];
await handlePointsUpdate(ctx, commandName, participants);
});

bot.command("demerit", async (ctx) => {
const commandName = "demerit";
const participants = ["playerA", "playerB"];
await handlePointsUpdate(ctx, commandName, participants);
});

bot.command("redeem", async (ctx) => {
const commandName = "redeem";
const participants = ["playerA", "playerB"];
await handlePointsUpdate(ctx, commandName, participants);
});

...
}

Note there are more opportunities to reduce duplication and improve but we shall leave it here for now as we have assessed earlier that this code will become irrelevant later.

Conversation state management

The next area we need to handle is the adding or updating of individual items used for the scoring, demerit and redemptions. Until now, we are relying on Firebase console to do it.

To update an item, we need to specify the class of items that we would like to manage. Followed by the type of actions to perform. For example, adding a new item or updating an existing one.

Note, that deleting is something we would like to have too but in terms of product feature, it could be deprioritised as there is substitute for handling this case given that there would not be a lot of items.

For example, the user could avoid choosing the obselete item.

Now one of the fields we want to update for the items is the description.

This is a free form entry which cannot be satisfied by finite selections on the built-in keyboard.

Therefore, we have reached a limitation to the cheap method used to preserve state for earlier commands.

We would like to add this command and the additional state management without disrupting the existing features for now since we are working without automated tests, regression will take more time.

It is still experimental and we are learning more about what we need for effective and scalable design.

We would now need to incorporate persisted conversation state and context at a per user level by adding a field to the existing user document to persist the state.

Adding more fields to the user document.

For the first portion, we could still rely on built-in keyboard for finite selections for input.

const handleItemsUpdate = async (
ctx: Context<{
message: Update.New & Update.NonChannel & Message.TextMessage;
update_id: number;}>,
itemClasses: Array<string>): Promise<void> => {
const user = await getUser(firestore, ctx.from.id);
if (!user.valid) return;
if (!isAdmin(user)) return;

const tokens = ctx.message.text.split(" ");

if (tokens.length < 2) {
const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};
itemClasses.forEach((itemClass) => {
builtinKeyboard["keyboard"].push([`/items ${itemClass}`]);
});

functions.logger.info(ctx, {structuredData: true});
await ctx.reply("Which item to query?",
{reply_markup: builtinKeyboard});
} else if (tokens.length === 2 ) {
const itemClass = tokens[1];
const itemList =
await getData(firestore, `items/${itemClass}`,
{descriptions: [], values: []});

const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": [
[`/items ${itemClass} Add`],
[`/items ${itemClass} Update`],
[`/items ${itemClass} Cancel`],
],
};

let reply = "Item : Value\n";
const length = itemList.descriptions.length;
for (let idx = 0; idx < length; idx++) {
reply = reply.concat(
`${itemList.descriptions[idx]} : ${itemList.values[idx]}\n`);
}

await ctx.reply(reply, {reply_markup: builtinKeyboard});
} else if (tokens.length === 3 ) {
const itemClass = tokens[1];
const action = tokens[2];
if (action === "Add") {
await updateDoc(
firestore, `users/${ctx.from.id}`,
{
state: "add item",
context: `{"itemClass":"${itemClass}",`+
"\"currentField\":\"description\"}"});
await ctx.reply(
"What is the description for the new item?",
{reply_markup: removeKeyboard});
} else if (action === "Update") {
const itemList =
await getData(firestore, `items/${itemClass}`,
{descriptions: [], values: []});

const builtinKeyboard = {
"resize_keyboard": true,
"one_time_keyboard": true,
"keyboard": new Array<Array<KeyboardButton>>(),
};

const length = itemList.descriptions.length;
for (let idx = 0; idx < length; idx++) {
builtinKeyboard.keyboard.push(
[`/items ${itemClass} Update ${idx} ${itemList.descriptions[idx]}`]);
}

await ctx.reply("Which item to update?", {reply_markup: builtinKeyboard});
} else {
await ctx.reply("cancelled", {reply_markup: removeKeyboard});
}
} else {
const itemClass = tokens[1];
const action = tokens[2];
const itemIdx = tokens[3];

if (action === "Add") {
await ctx.reply("cancelled", {reply_markup: removeKeyboard});
} else if (action === "Update") {
await updateDoc(
firestore, `users/${ctx.from.id}`,
{state: "update item", context: JSON.stringify(
`{"itemClass":"${itemClass}","itemIdx":${itemIdx}}`)});
await ctx.reply("What is the new value?", {reply_markup: removeKeyboard});
} else {
await updateDoc(
firestore, `users/${ctx.from.id}`,
{state: "idle", context: "{}"});
await ctx.reply("cancelled", {reply_markup: removeKeyboard});
}
}
};

if (!process.env.YOURTOKEN) {
...
} else {
...

bot.command("items", async (ctx) => {
await handleItemsUpdate(ctx,
["score", "demerit", "redemption"]);
});

...
}

Note that this looks kind of similar to handlePointsUpdate function written earlier. It is adapted from it to reduce time to delivery.

We could attempt to refactor it to make parts re-usable but we shall not do it for now as we are also aware of the limitation of such approach.

When we need to receive free form inputs from users, we will need setup a conversation state to allow the handling to resume from the following code that process free form input.

if (!process.env.YOURTOKEN) {
...
} else {
...

bot.on("text", async (ctx) => {
const userId = ctx.from.id;
const user = await getData(
firestore,
`users/${userId}`,
{
valid: false,
});

if (!user.valid) return;

if (isAdmin(user)) {
switch (user.state) {
case "update item": {
const context = JSON.parse(user.context);
const itemList = await getData(
firestore, `items/${context.itemClass}`,
{descriptions: [], values: []});
if (itemList.descriptions.length === 0) return;
const itemDescription = itemList.descriptions[context.itemIdx];
const newValue = parseInt(ctx.message.text);
const newValuesList = itemList.values;
newValuesList[context.itemIdx] = newValue;
await updateDoc(firestore,
`items/${context.itemClass}`, {values: newValuesList});
await updateDoc(
firestore,
`users/${userId}`,
{state: "idle", context: "{}"});
await ctx.reply(
`Updated ${context.itemClass} item,`+
`"${itemDescription}" with value "${newValue}"`,
{reply_markup: removeKeyboard});
return;
}
case "add item": {
const context = JSON.parse(user.context);
if (context.currentField === "description") {
const description = ctx.message.text;
const newContext = `{"itemClass":"${context.itemClass}",`+
`"currentField":"value","description":"${description}"}`;

await updateDoc(
firestore,
`users/${userId}`,
{state: "add item", context: newContext});
await ctx.reply(
`Ok description is "${description}", please provide the value.`,
{reply_markup: removeKeyboard});
} else if (context.currentField === "value") {
const itemList = await getData(
firestore, `items/${context.itemClass}`,
{descriptions: [], values: []});
const itemValue = parseInt(ctx.message.text);
const itemDescription = context.description;

itemList.descriptions.push(itemDescription);
itemList.values.push(itemValue);
await updateDoc(firestore,
`items/${context.itemClass}`,
{
descriptions: itemList.descriptions,
values: itemList.values,
});
await updateDoc(
firestore, `users/${ctx.from.id}`,
{state: "idle", context: "{}"});
await ctx.reply(
`Added new ${context.itemClass},`+
` "${itemDescription}" of value "${itemValue}"`,
{reply_markup: removeKeyboard});
}
return;
}
default: {
return;
}
}
}
});

...
}

Note the switch case is used which will eventually grow unsustainably without refactoring with more commands are added.

The resulting behavior is illustrated below.

End to end interaction to add a new item.

Congratulations! We have a working MVP and delivered some value to our current users for now.

Photo by Drew Hays on Unsplash

This creates headroom for us to step back to refactor and improve the code for scaling.

One of these is the use of State Management libraries to help us. We will touch on these on subsequent articles.

References:

[1] https://core.telegram.org/bots/features#creating-a-new-bot

[2] https://medium.com/@kaikok/rapid-firebase-setup-656bbefb0dc5

[3] https://medium.com/@kaikok/cloud-clicker-using-firestore-1eb4a478b321

--

--