Skip to content

HabitPulse: Making the handlers. Part 3

Posted on:June 17, 2024 at 12:00 AM

HabitPulse. Making the handlers. Part 3

Introduction

In our last article, we focused on setting up the development environment necessary for high-quality development. Now, we pivot towards defining the MVP (Minimum Viable Product) scope and constructing the main workflow for our Telegram bot.

Defining the MVP Scope

An MVP is essential to get a functional product that can be iteratively improved. The core of our MVP for the Telegram bot will include:

Project Structure

While a single file could suffice for the MVP, we opt for a modular approach for the following reasons:

  1. Scalability: Separate packages allow easy scaling of the bot’s functionalities.
  2. Maintainability: It simplifies maintenance and debugging.
  3. Flexibility: This structure facilitates future improvements and additions without overhauling the codebase.

The project will be structured into packages for:

Implementation Strategy

Implementing handlers

So in order it to work we want to use handlers package which starts like that:

package handlers

import (
	"fmt"
	"log"
	"strconv"
	"strings"
	"time"

	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
	"github.com/painhardcore/habitpulse/database"
)

type BotHandlers struct {
	bot *tgbotapi.BotAPI
	db  database.DatabaseClient
	UserSession
}

type UserSession struct {
	State    string
	Activity string
}

// TODO: make it thread-safe
var userSessions = make(map[int64]*UserSession)

func NewBotHandlers(bot *tgbotapi.BotAPI, db database.DatabaseClient) *BotHandlers {
	return &BotHandlers{bot: bot, db: db}
}

func (bh *BotHandlers) StartBot() error {
	u := tgbotapi.NewUpdate(0)
	u.Timeout = 60

	updates := bh.bot.GetUpdatesChan(u)

	for update := range updates {
		if update.CallbackQuery != nil {
			bh.handleCallback(update.CallbackQuery)
		} else if update.Message != nil {
			if update.Message.IsCommand() {
				switch update.Message.Command() {
				case "start":
					bh.handleStart(update.Message)
				case "add":
					bh.handleAdd(update.Message)
				case "stats":
					bh.handleStats(update.Message)
				case "list":
					bh.handleList(update.Message)
				default:
					bh.handleUnknown(update.Message)
				}
			} else {
				bh.handleInput(update.Message)
			}
		}
	}

	return nil
}

Since I’m the only one use - I don’t need something safe for session and I deal with it later. Other than that - everything straightword. We bind each command with method.

Lets focus on /add method. We gonna need to add some number to activity in order to track it. And when we want to add we should list the activities with buttons

func (bh *BotHandlers) getActivityButtons(useID int64) []tgbotapi.InlineKeyboardButton {
	// Display a list of activities or a button to add a new activity
	activities, err := bh.db.ListActivities(useID)
	if err != nil {
		log.Println("Error retrieving activities:", err)
		return nil
	}

	// I don't beleive there would a lot of activities, so I'm not going to paginate them
	btns := make([]tgbotapi.InlineKeyboardButton, 0, len(activities))
	for _, activity := range activities {
		button := tgbotapi.NewInlineKeyboardButtonData(activity, "select:"+activity)
		btns = append(btns, button)
	}
	return btns

}
func (bh *BotHandlers) handleAdd(message *tgbotapi.Message) {
	userID := message.From.ID
	rows := [][]tgbotapi.InlineKeyboardButton{}
	btns := bh.getActivityButtons(userID)
	if btns != nil {
		rows = append(rows, btns)
	}
	addNewButton := tgbotapi.NewInlineKeyboardButtonData("New Activity", "add_new")
	rows = append(rows, []tgbotapi.InlineKeyboardButton{addNewButton})

	keyboard := tgbotapi.NewInlineKeyboardMarkup(rows...)
	msg := tgbotapi.NewMessage(userID, "Select an activity or add a new one:")
	msg.ReplyMarkup = keyboard
	bh.bot.Send(msg)
}

And here we’re creating the buttons with the activities and adding a separate button for a new one.

Next, when we want to work with the buttons we should handle the callbacks. It’s separate function and we use Sessions, since we want to carry some context for this interaction.

func (bh *BotHandlers) handleCallback(callback *tgbotapi.CallbackQuery) {
	userID := callback.From.ID
	data := strings.Split(callback.Data, ":")
	if len(data) < 1 {
		// Send error message
		return
	}

	session, exists := userSessions[userID]
	if !exists {
		session = &UserSession{}
		userSessions[userID] = session
	}

	switch data[0] {
	case "select":
		if len(data) == 2 {
			session.Activity = data[1]
			session.State = "awaiting_count"
			msg := tgbotapi.NewMessage(userID, "Enter the number for "+session.Activity+":")
			bh.bot.Send(msg)
		}
	case "add_new":
		session.State = "awaiting_activity"
		msg := tgbotapi.NewMessage(userID, "Enter the name of the new activity:")
		bh.bot.Send(msg)
	}
}

So after used choosed what he want - we just asking for action and now waiting to handle the input:

func (bh *BotHandlers) handleInput(message *tgbotapi.Message) {
	userID := message.From.ID
	session, exists := userSessions[userID]
	if !exists {
		msg := tgbotapi.NewMessage(message.From.ID, "I don't understand you.")
		bh.bot.Send(msg)
		return
	}

	switch session.State {
	case "awaiting_activity":
		session.Activity = message.Text
		session.State = "awaiting_count"
		msg := tgbotapi.NewMessage(userID, "Enter the count for "+session.Activity+":")
		bh.bot.Send(msg)

	case "awaiting_count":
		count, err := strconv.Atoi(message.Text)
		if err != nil {
			msg := tgbotapi.NewMessage(userID, "Please enter a valid number.")
			bh.bot.Send(msg)
			return
		}

		err = bh.db.AddActivity(userID, session.Activity, count)
		if err != nil {
			log.Println("Error adding activity:", err)
			return
		}
		delete(userSessions, userID)
		dayCount, err := bh.db.GetCountForToday(userID, session.Activity)
		if err != nil {
			log.Println("Error getting days activity:", err)
		}
		stat7d := bh.getStatMessageActivity(userID, session.Activity, 7)
		msg := tgbotapi.NewMessage(userID, fmt.Sprintf("%d %s added. Today: %d.\n%s\nMore ?", count, session.Activity, dayCount, stat7d))
		btns := bh.getActivityButtons(userID)
		if btns != nil {
			msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(btns)
		}
		bh.bot.Send(msg)

		session.State = ""
		session.Activity = ""
	}
}