Телеграм-бот с нуля (часть 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)
		}
	}
}

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

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

В заключении

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

Поделиться
Вы можете оставить комментарий, или ссылку на Ваш сайт.
That may also sometimes referred to everyday emotional pills like viagra or relationship difficulties.

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

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