Телеграм-бот с нуля (часть 2)

Я уже писал про телеграм-бот с нуля. В этот раз я добавлю базу данных, в которой буду хранить сообщения и показывать случайным образом. На самом деле ничего сложного в этом нету, а базы данных не кусаются. Продолжим.

Материал

Прежде чем что-то делать нужно что-то иметь. Нам нужен текст. Не буду писать о нем много. Скажу только что сообщения предварительно нужно подготовить. Я сделал 2 файла CSV. Первый файл у меня содержит в первой колонке раздел, а во второй само сообщение. Второй файл будет содержать раздел и его отображаемое сообщение.

База данных

Для начала нужен пакет для работы с БД. Я возьму GORM. Библиотека может много чего взять на себя, а мы этим воспользуемся. Я буду использовать MariaDB так как она у меня уже есть и новый сервер я разворачивать не собираюсь (а зачем?). Устанавливаем:


go get gorm.io/gorm
go get gorm.io/driver/mysql
go get github.com/satori/go.uuid

Для работы с MariaDB мне понадобится пакет mysql. Так же я буду немного маньячить. Для этого и нужен пакет для работы с UUID. В принципе все готово.

Структура БД

Для начала создадим базовую структуру, которая будет будет использоваться во всех моделях (db/models/base.go):


package models

import (
	"time"
	"gorm.io/gorm"
)

type Base struct {
	ID        uint      `gorm:"type:uuid;primary_key;"`
	CreatedAt time.Time      
	UpdatedAt time.Time      
	DeletedAt gorm.DeletedAt `sql:"index"`
}

Эта структура описывает все самое необходимое для моделей. Теперь нам нужны 2е таблицы. Первая db/models/congrat_cats.go:


package models

type CongratCats struct {
	Base
	Name    string `gorm:"column:name;type:varchar(50);"`
	Display string `gorm:"column:name;type:text"`
}

Тут я перечисляю категории поздравлений. Вторая db/models/congrat_text.go:


package models

type CongratText struct {
	Base
	CongratCatsID uint `gorm:"column:id_congrat_cat"`
	CongratCats   CongratCats
	txt           string `gorm:"column:txt;type:text"`
}


Подготовка БД

Прежде чем работать с БД необходимо немного можифицировать код из предыдущей статьи. В файле structs/config.go добавим некоторые свойства структуры:


type Config struct {
	TBotApiToken string
	Env          string

	MigrateDB           bool
	MigrateDBReferences bool
	DbName              string
	DbAddress           string
	DbPort              string
	DbLogin             string
	DbPassword          string
}

И теперь нужно сделать получить все необходимые параметры. Для этого подправим libs/config.go:


package libs

import (
	"os"
	"tgbot-newyear/structs"
)

var Config structs.Config

func LoadConfig() {
	var exists bool
	Config.TBotApiToken = os.Getenv("TBOT_API_TOKEN")

	if Config.Env, exists = os.LookupEnv("ENV"); !exists {
		Config.Env = "development"
	}

	if Config.DbName, exists = os.LookupEnv("DB_NAME"); !exists {
		Config.DbName = "bot"
	}

	if Config.DbAddress, exists = os.LookupEnv("DB_ADDRESS"); !exists {
		Config.DbAddress = "bot"
	}

	if Config.DbLogin, exists = os.LookupEnv("DB_LOGIN"); !exists {
		Config.DbLogin = "bot"
	}

	if Config.DbPassword, exists = os.LookupEnv("DB_PASSWORD"); !exists {
		Config.DbPassword = "bot"
	}

	if Config.DbPort, exists = os.LookupEnv("DB_PORT"); !exists {
		Config.DbPort = "3306"
	}

	prepareArguments()
}

func prepareArguments() {
	args := os.Args[1:]
	for i := range args {
		switch args[i] {
		case "migrate":
			Config.MigrateDB = true
		}
	}
}


Так же добавим вспомогательную функцию в файл libs/end_transaction.go:


package libs

import "gorm.io/gorm"

func EndTransaction(tx *gorm.DB, err error) (errout error) {
	if err != nil {
		// Откат транзакции, так как получилось неудачно
		errout = tx.Rollback().Error
	} else {
		// Ошабок нет, значит все хорошо. Подтверждаем транзакцию
		tx.Commit()
		errout = nil
	}
	return
}


И добавим в launch.json переменные окружения:


{
  "name": "Launch Bot",
  "type": "go",
  "request": "launch",
  "mode": "debug",
  "program": "${workspaceFolder}/main.go",
  "env": {
    "TBOT_API_TOKEN": "Ваш токен",
    "DB_ADDRESS": "localhost",
    "DB_NAME": "bot",
    "DB_LOGIN": "bot",
    "DB_PASSWORD": "bot"
  }
}

Далее нам нужно написать немного кода для подключения в базе. Наполняем файл db/db.go:


package db

import (
	"embed"
	"log"
	"os"
	"time"

	"tgbot-newyear/db/models"
	"tgbot-newyear/libs"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

//go:embed cats.csv.gz
//go:embed congrats.csv.gz
var f embed.FS

var DB *gorm.DB

func Connect() {
	dsnMySQL := libs.Config.DbLogin + ":" + libs.Config.DbPassword + "@tcp(" + libs.Config.DbAddress + ":" + libs.Config.DbPort + ")/" + libs.Config.DbName + "?charset=utf8mb4&parseTime=True&loc=Local"

	var logLevel logger.LogLevel
	if libs.Config.Env == "production" {
		logLevel = logger.Silent
	} else {
		logLevel = logger.Info
	}

	var err error
	newLogger := logger.New(
		log.New(os.Stdout, "\r\n", log.LstdFlags), // io writer
		logger.Config{
			SlowThreshold: time.Second, // Slow SQL threshold
			LogLevel:      logLevel,    // Log level
			Colorful:      false,       // Disable color
		},
	)
	newLogger.LogMode(logger.Info)
	DB, err = gorm.Open(mysql.Open(dsnMySQL), &gorm.Config{
		Logger:                                   newLogger,
		DisableForeignKeyConstraintWhenMigrating: true,
	})

	if err != nil {
		panic(err)
	}

	// Установим пул соединений
	dbSettings, err := DB.DB()
	if err != nil {
		panic(err)
	}
	dbSettings.SetMaxIdleConns(10)
	dbSettings.SetMaxOpenConns(50)
	dbSettings.SetConnMaxLifetime(time.Minute * 10)

	if libs.Config.MigrateDB {

		// Создание БД

		DB.AutoMigrate(
			&models.CongratCats{},
			&models.CongratText{},
		)

		if libs.Config.MigrateDBReferences {
			migrateCats()
			migrateCongrats()
		}
	}
}

func migrateCats() {
	// TODO
}

func migrateCongrats() {
	// TODO
}

Здесь я настраиваю подключение к базе. Если будет передан параметр migrate, то будет создана структура базы либо ее обновление (если она уже есть). В промышленной среде это называется процесс «миграции БД». Так как у нас всего 2 таблицы, то не смысла заморачиваться с чем-то сложным. По этому в данном случае я использую простой вариант.

Теперь, чтобы все работало правильно нужно немного изменить main.go:


func main() {
	libs.LoadConfig()
	db.Connect()
	if !libs.Config.MigrateDB {
		libs.LoadConfig()
		srv.TGBot()
	}
}

Обновление справочников

Помимо создания структуры БД еще необходимо наполнить таблицы данными, из которых будем брать данные. В файле db/db.go я подготовил 2е функции: migrateCats и migrateCongrats. Вот их и будем использовать. И так, для начала migrateCats:


func migrateCats() {
	var catsFile fs.File
	var err error

	catsFile, err = f.Open("cats.csv.gz")
	if err != nil {
		panic(err)
	}

	defer catsFile.Close()

	var gz *gzip.Reader
	gz, err = gzip.NewReader(catsFile)
	if err != nil {
		panic(err)
	}

	defer gz.Close()

	reader := csv.NewReader(gz)
	reader.Comma = ';'

	var record []string
	z := 0
	fmt.Println("Update categories")

	tx := DB.Begin()

	defer func() {
		libs.EndTransaction(tx, err)
	}()

	for {
		record, err = reader.Read()
		if err != nil {
			break
		}

		var cat models.CongratCats
		if res := tx.First(&cat, "name = ?", record[0]); res.RowsAffected == 0 {
			// Создаем
			cat.Name = record[0]
			cat.Display = record[1]
			if res := tx.Create(&cat); res.RowsAffected == 0 {
				panic(res.Error)
			}
		} else {
			// Обновляем
			cat.Display = record[1]
			if res := tx.Save(&cat); res.RowsAffected == 0 {
				panic(res.Error.Error())
			}
		}
	}

	fmt.Printf("Finished %d records\n", z)

	err = nil

}

И migrateCongrats:


func migrateCongrats() {
	var catsFile fs.File
	var err error

	catsFile, err = f.Open("congrats.csv.gz")
	if err != nil {
		panic(err)
	}

	defer catsFile.Close()

	var gz *gzip.Reader
	gz, err = gzip.NewReader(catsFile)
	if err != nil {
		panic(err)
	}

	defer gz.Close()

	reader := csv.NewReader(gz)
	reader.Comma = ';'

	var record []string
	fmt.Println("Update texts")

	tx := DB.Begin()

	defer func() {
		libs.EndTransaction(tx, err)
	}()

	// Очистим все поздравления. Нам они не нужны
	tx.Delete(&models.CongratText{}, "1 = 1")

	for {
		record, err = reader.Read()
		if err != nil {
			break
		}

		var cat models.CongratCats
		if res := tx.First(&cat, "name = ?", record[0]); res.RowsAffected == 0 {
			continue
		}

		// Добавим поздравление

		var text models.CongratText
		text.CongratCatsID = cat.ID
		text.Txt = record[1]

		if res := tx.Create(&text); res.RowsAffected == 0 {
			panic(res.Error)
		}

	}

	err = nil

}

Я не стал вводить проверку существующего сообщения в таблице, так как записей не много и выполняется быстро. В академических целях этого достаточно. В реальном же приложении нужно предусмотреть именно добавление и обновление записей, так как это может быть справочная информация и при удалении и новой вставке связи могут быть смещены и потеряны. Так что подбирайте методы в соответствии с требованиями.

Поздравления

Вот теперь мы подошли к реализации результата. В файле srv/handlers/index.go пишем следующее:


package handlers

import (
	"fmt"
	"math/rand"
	"tgbot-newyear/db"
	"tgbot-newyear/db/models"
	"tgbot-newyear/libs"
	"tgbot-newyear/tglibs"

	tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api"
)

func Handlers(event *tgbotapi.Update) (msg *tgbotapi.MessageConfig, err error) {

	if event.CallbackQuery != nil {

		tx := db.DB.Begin()
		defer func() {
			libs.EndTransaction(tx, err)
		}()

		var cats models.CongratCats
		if res := tx.Preload("CongratText").First(&cats, "name = ?", event.CallbackQuery.Data); res.RowsAffected == 0 {
			tmp := tgbotapi.NewMessage(tglibs.GetChatID(event), fmt.Sprintf("Что вы имели ввиду?"))
			msg = &tmp
			err = res.Error
			return
		}

		if len(cats.CongratText) == 0 {
			tmp := tgbotapi.NewMessage(tglibs.GetChatID(event), fmt.Sprintf("Извините, но я не знаю что Вам сказать..."))
			msg = &tmp
			return
		}

		num := rand.Int31n(int32(len(cats.CongratText)))

		tmp := tgbotapi.NewMessage(tglibs.GetChatID(event), cats.CongratText[num].Txt)
		tglibs.TGSendMessage(tmp)
	}

	return

}

И немного изменим srv/tgbot.go функцию processingMessage:


func processingMessage(update *tgbotapi.Update) {

	var err error
	var message *tgbotapi.MessageConfig

	chatID := tglibs.GetChatID(update)

	message, err = handlers.Handlers(update)

	if err != nil {
		tmp := tgbotapi.NewMessage(chatID, err.Error())
		tglibs.TGSendMessage(tmp)
	} else if message != nil {
		tglibs.TGSendMessage(*message)
	} else {

		tx := db.DB.Begin()
		defer func() {
			libs.EndTransaction(tx, err)
		}()

		var cats []models.CongratCats
		if res := tx.Find(&cats); res.RowsAffected == 0 {
			tmp := tgbotapi.NewMessage(chatID, "Ой! Что-то случилось...")
			tglibs.TGSendMessage(tmp)
		} else {

			tmp := tgbotapi.NewMessage(chatID, "Выберите действие")

			var buttons [][]tgbotapi.InlineKeyboardButton
			for i := range cats {
				b := tgbotapi.NewInlineKeyboardButtonData(cats[i].Display, cats[i].Name)
				row := tgbotapi.NewInlineKeyboardRow(b)
				buttons = append(buttons, row)
				if len(buttons) == 100 {
					var kb tgbotapi.InlineKeyboardMarkup
					kb.InlineKeyboard = buttons
					message.ReplyMarkup = kb
					tglibs.TGSendMessage(tmp)
					tmp = tgbotapi.NewMessage(chatID, "Продолжение")
					buttons = [][]tgbotapi.InlineKeyboardButton{}
				}
			}

			var kb tgbotapi.InlineKeyboardMarkup
			kb.InlineKeyboard = buttons

			tmp.ReplyMarkup = kb
			tglibs.TGSendMessage(tmp)
		}
	}
}

Теперь можно запускать.

Результат работы бота

В заключении

Бот готов и им можно пользоваться. Мой результат получился вот таким.

Поделиться
Вы можете оставить комментарий, или ссылку на Ваш сайт.

Оставить комментарий

Вы должны быть авторизованы, чтобы разместить комментарий.