What's new
Runion

This is a sample guest message. Register a free account today to become a member! Once signed in, you'll be able to participate on this site by adding your own topics and posts, as well as connect with other members through your own private inbox!

Мануал по написанию Telegram-ботов на Golang

miserylord

Light Weight
Депозит
$0
Автор: miserylord
Эксклюзивно для форума:
Проектирование

Компоненты


В первую очередь разработка Telegram-бота начинается с определения его составных компонентов на уровне высоких абстракций.

По сути, Telegram-бот — это лишь посредник, прокси между сервером и клиентом. Следовательно, нам нужен клиент и сервер. Клиентом выступает сам Telegram (а также реализованный интерфейс бота в его экосистеме).

Итак, у нас есть:

  • Telegram-бот — это сущность, которая получает запросы от клиента (пользователя Telegram) и обрабатывает их либо через промежуточное API, либо напрямую. Я решил, что промежуточное API здесь не нужно. В моем проекте бот будет напрямую обращаться к базе данных, и его методы будут независимы от администраторского API.
  • База данных — она используется как ботом через его методы, так и администратором через панель управления.
  • API администратора — через него администратор взаимодействует с базой данных и управляет функциями бота.
  • Панель управления — это интерфейс для взаимодействия администратора с системой.

Также нужно будет настроить сервер, на котором будет запущен бот.

Технологии

Теперь перейдем на уровень ниже и определим технологии, которые будут представлять наши сущности.

Telegram-бот может использовать готовую библиотеку, список которых доступен в официальной документации — Bot API Library Examples. Какую библиотеку следует использовать? Никакую. Я буду использовать нативный API — ядро Telegram. Библиотеки — это более высокоуровневые абстракции над нативным механизмом, Telegram API. С библиотеками есть несколько проблем: во-первых, их слишком много — около десяти рекомендованных. Можно выбирать по количеству звёзд на GitHub, но что, если самое гениальное решение получило наименьшее внимание? Следовательно, необходимо сравнить как минимум несколько, а лучше все. Сколько времени на это уйдёт? Если поверхностно — несколько часов, но что, если мы вообще не понимаем, как устроены Telegram-боты? В таком случае можно сравнивать их неделями. К тому же ни у одной библиотеки нет нормальной документации, которая бы упрощала работу. Документация Telegram API, хотя и недостаточно снабжена примерами, остаётся наиболее понятной из всех. Да, кода будет больше, но в процессе его написания мы сможем лучше понять, как работают механизмы. Какой смысл инкапсулировать что-то, не зная, как это работает? Впрочем, возможно, я чего-то не учёл.

База данных. Как выбрать между реляционной и нереляционной базой данных? Первые — это классические SQL, такие как MySQL и PostgreSQL. Вторые — так называемые NoSQL, например, MongoDB. Нереляционные базы данных предлагают главный плюс — гибкость. Реляционные базы данных требуют заранее прописанную структуру полей и отношений, что делает их менее интуитивными для пользователей, поскольку людям не свойственно мыслить связными таблицами. Казалось бы, очевидно, что гибкость в разработке логики базы данных, где можно добавлять поля по ходу работы, является преимуществом. Однако если не продумать архитектуру изначально, переписать логику впоследствии может потребоваться непредсказуемо много времени. SQL-базы данных масштабируются вертикально, а NoSQL — горизонтально. Иными словами, в SQL базах данных создаются больше таблиц, а в NoSQL — расширяются существующие записи. Также в NoSQL базах данных поддержка ACID либо отсутствует, либо реализована менее эффективно. ACID (атомарность, согласованность, изолированность, долговечность) обеспечивает выполнение транзакций, при которых либо выполняются все операции, либо не выполняется ни одна, и система возвращается к исходному состоянию. В NoSQL реализуется подход BASE, который делает упор на доступность в ущерб согласованности. Подробнее можно прочитать здесь: Базы данных ACID и BASE — Разница между базами данных — AWS. Какую же базу данных выбрать для Telegram-бота? На самом деле любую. Любая популярная СУБД справится с задачей, например, для магазина аккаунтов Roblox. Будем использовать MongoDB.

Далее необходимо определиться с фреймворком для админского API. Язык разработки — Golang, выбор фреймворка — Gin. На самом деле, все фреймворки Golang для бэкенда одинаково просты. Такого, как PHP Laravel или JavaScript Nest, насколько я знаю, на данный момент нет, поэтому выбор пал на Gin. Это хороший фреймворк.

Осталось лишь определиться с фреймворком для клиентской части панели управления. На самом деле, можно выбрать любой популярный JavaScript фреймворк. Я воспользуюсь самым популярным — React. В качестве сборщика будет использоваться Vite.

В качестве сборщика всего проекта будет выступать Docker.

Логика

Далее необходимо определить логику проекта.

Она будет следующей: пользователь запускает бота и выбирает категорию товара. Возможно, в будущем мы расширим ассортимент, но пока что это будет только одна категория — Roblox. Далее пользователь выбирает подкатегорию, представляющую особенность аккаунта. После выбора подкатегории пользователь увидит цену товара и кнопки «Купить» и «Назад». Затем происходит выбор способа оплаты, процесс оплаты, проверка оплаты и выдача товара.

Остались неотвеченные вопросы: детали работы Telegram-бота, вид клавиатуры, приём оплаты. На них можно ответить сразу, можно расписать модели базы данных и логику до уровня методов. Однако можно приступать к разработке, решая проблемы по мере их появления. Приступаем к разработке.

Разработка

Запуск Telegram-бота

Существует два способа работы Telegram-бота в контексте получения сообщений — long polling и webhooks. По сути, они различаются методом приёма сообщений. Помните, что Telegram-бот выступает как прокси, которое хранит сообщения. Мы можем либо самостоятельно запрашивать новые сообщения с помощью long polling, либо Telegram-серверы будут отправлять их нам автоматически через webhooks. Преимущество первого способа — его простота имплементации по сравнению с webhooks, однако webhooks могут сэкономить ресурсы при масштабировании.

Мне нравится это сравнение (не обращайте внимания на то, что речь идёт о библиотеке на Python — это абсолютно не меняет сути). Оно поможет немного лучше понять разницу между этими двумя способами: project-nashenas-telegram-bot/Long Polling vs. Webhook.md at main · pytopia/project-nashenas-telegram-bot · GitHub.

Я выберу реализацию через long polling. Думаю, будет хорошим решением сначала реализовать всё через long polling, а потом, при необходимости, перейти на webhooks.

Для запуска Telegram-бота нам необходимо связаться с Telegram: Contact @BotFather, ввести команду /newbot, выбрать имя бота, затем указать юзернейм, который должен заканчиваться на _bot, и получить токен. Этот токен является ключом к вашему боту, и если кто-то получит доступ к нему, то сможет перехватить контроль над ботом.

Инициализируем проект бота. Создаем файл .env, записывая в переменную TOKEN полученный токен. Создаем папку config с файлом init.go.


C-подобный: Скопировать в буфер обмена
Code:
package config

import (
 "log"
 "os"

 "github.com/joho/godotenv"
)

func GetTelegramBotToken() string {
 errLoad := godotenv.Load()
 if errLoad != nil {
  log.Fatal("Error loading .env file")
 }

 token := os.Getenv("TOKEN")

 return token
}


Подключаем библиотеку github.com/joho/godotenv, которая будет использоваться для работы с переменными окружения из файла .env. Функция GetTelegramBotToken загружает файл .env, читает из него переменную окружения TOKEN, которая должна содержать токен Telegram-бота, и возвращает его. Если файл .env не будет загружен, программа завершит работу с ошибкой.

В папке telegram создаем одноименные файл и пакет. Объявляем глобальную переменную (в рамках пакета) botToken, а также функцию, которая присваивает значение глобальной переменной botToken.

C-подобный: Скопировать в буфер обмена
Code:
package telegram

var botToken string

func InitBotToken(token string) {
 botToken = token
}

В файле main.go в функции main проверяем, содержит ли файл .env токен для подключения, а также инициализируем переменную botToken для дальнейшего использования.


C-подобный: Скопировать в буфер обмена
Code:
package main

import (
 "log"
 "rtgbot/config"
 "rtgbot/telegram"
)

func main() {
 if config.GetTelegramBotToken() == "" {
  log.Fatal("Environment variables TELEGRAM_BOT_TOKEN not set")
 }

 telegram.InitBotToken(config.GetTelegramBotToken())
}


На данный момент оставим код Telegram и начнем реализовывать код для API, в ходе которого добавим категории товаров.

Подключаем базу данных

Начнем интеграцию базы данных в приложение. Создаем и инициализируем проект в соседней директории, который будет API для работы с ботом через панель управления. Это отдельный проект, находящийся в отдельной директории от той, где реализуется сам бот.

На более позднем этапе можно будет вынести базу данных MongoDB в отдельный Docker-контейнер, но на данном этапе воспользуемся облачным сервисом MongoDB Atlas. Зарегистрируем аккаунт и создадим бесплатный кластер. Важно задать имя пользователя и пароль, а также открыть доступ к базе данных для тех IP-адресов, с которыми вы планируете работать. Если у вас динамический IP-адрес, можно открыть доступ для всех IP-адресов (0.0.0.0/0). В результате нужно получить ссылку формата: mongodb+srv://user:pass@cluster1337.1337.mongodb.net/?retryWrites=true&w=majority&appName=Cluster1337. Эту строку мы добавляем в файл .env. Также в .env добавляем две переменные: INITADMINUSER и INITADMINPASSWORD, например, со значениями admin/admin.

Мы защитим маршруты от несанкционированного использования, и это первый шаг к дальнейшей реализации этой логики.

Создаем папку config с файлом mongo.go. Устанавливаем несколько библиотек: уже знакомую нам github.com/joho/godotenv, а также go.mongodb.org/mongo-driver/mongo, go.mongodb.org/mongo-driver/bson и golang.org/x/crypto/bcrypt.
C-подобный: Скопировать в буфер обмена
Code:
//1
var Client *mongo.Client

//2
func InitMongoDB(uri string) error {
 // 3
 serverAPI := options.ServerAPI(options.ServerAPIVersion1)
 opts := options.Client().ApplyURI(uri).SetServerAPIOptions(serverAPI)

 // 4
 client, err := mongo.Connect(context.TODO(), opts)
 if err != nil {
  return err
 }

 // 5
 if err := client.Database("MasterDB").RunCommand(context.TODO(), bson.D{{"ping", 1}}).Err(); err != nil {
  return err
 }
 fmt.Println("Pinged your deployment. You successfully connected to MongoDB!")
 Client = client

 // 6
 databases, err := client.ListDatabaseNames(context.TODO(), bson.M{})
 if err != nil {
  return err
 }

 // 7
 databaseExists := false
 for _, dbName := range databases {
  if dbName == "MasterDB" {
   databaseExists = true
   break
  }
 }

 // 8
 if !databaseExists {
  err = client.Database("MasterDB").CreateCollection(context.TODO(), "initCollection")
  if err != nil {
   return err
  }
  fmt.Println("Database 'MasterDB' created successfully.")

  err = client.Database("MasterDB").Collection("initCollection").Drop(context.TODO())
  if err != nil {
   return err
  }
  fmt.Println("initCollection deleted successfully.")
 } else {
  fmt.Println("Database 'MasterDB' already exists.")
 }

 // 9
 adminCollection := client.Database("MasterDB").Collection("admin")
 count, err := adminCollection.CountDocuments(context.TODO(), bson.M{})
 if err != nil {
  return err
 }

 // 10
 if count == 0 {
  errLoad := godotenv.Load()
  if errLoad != nil {
   log.Fatal("Error loading .env file")
  }

  username := os.Getenv("INITADMINUSER")
  password := os.Getenv("INITADMINPASSWORD")

  hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
  if err != nil {
   return err
  }

  adminDoc := bson.D{
   {Key: "name", Value: string(username)},
   {Key: "password", Value: string(hashedPassword)},
   {Key: "isAdmin", Value: true},
  }
  _, err = adminCollection.InsertOne(context.TODO(), adminDoc)
  if err != nil {
   return err
  }
  fmt.Println("Admin collection initialized with initial data.")
 } else {
  fmt.Println("Admin collection already initialized.")
 }

 return nil
}


  1. Объявляем глобальную переменную Client, которая будет хранить подключение к базе данных MongoDB. Тип этой переменной — указатель на mongo.Client.
  2. Функция InitMongoDB предназначена для инициализации MongoDB. На вход она принимает строку uri, содержащую URI для подключения к MongoDB, и возвращает ошибку, если процесс подключения завершится неудачей.
  3. Создается конфигурация для API-сервера, которая указывает использовать версию API 1. Затем задаются настройки клиента MongoDB, включая URI для подключения и созданную конфигурацию API.
  4. Вызов функции mongo.Connect создает подключение к MongoDB с указанными параметрами opts.
  5. Проверяется, установлено ли успешное подключение к базе данных с помощью команды ping. Если ping успешен, подключение завершено, и в консоль выводится сообщение о том, что подключение выполнено успешно. В глобальную переменную Client сохраняется ссылка на созданное подключение.
  6. Вызывается метод ListDatabaseNames для получения списка всех баз данных, хранящихся на сервере MongoDB.
  7. Цикл перебирает имена всех баз данных, и если среди них есть база с именем "MasterDB", переменная databaseExists устанавливается в true.
  8. Если базы данных "MasterDB" не существует, она создается с коллекцией "initCollection", после чего созданная коллекция удаляется. Это нужно, чтобы убедиться, что база данных была успешно создана.
  9. Проверяется наличие документов в коллекции admin в базе данных "MasterDB".
  10. Если коллекция admin пуста, загружаются переменные окружения из файла .env для получения данных администратора (имя пользователя и пароль). Пароль хешируется с помощью библиотеки bcrypt, после чего создается документ с учетными данными администратора и флагом isAdmin. Этот документ вставляется в коллекцию admin. Если коллекция уже инициализирована, выводится сообщение о том, что она уже существует.

Итого, мы инициализируем базу данных сразу с административными доступами.

В файле main.go на данный момент мы получаем переменную MONGO и инициализируем подключение.

C-подобный: Скопировать в буфер обмена
Code:
func main() {
 errLoad := godotenv.Load()
 if errLoad != nil {
  log.Fatal("Error loading .env file")
 }

 mongoURI := os.Getenv("MONGO")
 if mongoURI == "" {
  log.Fatal("MONGO environment variable not set")
 }

 errInit := config.InitMongoDB(mongoURI)
 if errInit != nil {
  log.Fatalf("Failed to initialize MongoDB: %v", errInit)
 }
}

Добавляем JWT-токен

Для защиты маршрутов (чтобы каждый встречный не смог добавить в бота любые товары) подключим JWT-токен. JWT-токен — это механизм, при котором пользователю выдается своего рода "карточка" с номером, который может расшифровать каждый (мы можем увидеть его содержимое). Представим, что мы выдали пользователю строку "белиберда", и с помощью простых действий каждый может понять, что это токен с правами просмотра информации, но без административного доступа. Более того, пользователь может попытаться изменить содержимое токена, например, сменив роль на административную. Однако в таком случае сервер при проверке увидит подделку. Валидация подделки осуществляется с помощью секретного ключа. На сайте jwt.io можно посмотреть, как это работает.

В файл .env добавим переменную JWTSECRET со значением м0йс3кр3тн3п0вт0р1м (да, в JWT-токены можно добавлять не только латинские символы).

Установим библиотеку github.com/gin-gonic/gin, которая, как мы ранее определили, будет выступать в качестве сервера. Для работы с JWT-токенами подключим github.com/golang-jwt/jwt.

В папке middleware создадим файл auth_middleware.go. Middleware — это функция-перехватчик, которая проксирует выполнение основной функции. В данном случае она будет навешана на маршруты, связанные с добавлением товаров, и будет проверять валидность токена.
C-подобный: Скопировать в буфер обмена
Code:
func AuthMiddleware() gin.HandlerFunc {
 return func(c *gin.Context) {
  tokenString := c.GetHeader("Authorization")
  if tokenString == "" {
   c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization token required"})
   c.Abort()
   return
  }

  token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
   errLoad := godotenv.Load()
   if errLoad != nil {
    log.Fatal("Error loading .env file")
   }
   secretKey := os.Getenv("JWTSECRET")
   if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
    return nil, jwt.NewValidationError("invalid signing method", jwt.ValidationErrorSignatureInvalid)
   }
   return []byte(secretKey), nil
  })
  if err != nil || !token.Valid {
   c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
   c.Abort()
   return
  }
  c.Next()

 }
}

Функция проверяет наличие токена в заголовке запроса. Она разбирает токен с помощью библиотеки JWT и выполняет валидацию подписи токена с использованием секретного ключа из файла окружения. Если токен отсутствует, недействителен или имеет неправильный метод подписи, запрос блокируется и возвращается ошибка. Если токен корректен, запрос продолжает обрабатываться. Таким образом, это middleware гарантирует, что к защищённым маршрутам смогут получить доступ только пользователи с валидными JWT-токенами.

Маршрут для авторизации

Добавим маршрут для авторизации (получения JWT-токена), а также для смены пароля.

Мы будем использовать модель MVC. Начнем с определения моделей. Создадим папку models с файлом user.go.
C-подобный: Скопировать в буфер обмена
Code:
package models

type User struct {
 Username string `bson:"name" json:"name"`
 Password string `bson:"password" json:"password"`
}

type ChangePassword struct {
 Username string `bson:"name" json:"name"`
 OldPassword string `bson:"password" json:"password"`
 NewPassword string `bson:"new_password" json:"new_password"`
}

Теги bson и json: Теги используются для управления тем, как Go-структуры сериализуются (преобразуются) и десериализуются при работе с MongoDB (через библиотеку BSON) и форматами JSON. Тег bson:"name" указывает, что поле будет храниться в базе данных MongoDB под указанным именем. Например, поле Username будет сохранено как "name". Тег json:"name" указывает, как это поле будет называться при обмене данными в формате JSON, например, при отправке данных в HTTP-ответах или запросах.

  • User: модель для представления пользователя с полями для имени и пароля, используемая как при работе с MongoDB, так и при передаче данных через API в формате JSON.
  • ChangePassword: модель для обработки запроса на смену пароля, содержащая имя пользователя, старый и новый пароли.

Создадим папку services с пакетом services и файлом auth_service.go. Этот сервис будет отвечать за взаимодействие с базой данных.
C-подобный: Скопировать в буфер обмена
Code:
// 1
type AuthService struct {
 db *mongo.Database
}

// 2
func NewAuthService(db *mongo.Database) *AuthService {
 return &AuthService{db: db}
}

// 3
func (s *AuthService) AuthenticateUser(ctx context.Context, name string, password string) (string, error) {
 user := &models.User{}
 err := s.db.Collection("admin").FindOne(ctx, bson.M{"name": name}).Decode(user)
 if err != nil {
  if err == mongo.ErrNoDocuments {
   return "", errors.New("invalid username")
  }
 return "", err 
 }

 err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
 if err != nil {
  return "", errors.New("invalid password")
 }

 token, err := generateJWT(name)
 if err != nil {
  return "", err
 }

 return token, nil
}

// 4
func (s *AuthService) ChangePassword(ctx context.Context, name string, password string, newPassword string) (string, error) {
 user := &models.User{}
 err := s.db.Collection("admin").FindOne(ctx, bson.M{"name": name}).Decode(user)
 if err != nil {
  if err == mongo.ErrNoDocuments {
   return "", errors.New("invalid username")
  }
  return "", err
 }

 err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
 if err != nil {
  return "", errors.New("invalid current password")
 }

 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
 if err != nil {
  return "", err
 }

 _, err = s.db.Collection("admin").UpdateOne(ctx, bson.M{"name": name}, bson.M{"$set": bson.M{"password": string(hashedPassword)}})
 if err != nil {
  return "", err
 }

 return "password updated successfully", nil
}

// 5
func generateJWT(username string) (string, error) {
 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
  "username": username,
  "exp": time.Now().Add(time.Hour * 24 * 30).Unix(),
 })
 errLoad := godotenv.Load()
 if errLoad != nil {
  log.Fatal("Error loading .env file")
 }


 secretKey := os.Getenv("JWTSECRET")
 tokenString, err := token.SignedString([]byte(secretKey))
 if err != nil {
  return "", err
 }
 return tokenString, nil
}

  1. AuthService — это структура, представляющая сервис аутентификации. Внутри она содержит ссылку на базу данных MongoDB через поле db. Это поле позволяет сервису взаимодействовать с базой данных, выполняя операции чтения и записи с коллекциями.
  2. Это функция-конструктор, которая создаёт и возвращает новый экземпляр структуры AuthService. На вход она принимает объект базы данных db (указатель на mongo.Database). Возвращаемое значение — указатель на новый объект типа AuthService, который можно использовать для вызова методов аутентификации.
  3. Метод аутентификации пользователя по имени и паролю. Если аутентификация успешна, возвращается JWT-токен. Создаётся пустой объект User, в который будут загружены данные пользователя из базы данных. Выполняется запрос к коллекции admin, где ищется документ с полем name, равным переданному имени пользователя. Если пользователь найден, результат запроса декодируется в объект user. Если пользователя не существует, возвращается ошибка с сообщением "invalid username". Далее проверяется, совпадает ли хешированный пароль пользователя в базе данных с переданным паролем. Если имя пользователя и пароль верны, вызывается функция generateJWT, которая создаёт JWT-токен для пользователя. В случае успешного выполнения возвращается сгенерированный токен.
  4. Метод смены пароля. Аналогично предыдущему методу, происходит поиск пользователя по имени в базе данных. После успешного нахождения пользователя проверяется правильность текущего пароля. Если текущий пароль верен, создаётся хеш для нового пароля, и новый хешированный пароль записывается в базу данных. Если операция обновления завершилась успешно, возвращается сообщение "password updated successfully".
  5. Функция генерации JWT-токена для указанного имени пользователя. Создаётся новый токен с методом подписи HS256 (HMAC с SHA-256). Внутри токена записываются "claims" — данные пользователя, такие как имя пользователя и время истечения срока действия токена (он будет действителен 30 дней). Секретный ключ для подписи токена загружается из файла .env. Токен подписывается с использованием этого секретного ключа, и, если подпись прошла успешно, возвращается строка с токеном.

Контроллеры выполняют роль посредников между моделями и сервисами. Их задача — обрабатывать пользовательские запросы, выполнять необходимую бизнес-логику, взаимодействовать с моделями и передавать данные в сервисы для дальнейшего использования.

Далее создаём папку controllers с одноимённым пакетом и файлом auth_controller.go.
C-подобный: Скопировать в буфер обмена
Code:
// 1
type AuthController struct {
 service *services.AuthService
}

// 2
func NewAuthController(service *services.AuthService) *AuthController {
 return &AuthController{service: service}
}

// 3
func (ctrl *AuthController) Login(c *gin.Context) {
 var input models.User
 if err := c.ShouldBindJSON(&input); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }

 token, err := ctrl.service.AuthenticateUser(context.Background(), input.Username, input.Password)
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"token": token})
}

// 4
func (ctrl *AuthController) ChangePassword(c *gin.Context) {
 var input models.ChangePassword
 if err := c.ShouldBindJSON(&input); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }

 response, err := ctrl.service.ChangePassword(context.Background(), input.Username, input.OldPassword, input.NewPassword)
 if err != nil {
  c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, gin.H{"response": response})
}

  1. Определяется структура AuthController, которая содержит поле service. Контроллер не реализует бизнес-логику напрямую, а передаёт данные сервису, обеспечивая таким образом разделение ответственности.
  2. Эта функция является конструктором для AuthController. Задача конструктора — упростить создание контроллера и передать ему необходимые зависимости (в данном случае сервис аутентификации).
  3. Метод Login обрабатывает POST-запрос для входа. Создаётся структура для данных пользователя. Вызов метода ShouldBindJSON(&input) привязывает данные запроса в формате JSON к структуре. В случае ошибки возвращается ответ с кодом 400 (Bad Request). Далее вызывается метод аутентификации пользователя. При ошибке возвращается ответ с кодом 401 (Unauthorized). В случае успеха возвращается сгенерированный токен.
  4. Метод ChangePassword аналогичен, но обрабатывает смену пароля. Подключается структура для данных по смене пароля. Происходит валидация JSON. Затем вызывается метод для смены пароля, и при успехе возвращается подтверждение.

После этого остаётся лишь добавить маршрут. Создаём папку routes с пакетом routes и файлом auth_routes.go.


C-подобный: Скопировать в буфер обмена
Code:
func SetupAuthRoutes(router *gin.Engine, service *services.AuthService) {
 controller := controllers.NewAuthController(service)

 router.POST("/login", controller.Login)
 router.POST("/change-password", middleware.AuthMiddleware(), controller.ChangePassword)
}

Функция SetupAuthRoutes принимает два аргумента: router — объект маршрутизатора от Gin, который управляет HTTP-запросами, и объект сервиса аутентификации. Внутри создаётся новый объект контроллера. Далее создаются маршруты: первый маршрут по адресу /login с ранее написанным функционалом для получения токена, второй маршрут для смены пароля с промежуточной функцией, которая валидирует токен. Миддлвер будет добавлен ко всем дальнейшим маршрутам.

Изменим файл main.go.
C-подобный: Скопировать в буфер обмена
Code:
db := config.Client.Database("MasterDB")
 authService := services.NewAuthService(db)

 router := gin.Default()
 routes.SetupAuthRoutes(router, authService)

 router.Run(":8083")

Подключаемся к базе данных MasterDB (созданной на этапе инициализации конфига). Создаём экземпляр сервиса аутентификации. Инициализируем новый объект маршрутизатора Gin с настройками по умолчанию. Вызываем функцию SetupAuthRoutes. Запускаем HTTP-сервер на порту 8083. Функция Run начинает прослушивание входящих HTTP-запросов. Приложение будет доступно по адресу localhost:8083.

Для тестирования запросов можно использовать Thunder Client в VS Code, а я воспользуюсь инструментом Postman. Создаём новую коллекцию, добавляем папку Auth и тестируем маршрут по адресу http://localhost:8083/login. Выбираем вкладку Body, далее — Raw, и передаём данные в виде JSON-объекта:

JSON: Скопировать в буфер обмена
Code:
{
{ "name": "admin",
 "password": "admin" }
}

Если всё сделано правильно, в ответ получаем токен. Для тестирования дальнейших маршрутов необходимо добавить заголовок Authorization со значением токена. Для удобства можно вынести его в переменную (любой клиент поддерживает такую функциональность).

Именно такой процесс будет использоваться для добавления новых маршрутов. Давайте закрепим его на примере маршрута для добавления категорий.

Добавление категорий

Создаем модель в файле category.go. Определим модели для создания и обновления категорий.

C-подобный: Скопировать в буфер обмена
Code:
type Category struct {
 CategoryName string `bson:"categoryName" json:"categoryName" unique:"true"`
}

type UpdateCategoryName struct {
 CategoryName string `bson:"categoryName" json:"categoryName"`
 NewName  string `bson:"new_categoryName" json:"new_categoryName"`
}

После этого создаем файл cat_service.go и прописываем логику сервиса. Сервис для работы с категориями включает операции создания, чтения, обновления и удаления (CRUD) категорий.
C-подобный: Скопировать в буфер обмена
Code:
package services

import (
 "apirtgbot/models"
 "context"
 "errors"

 "go.mongodb.org/mongo-driver/bson"
 "go.mongodb.org/mongo-driver/mongo"
)

type CatService struct {
 db *mongo.Database
}

func NewCatService(db *mongo.Database) *CatService {
 return &CatService{db: db}
}

// 1
func (s *CatService) CreateCategory(ctx context.Context, name string) error {
 existingCategory := &models.Category{}
 err := s.db.Collection("items").FindOne(ctx, bson.M{"categoryName": name}).Decode(existingCategory)
 if err == nil {
  return errors.New("category already exists")
 } else if err != mongo.ErrNoDocuments {
  return err
 }

 _, err = s.db.Collection("items").InsertOne(ctx, models.Category{CategoryName: name})
 return err
}

// 2
func (s *CatService) CreateCategories(ctx context.Context, names []string) ([]string, error) {
 var newCategories []string
 for _, name := range names {
  existingCategory := &models.Category{}
  err := s.db.Collection("items").FindOne(ctx, bson.M{"categoryName": name}).Decode(existingCategory)
  if err == mongo.ErrNoDocuments {
   _, err := s.db.Collection("items").InsertOne(ctx, models.Category{CategoryName: name})
   if err != nil {
    return nil, err
   }
   newCategories = append(newCategories, name)
  } else if err != nil {
   return nil, err
  }
 }
 return newCategories, nil
}

// 3
func (s *CatService) GetCategories(ctx context.Context) ([]models.Category, error) {
 cursor, err := s.db.Collection("items").Find(ctx, bson.D{})
 if err != nil {
  return nil, err
 }
 defer cursor.Close(ctx)

 var cats []models.Category
 if err := cursor.All(ctx, &cats); err != nil {
  return nil, err
 }
 return cats, nil
}

// 4
func (s *CatService) UpdateCategoryName(ctx context.Context, name, newName string) error {
 update := bson.M{"$set": bson.M{"categoryName": newName}}
 result, err := s.db.Collection("items").UpdateOne(ctx, bson.M{"categoryName": name}, update)
 if err != nil {
  return errors.New("failed to update category: " + err.Error())
 }
 if result.MatchedCount == 0 {
  return errors.New("category '" + name + "' does not exist")
 }
 return nil
}

// 5
func (s *CatService) DeleteCategories(ctx context.Context, name string) error {
 result, err := s.db.Collection("items").DeleteOne(ctx, bson.M{"categoryName": name})
 if err != nil {
  return err
 }
 if result.DeletedCount == 0 {
  return errors.New("category does not exist")
 }

 return nil
}


  1. Создание одной категории. Проверяет, существует ли категория с таким именем. Если категория уже существует, возвращает ошибку. Если категории с таким именем нет, добавляет её в базу данных.
  2. Создание нескольких категорий. Перебирает список имён категорий. Для каждой категории проверяет, существует ли она. Если категория не существует, добавляет её в базу данных и сохраняет в список новых категорий. Возвращает список добавленных категорий.
  3. Чтение всех категорий. Получает все категории из базы данных. Возвращает список найденных категорий.
  4. Обновление имени категории. Ищет категорию по старому имени. Если категория найдена, обновляет её имя. Если категория не найдена, возвращает ошибку.
  5. Удаление категории. Ищет категорию по имени. Если категория существует, удаляет её. Если категории не существует, возвращает ошибку.

Переходим к контроллеру. Логика в файле cat_controller.go аналогична логике авторизации, а функции соответствуют функциям сервиса.

C-подобный: Скопировать в буфер обмена
Code:
package controllers

import (
 "apirtgbot/models"
 "apirtgbot/services"
 "context"
 "errors"
 "net/http"

 "github.com/gin-gonic/gin"
 "go.mongodb.org/mongo-driver/mongo"
)

type CatController struct {
 service *services.CatService
}

func NewCatController(service *services.CatService) *CatController {
 return &CatController{service: service}
}

func (ctrl *CatController) CreateCategory(c *gin.Context) {
 var category models.Category
 if err := c.BindJSON(&category); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }

 if err := ctrl.service.CreateCategory(context.Background(), category.CategoryName); err != nil {
  if errors.Is(err, mongo.ErrNoDocuments) {
   c.JSON(http.StatusConflict, gin.H{"error": "Category alredy exists"})
  } else {
   c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  }
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "Category added successfully"})
}

func (ctrl *CatController) CreateCategories(c *gin.Context) {
 var categories []models.Category
 if err := c.BindJSON(&categories); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }

 var names []string
 for _, cat := range categories {
  names = append(names, cat.CategoryName)
 }

 newCategories, err := ctrl.service.CreateCategories(c.Request.Context(), names)
 if err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }

 c.JSON(http.StatusOK, gin.H{"message": "Categories added successfully", "newCategories": newCategories})
}

func (ctrl *CatController) GetCategories(c *gin.Context) {
 cats, err := ctrl.service.GetCategories(context.Background())
 if err != nil {
  c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  return
 }
 c.JSON(http.StatusOK, cats)
}

func (ctrl *CatController) UpdateCategoryName(c *gin.Context) {
 var updateName models.UpdateCategoryName
 if err := c.BindJSON(&updateName); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }

 if err := ctrl.service.UpdateCategoryName(context.Background(), updateName.CategoryName, updateName.NewName); err != nil {
  if errors.Is(err, mongo.ErrNoDocuments) {
   c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
  } else {
   c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  }
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "Category updated successfully"})
}

func (ctrl *CatController) DeleteCategories(c *gin.Context) {
 var category models.Category
 if err := c.BindJSON(&category); err != nil {
  c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  return
 }
 if err := ctrl.service.DeleteCategories(context.Background(), category.CategoryName); err != nil {
  if errors.Is(err, mongo.ErrNoDocuments) {
   c.JSON(http.StatusConflict, gin.H{"error": "Category do not exists"})
  } else {
   c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
  }
  return
 }
 c.JSON(http.StatusOK, gin.H{"message": "Category deleted successfully"})

}


В файле cat_routes.go определяем необходимые маршруты.
C-подобный: Скопировать в буфер обмена
Code:
func SetupCatRoutes(router *gin.Engine, service *services.CatService) {
 controller := controllers.NewCatController(service)

 router.POST("/create/cat", middleware.AuthMiddleware(), controller.CreateCategory)
 router.POST("/create/cats", middleware.AuthMiddleware(), controller.CreateCategories)
 router.GET("/get/cats", middleware.AuthMiddleware(), controller.GetCategories)
 router.PUT("/update/name/cat", middleware.AuthMiddleware(), controller.UpdateCategoryName)
 router.DELETE("/delete/cat", middleware.AuthMiddleware(), controller.DeleteCategories)
}


Вносим изменения в main.go.
C-подобный: Скопировать в буфер обмена
Code:
db := config.Client.Database("MasterDB")
 authService := services.NewAuthService(db)
 catService := services.NewCatService(db)

 router := gin.Default()
 routes.SetupAuthRoutes(router, authService)
 routes.SetupCatRoutes(router, catService)

 router.Run(":8083")


Открываем Postman и по адресу localhost:8083/create/cat добавляем категорию "Roblox".

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

Что касается фотографий, для их хранения можно использовать сервер S3, но это не обязательно — можно брать ссылки из интернета.

Также я добавлю сами аккаунты, у которых будут следующие поля: ID, содержимое (непосредственно доступ) и дата добавления (для этого подключим библиотеку time). Для создания ID внутри коллекций (MongoDB создает внутренний _id на уровне записи в коллекцию, но не на уровне вложенности) подключим библиотеку github.com/google/uuid. ID будут нужны для обновления.

Итак, в нашей базе данных уже есть категория (Roblox), подкатегории (типы аккаунтов) и товары (сами аккаунты). Возвращаемся к Telegram-боту!

Отображение в Telegram-боте

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

  • Команды представляют собой текстовые сообщения, начинающиеся с символа /. Они используются для вызова определённых функций бота. Например, команда /start может запускать приветственное сообщение или основное меню бота.
  • Кнопки — это элементы интерфейса, которые могут быть добавлены к каждому сообщению бота.
  • Клавиатура — это особый вид кнопок, который отображается прямо под сообщением бота.

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

В папке config создадим файл message.go, в котором будем хранить все сообщения бота для пользователя. Добавим приветственное сообщение. В будущем все остальные сообщения буду добавлять сюда.


C-подобный: Скопировать в буфер обмена
Code:
package config

var HelloMessage = "👋 Выберите категорию:"


В файле .env добавим строку подключения к базе данных, а в файле init.go напишем функцию для ее получения.


C-подобный: Скопировать в буфер обмена
Code:
func GetMongoURL() string {
 errLoad := godotenv.Load()
 if errLoad != nil {
  log.Fatal("Error loading .env file")
 }

 mongoURI := os.Getenv("MONGO")

 return mongoURI
}

Создадим три папки: models, handlers, db. В первую очередь создадим два файла в папке db. В файле db.go будет код для подключения к базе данных (очень похожий код был в API).

C-подобный: Скопировать в буфер обмена
Code:
package db

import (
 "context"
 "log"

 "go.mongodb.org/mongo-driver/mongo"
 "go.mongodb.org/mongo-driver/mongo/options"
)

var client *mongo.Client

func InitMongoClient(uri string) {
 var err error
 client, err = mongo.Connect(context.TODO(), options.Client().ApplyURI(uri))
 if err != nil {
  log.Fatalf("Failed to connect to MongoDB: %v", err)
 }
}

Также напишем код для получения списка категорий из базы данных.
C-подобный: Скопировать в буфер обмена
Code:
package db

import (
 "context"

 "go.mongodb.org/mongo-driver/bson"
)

func GetCategories(ctx context.Context) ([]string, error) {
 collection := client.Database("MasterDB").Collection("items")
 cursor, err := collection.Find(ctx, bson.D{})
 if err != nil {
  return nil, err
 }
 defer cursor.Close(ctx)

 var categories []string
 for cursor.Next(ctx) {
  var result map[string]interface{}
  if err := cursor.Decode(&result); err != nil {
   return nil, err
  }
  if categoryName, ok := result["categoryName"].(string); ok {
   categories = append(categories, categoryName)
  }
 }
 return categories, nil
}

Изменим код main.go:
C-подобный: Скопировать в буфер обмена
Code:
// 1
func handleUpdates() {

 // 2
 offset := 0

 // 3
 for {
  // 4
  url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?offset=%d", config.GetTelegramBotToken(), offset)
  resp, err := http.Get(url)
  if err != nil {
   log.Printf("Failed to get updates: %v", err)
   time.Sleep(5 * time.Second)
   continue
  }
  // 5
  if err := json.NewDecoder(resp.Body).Decode(&models.UpdatesResponse); err != nil {
   log.Printf("Failed to decode response: %v", err)
   resp.Body.Close()
   time.Sleep(5 * time.Second)
   continue
  }
  resp.Body.Close()

  // 6
  for _, update := range models.UpdatesResponse.Result {
   offset = int(update.UpdateID) + 1

   // 7
   if update.Message != nil {
    chatID := update.Message.Chat.ID
    if update.Message.Text == "/start" {
     handlers.HandleStartMessage(chatID)
    }
    // 8
   } else if update.CallbackQuery != nil {
    callbackID := update.CallbackQuery.ID
    data := update.CallbackQuery.Data
    chatID := update.CallbackQuery.From.ID
    handlers.HandleCallbackQuery(callbackID, data, chatID)
   }
  }
 }
}


func main() {
 if config.GetTelegramBotToken() == "" {
  log.Fatal("Environment variables TELEGRAM_BOT_TOKEN not set")
 }

 telegram.InitBotToken(config.GetTelegramBotToken())
 // 9
 db.InitMongoClient(config.GetMongoURL())
 handleUpdates()
}
  1. Функция выполняет основной цикл обработки обновлений из Telegram.
  2. Offset используется для получения новых сообщений, чтобы Telegram не присылал уже обработанные.
  3. Далее запускается бесконечный цикл, который постоянно запрашивает новые обновления от Telegram и обрабатывает их.
  4. Строится URL для запроса к Telegram API.
  5. Ответ от Telegram в формате JSON декодируется в структуру UpdatesResponse, которая будет написана ниже.
  6. Для каждого обновления из Telegram происходит обновление offset, чтобы в следующий раз не получать те же данные.
  7. Если получено сообщение, проверяется, является ли оно командой /start. Если да, вызывается функция HandleStartMessage, которая отвечает за обработку команды /start.
  8. Если в обновлении есть Callback-запрос (нажатие кнопки), вызывается HandleCallbackQuery, которая обрабатывает такие запросы.
  9. Подключаемся к базе данных и запускаем обработку сообщений.

Структура данных UpdatesResponse используется для хранения и обработки ответа от Telegram API на запрос обновлений через метод getUpdates. Она соответствует JSON-ответу, который приходит от Telegram.

C-подобный: Скопировать в буфер обмена
Code:
package models

var UpdatesResponse struct {
 Result []struct {
  UpdateID int64 `json:"update_id"`
  Message *struct {
   Chat struct {
    ID int64 `json:"id"`
   } `json:"chat"`
   Text string `json:"text"`
  } `json:"message,omitempty"`
  CallbackQuery *struct {
   ID string `json:"id"`
   Data string `json:"data"`
   From struct {
    ID int64 `json:"id"`
   } `json:"from"`
  } `json:"callback_query,omitempty"`
 } `json:"result"`
}

Реализуем несколько функций в файле telegram.go:
C-подобный: Скопировать в буфер обмена
Code:
// 1
func SendMessage(chatID int64, text string, replyMarkup interface{}) error {
 url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
 body := map[string]interface{}{
  "chat_id":  chatID,
  "text":   text,
  "reply_markup": replyMarkup,
  "parse_mode": "HTML",
 }
 buf := new(bytes.Buffer)
 if err := json.NewEncoder(buf).Encode(body); err != nil {
  return err
 }
 _, err := http.Post(url, "application/json", buf)
 return err
}

// 2
func SendPhoto(chatID int64, photoURL string, caption string, replyMarkup interface{}) error {
 url := fmt.Sprintf("https://api.telegram.org/bot%s/sendPhoto", botToken)
 body := map[string]interface{}{
  "chat_id":  chatID,
  "photo":  photoURL,
  "caption":  caption,
  "reply_markup": replyMarkup,
  "parse_mode": "HTML",
 }
 buf := new(bytes.Buffer)
 if err := json.NewEncoder(buf).Encode(body); err != nil {
  return err
 }
 _, err := http.Post(url, "application/json", buf)
 return err
}

// 3
func AnswerCallbackQuery(callbackQueryID string) error {
 url := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", botToken)
 body := map[string]interface{}{
  "callback_query_id": callbackQueryID,
 }
 buf := new(bytes.Buffer)
 if err := json.NewEncoder(buf).Encode(body); err != nil {
  return err
 }
 _, err := http.Post(url, "application/json", buf)
 return err
}

  1. Функция для отправки текстового сообщения в Telegram. Принимает в качестве аргументов идентификатор чата, текст сообщения, кнопки с ответом. Формируется URL для вызова метода sendMessage через Telegram API с использованием токена бота. Создается и кодируется тело запроса в формате JSON. Если запрос выполнен успешно, функция возвращает nil, иначе возвращает ошибку.
  2. Функция для отправки фотографии в Telegram. Принимает идентификатор чата, URL фотографии, которую нужно отправить, подпись к фотографии и кнопки управления.
  3. Функция для ответа на Callback-запрос (например, когда пользователь нажимает кнопку). Принимает уникальный идентификатор Callback-запроса, который Telegram присылает при взаимодействии с кнопками.

Переходим к хендлерам. В файле showCats.go реализуем функцию HandleStartMessage(), предназначенную для обработки команды /start (эта команда вызывается при первом контакте с ботом).

C-подобный: Скопировать в буфер обмена
Code:
func HandleStartMessage(chatID int64) {
 categories, err := db.GetCategories(context.Background())
 if err != nil {
  log.Printf("Failed to get categories: %v", err)
  return
 }

 inlineButtons := createInlineButtons(categories, "category_")
 replyMarkup := map[string]interface{}{
  "inline_keyboard": inlineButtons,
 }
 if err := telegram.SendMessage(chatID, config.HelloMessage, replyMarkup); err != nil {
  log.Printf("Failed to send message: %v", err)
 }
}

Функция обращается к базе данных для получения списка категорий, после чего вызывается функция createInlineButtons, которая на основе списка категорий формирует массив кнопок. replyMarkup — это объект, который содержит клавиатуру с инлайн-кнопками. Он используется для отправки кнопок вместе с сообщением. Отправляем приветственное сообщение и кнопки.

В файле handlers.go реализуем функцию createInlineButtons для создания массива инлайн-кнопок для использования в сообщениях бота, а также HandleCallbackQuery для обработки Callback-запросов.

C-подобный: Скопировать в буфер обмена
Code:
func HandleCallbackQuery(callbackID, data string, chatID int64) {
 telegram.AnswerCallbackQuery(callbackID)
}

func createInlineButtons(items []string, prefix string) [][]map[string]interface{} {
 var inlineButtons [][]map[string]interface{}
 for _, item := range items {
  inlineButtons = append(inlineButtons, []map[string]interface{}{
   {
    "text":   item,
    "callback_data": prefix + item,
   },
  })
 }
 return inlineButtons
}

Отображение подкатегорий

Далее флоу работы будет очень простым. Первым делом мы пишем обращение к базе данных. Нам необходимо получить подкатегории для категорий. Реализуем функционал в файле getSubCats.go.
C-подобный: Скопировать в буфер обмена
Code:
func GetSubCategories(ctx context.Context, categoryName string) ([]string, error) {
 collection := client.Database("MasterDB").Collection("items")
 filter := bson.M{"categoryName": categoryName}
 var result struct {
  SubCategories []struct {
   SubCategoryName string `bson:"subCategoryName"`
  } `bson:"subCategories"`
 }
 err := collection.FindOne(ctx, filter).Decode(&result)
 if err != nil {
  return nil, err
 }

 var subCategories []string
 for _, subcat := range result.SubCategories {
  subCategories = append(subCategories, subcat.SubCategoryName)
 }
 return subCategories, nil
}

Функция GetSubCategories делает запрос к коллекции items в MongoDB для получения подкатегорий на основе названия категории. Она декодирует результат запроса в структуру данных, а затем преобразует список подкатегорий в массив строк. В случае успеха возвращает массив строк с подкатегориями, в случае ошибки — возвращает nil и ошибку.

Создадим файл showSubCats.go в хендлерах.
C-подобный: Скопировать в буфер обмена
Code:
func handleCategorySelection(chatID int64, categoryName string) {
 subcategories, err := db.GetSubCategories(context.Background(), categoryName)
 if err != nil {
  log.Printf("Failed to get subcategories: %v", err)
  return
 }

 inlineButtons := createInlineButtons(subcategories, "subcategory_"+categoryName+"_")
 inlineButtons = append(inlineButtons, []map[string]interface{}{
  {
   "text":   "⬅️ Назад",
   "callback_data": "back_to_categories",
  },
 })

 replyMarkup := map[string]interface{}{
  "inline_keyboard": inlineButtons,
 }
 if err := telegram.SendMessage(chatID, config.SubcatMessage, replyMarkup); err != nil {
  log.Printf("Failed to send message: %v", err)
 }
}

func handleBackToCategories(chatID int64) {
 HandleStartMessage(chatID)
}

handleCategorySelection: Получает подкатегории для выбранной категории из базы данных. Создает инлайн-кнопки для подкатегорий и добавляет кнопку "Назад". Отправляет сообщение с подкатегориями и кнопками пользователю.

handleBackToCategories: Позволяет пользователю вернуться к начальному меню (выбору категорий) с помощью вызова функции HandleStartMessage.

Далее мы изменим код в файле handlers.go. По сути, мы связываем функцию на основе данных коллбека. Коллбек — это как бы команда, но происходящая в рамках одной команды. Следовательно, существует коллбек как команда, и мы можем передавать данные между вызовами коллбеков. Это становится более понятно на практике после написания нескольких команд.


C-подобный: Скопировать в буфер обмена
Code:
func HandleCallbackQuery(callbackID, data string, chatID int64) {
 switch {
 case data == "back_to_categories":
  handleBackToCategories(chatID)
 case strings.HasPrefix(data, "category_"):
  categoryName := strings.TrimPrefix(data, "category_")
  handleCategorySelection(chatID, categoryName)
 }
 telegram.AnswerCallbackQuery(callbackID)
}

Используется конструкция switch для определения, какое действие выполнить в зависимости от значения data. Если пользователь нажал кнопку "Назад", вызывается функция handleBackToCategories(chatID), которая возвращает пользователя к выбору категорий. Если данные начинаются с префикса category_, это означает, что пользователь выбрал определенную категорию. TrimPrefix используется для извлечения названия категории, удаляя префикс. После этого вызывается handleCategorySelection для обработки выбора подкатегорий этой категории.

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

Подключение платежного шлюза

У Telegram есть платежные системы, которые поддерживаются ими по умолчанию — Bot Payments API, но вы также можете подключить любую другую платежную систему.

Например, мы хотим подключить оплату в криптовалюте. Какие шаги необходимо предпринять?

Первым делом необходимо определиться, хотим ли мы использовать внутренний баланс, или получать оплату по факту продажи товара, не используя внутренний баланс. Я не буду использовать внутренний баланс; впрочем, у меня нет доводов против, любые варианты имеют право на жизнь.

Во-первых, мы можем самостоятельно реализовать такую функцию. Поскольку блокчейн публичен, мы можем, например, выдавать каждому пользователю свой кошелек и просто проверять блокчейн на баланс кошелька (если блокчейн публичен, если нет — то проверять баланс кошелька). Минус этого подхода в том, что мы нагенерируем много кошельков. Теоретически, мы можем генерировать адреса на основе мастер-адреса, но на практике я это не реализовывал. Если генерировать множество кошельков, потом придется переводить балансы на мастер-кошелек, потратив часть на комиссию. Либо мы можем использовать один кошелек, выдавать его пользователю и просто проверять хеш-ссылку транзакций от пользователя, но это открывает очевидную уязвимость: если кто-то поймет логику, он сможет парсить блокчейн, сопоставлять транзакцию с суммой товара и, по сути, перехватывать ее, выдавая себя за пользователя, который действительно сделал оплату (на самом деле, для небольших проектов это маловероятно, на мой взгляд).

Но мы можем обратиться к существующим платежным шлюзам. В этом контексте существуют несколько терминов, которые могут вызвать путаницу, а именно эквайринг, мерчант и процессинг. Эквайринг — это процесс, который позволяет мерчантам (продавцам) принимать платежи. Вы можете зарегистрироваться у криптоэквайера и начать получать платежи. Мерчант — это лицо, которое принимает оплату за товары (продавец). Процессинг относится к обработке транзакций. Процессинговые компании обеспечивают инфраструктуру для проведения транзакций. Эквайринг и процессинг могут пересекаться и выполняться одной и той же компанией, но они не являются идентичными понятиями.

В качестве такого я выберу OxaPay. Можно выбрать что угодно; я нашел эту компанию в Google и понятия не имею о ее надежности и так далее. Я ни в коем случае не призываю думать о ней что-либо хорошее или плохое. В любом случае, что бы вы ни выбрали, вам нужно пройти процесс регистрации, после которого вы получите тот или иной приватный ключ, который необходимо добавить в .env и обрабатывать так же, как и все переменные в файле до этого. Далее находите документацию API, открываете Postman и начинаете тестировать. Пример для сервиса — OxaPay.

Давайте покажу на примере.

Создадим файл createCryptoPayment.go в папке handlers. Этот код будет выполняться при выборе оплаты в криптовалюте. При нажатии на кнопку оплаты должна создаться ссылка для оплаты, которая будет отправлена пользователю.

C-подобный: Скопировать в буфер обмена
Code:
// 1
type CryptoPaymentResponse struct {
 Result int `json:"result"`
 Message string `json:"message"`
 TrackID string `json:"trackId"`
 ExpiredAt int64 `json:"expiredAt"`
 PayLink string `json:"payLink"`
}


func handleCryptoPayment(chatID int64, categoryName, subCategoryName string) {

 // 2
 subCategory, err := db.GetSubCategoryDetails(context.Background(), categoryName, subCategoryName)
 if err != nil {
  log.Printf("Failed to get subcategory details: %v", err)
  replyMarkup := map[string]interface{}{
   "inline_keyboard": [][]map[string]interface{}{
    {
     {
      "text":   "⬅️ Назад",
      "callback_data": "back_" + categoryName,
     },
    },
   },
  }
  if err := telegram.SendMessage(chatID, config.ItemsNotFound, replyMarkup); err != nil {
   log.Printf("Failed to send message: %v", err)
  }
  return
 }

 // 3
 itemID, err := db.GetAvailableItemID(context.Background(), categoryName, subCategoryName)
 if err != nil {
  log.Printf("Failed to get itemID: %v", err)
  replyMarkup := map[string]interface{}{
   "inline_keyboard": [][]map[string]interface{}{
    {
     {
      "text":   "⬅️ Назад",
      "callback_data": "back_" + categoryName,
     },
    },
   },
  }
  if err := telegram.SendMessage(chatID, config.ItemsNotFound, replyMarkup); err != nil {
   log.Printf("Failed to send message: %v", err)
  }
  return
 }

 // 4
 price := subCategory.Price
 token := config.GetCryptoMerchantToken()

 // 5
 paymentResponse, err := sendCryptoPaymentRequest(token, price)
 if err != nil {
  log.Printf("Failed to send crypto payment request: %v", err)
  replyMarkup := map[string]interface{}{
   "inline_keyboard": [][]map[string]interface{}{
    {
     {
      "text":   "⬅️ Назад",
      "callback_data": "back_" + categoryName,
     },
    },
   },
  }
  if err := telegram.SendMessage(chatID, config.ItemsNotFound, replyMarkup); err != nil {
   log.Printf("Failed to send message: %v", err)
  }
  return
 }


 // 6
 message := config.CryptoPaymentMsg(subCategoryName, price, paymentResponse.PayLink)
 replyMarkup := map[string]interface{}{
  "inline_keyboard": [][]map[string]interface{}{
   {
    {
     "text": "Проверить оплату",
     "callback_data": "checkcrypto_" + paymentResponse.TrackID + "_" + itemID,
    },
   },
   {
    {
     "text":   "Отменить заказ",
     "callback_data": "cancelcrypto_" + subCategoryName,
    },
   },
  },
 }

 if err := telegram.SendMessage(chatID, message, replyMarkup); err != nil {
  log.Printf("Failed to send message: %v", err)
 }
}



// 7
func sendCryptoPaymentRequest(token string, price float64) (*CryptoPaymentResponse, error) {

 // 8
 requestBody := map[string]interface{}{
  "merchant": token,
  "amount": price,
 }
 body, err := json.Marshal(requestBody)
 if err != nil {
  return nil, fmt.Errorf("failed to marshal request body: %w", err)
 }

 // 9
 req, err := http.NewRequest("POST", "https://api.oxapay.com/merchants/request", bytes.NewBuffer(body))
 if err != nil {
  return nil, fmt.Errorf("failed to create request: %w", err)
 }
 req.Header.Set("Content-Type", "application/json")

 // 10
 client := &http.Client{}
 resp, err := client.Do(req)
 if err != nil {
  return nil, fmt.Errorf("failed to send request: %w", err)
 }
 defer resp.Body.Close()

 // 11
 var paymentResponse CryptoPaymentResponse
 if err := json.NewDecoder(resp.Body).Decode(&paymentResponse); err != nil {
  return nil, fmt.Errorf("failed to decode response: %w", err)
 }

 // 12
 if paymentResponse.Result != 100 {
  return nil, fmt.Errorf("payment request failed: %s", paymentResponse.Message)
 }

 return &paymentResponse, nil
}


  1. Это структура данных для хранения информации об ответе на запрос создания ссылки для криптовалютного платежа.
  2. Функция запрашивает детали подкатегории из базы данных. Если возникает ошибка, пользователю отправляется сообщение с кнопкой для возврата назад. Это необходимо для получения цены товара.
  3. Далее запрашивается доступный ID товара в базе данных. Это нужно для его последующей проверки при оплате и выдаче товара. Идеально — добавить поля блокировки товара и логику разблокировки.
  4. Из подкатегории извлекается цена товара, а также токен криптомерчанта для дальнейшего запроса на оплату.
  5. Запрос на оплату выполняется через функцию sendCryptoPaymentRequest. В случае ошибки пользователю также отправляется сообщение с опцией вернуться назад.
  6. Формируется сообщение для пользователя с информацией о сумме платежа и ссылкой на оплату. Пользователь получает кнопки для проверки оплаты и отмены заказа.
  7. В этой функции выполняется запрос к API для создания криптоплатежа (ссылки для оплаты).
  8. Создается JSON с токеном мерчанта и суммой платежа, который затем сериализуется в строку для отправки.
  9. Создается POST-запрос с URL API и телом запроса.
  10. Запрос отправляется через HTTP-клиент, после чего выполняется обработка ответа.
  11. Ответ от API декодируется в структуру CryptoPaymentResponse.
  12. Если результат ответа не равен 100 (успешный платеж), возвращается ошибка. В случае успеха возвращается ответ с данными о платеже.

Теперь напишем логику проверки платежа в файле checkCryptoPayment.go:
C-подобный: Скопировать в буфер обмена
Code:
func handleCheckCryptoPayment(chatID int64, payID string, itemID string) {
 token := config.GetCryptoMerchantToken()
 requestBody := map[string]interface{}{
  "merchant": token,
  "trackId": payID,
 }

 body, err := json.Marshal(requestBody)
 if err != nil {
  log.Printf("Failed to marshal request body: %v", err)
  return
 }

 // 1
 req, err := http.NewRequest("POST", "https://api.oxapay.com/merchants/inquiry", bytes.NewBuffer(body))
 if err != nil {
  log.Printf("Failed to create request: %v", err)
  return
 }
 req.Header.Set("Content-Type", "application/json")

 client := &http.Client{}
 resp, err := client.Do(req)
 if err != nil {
  log.Printf("Failed to send request: %v", err)
  return
 }
 defer resp.Body.Close()

 var paymentResponse models.CryptoPaymentStatusResponse
 if err := json.NewDecoder(resp.Body).Decode(&paymentResponse); err != nil {
  log.Printf("Failed to decode response: %v", err)
  return
 }


 // 2
 var message string
 var replyMarkup map[string]interface{}

 // 3
 if paymentResponse.Status == "Waiting" {

  ctx := context.TODO()

  itemContent, err := db.GetItemAndRemove(ctx, itemID)
  if err != nil {
   log.Printf("Failed to get item content: %v", err)
   return
  }

  message = fmt.Sprintf("Аккаунт - %s. Спасибо за покупку!", itemContent)
  replyMarkup = map[string]interface{}{
   "inline_keyboard": [][]map[string]interface{}{
    {
    },
   },
  }
 } else {
  message = "Ваша оплата еще не поступила, проверьте еще раз через несколько минут"
  replyMarkup = map[string]interface{}{
   "inline_keyboard": [][]map[string]interface{}{
    {
     {
      "text":   "Проверить оплату",
      "callback_data": "checkcrypto_" + payID + "_" + itemID,
     },
    },
   },
  }
 }

 if err := telegram.SendMessage(chatID, message, replyMarkup); err != nil {
  log.Printf("Failed to send message: %v", err)
 }

}

  1. Из конфигурации берется токен криптомерчанта. Вместе с идентификатором платежа формируется JSON-запрос. Этот запрос отправляется методом POST на URL API для проверки платежа. Если возникают ошибки при создании или отправке запроса, они логируются, и выполнение функции завершается.
  2. Ответ от API декодируется в структуру данных CryptoPaymentStatusResponse. Если декодирование не удалось, ошибка логируется, и функция завершается.
  3. Если статус платежа равен "Waiting", то: из базы данных извлекается содержимое товара по идентификатору (itemID), после чего товар удаляется, чтобы избежать повторных продаж. Пользователю отправляется сообщение с подтверждением покупки и содержимым товара. К сообщению прикладывается пустая разметка клавиатуры, так как дальнейших действий от пользователя не требуется. Если статус платежа отличается от "Waiting" (например, оплата еще не завершена), пользователю отправляется сообщение с предложением проверить оплату позже. К сообщению добавляется кнопка "Проверить оплату", при нажатии которой снова вызывается проверка платежа. Статус "Waiting" используется для отладки. При успешной оплате проверка должна выполняться на статусе "Done".

Подробности работы API (например, проверка по коду 100 или другим ответам) доступны в документации API. В любом случае вы должны перепроверить все самостоятельно. Если что-то не получается, можно обратиться в поддержку сервиса. Они, скорее всего, помогут, так как заинтересованы в интеграции своего сервиса, но не стоит злоупотреблять их помощью.

Каждое действие мы прописываем в хендлере. Вот как он будет выглядеть в итоге.
C-подобный: Скопировать в буфер обмена
Code:
func HandleCallbackQuery(callbackID, data string, chatID int64) {
 switch {
 case data == "back_to_categories":
  handleBackToCategories(chatID)
 case strings.HasPrefix(data, "category_"):
  categoryName := strings.TrimPrefix(data, "category_")
  handleCategorySelection(chatID, categoryName)
 case strings.HasPrefix(data, "subcategory_"):
  parts := strings.SplitN(data, "_", 3)
  if len(parts) == 3 {
   categoryName := parts[1]
   subCategoryName := parts[2]
   handleSubcategorySelection(chatID, categoryName, subCategoryName)
  }
 case strings.HasPrefix(data, "back_"):
  categoryName := strings.TrimPrefix(data, "back_")
  handleBackFromSubcategory(chatID, categoryName)
 case strings.HasPrefix(data, "pay_"):
   remainingData := strings.TrimPrefix(data, "pay_")
   parts := strings.SplitN(remainingData, "_", 2)
   if len(parts) == 2 {
   categoryName := parts[0]
   subCategoryName := parts[1]
   handlePaySelection(chatID, categoryName, subCategoryName) } 
 case strings.HasPrefix(data, "crypto_"):
  remainingData := strings.TrimPrefix(data, "crypto_")
  parts := strings.SplitN(remainingData, "_", 2)
  if len(parts) == 2 {
   categoryName := parts[0]
   subCategoryName := parts[1]
   handleCryptoPayment(chatID, categoryName, subCategoryName) }
 case strings.HasPrefix(data, "checkcrypto_"):
  remainingData := strings.TrimPrefix(data, "checkcrypto_")
  parts := strings.SplitN(remainingData, "_", 2)
  if len(parts) == 2 {
   payID := parts[0]
   itemID := parts[1]
   handleCheckCryptoPayment(chatID, payID, itemID) }
 }
 telegram.AnswerCallbackQuery(callbackID)
}

Вот как в итоге выглядит бот.



Панель управления

Панель управления может включать в себя множество функций. В первую очередь это статистика продаж и взаимодействия с ботом. Всю эту информацию удобно мониторить с панели управления. Во вторую очередь — это функциональность, связанная с управлением товарами.

JavaScript не был бы JavaScript'ом, если бы не существовало библиотеки, которая уже как-то там решила нужную задачу. В данном случае такой библиотекой является React-Admin - The Open-Source Framework For B2B Apps. Можно посмотреть, как может выглядеть панель управления на её основе и создать что-то своё.

Я не буду писать полноценную админ-панель, так как статья тогда превратилась бы в книгу, поэтому реализуем лишь ограниченный функционал: аутентификацию и добавление новой категории товара.

Напишем код для файла Login.jsx (первой страницы приложения). Установим axios, а CSS-стили вынесем в отдельный файл.
JavaScript: Скопировать в буфер обмена
Code:
import React, { useState } from 'react';
import axios from 'axios';
import './Login.css';

const Login = () => {
 // 1
 const [name, setName] = useState('');
 const [password, setPassword] = useState('');
 const [error, setError] = useState('');

 // 2
 const handleSubmit = async (e) => {
  e.preventDefault();

  try {
   const response = await axios.post('http://localhost:8083/login', {
    name,
    password,
   });

   const { token } = response.data;

   localStorage.setItem('token', token);

   window.location.href = '/admin';
  } catch (err) {
   setError('Неправильное имя пользователя или пароль');
  }
 };


 return (
  // 3
  <div className="login-container">
   <form className="login-form" onSubmit={handleSubmit}>
    <h2>Sign In</h2>
    <input
     type="text"
     placeholder="Username"
     value={name}
     onChange={(e) => setName(e.target.value)}
     required
    />
    <input
     type="password"
     placeholder="Password"
     value={password}
     onChange={(e) => setPassword(e.target.value)}
     required
    />
    <button type="submit">Login</button>
    {error && <p className="error">{error}</p>}
   </form>
  </div>
 );
};

export default Login;

  1. Используем хук useState, чтобы создать три состояния: name для хранения имени пользователя (инициализируется пустой строкой), password для хранения пароля (также инициализируется пустой строкой) и error для хранения сообщений об ошибках (например, если логин не удался), изначально тоже пустая строка.
  2. Асинхронная функция, которая обрабатывает событие отправки формы. Она предотвращает перезагрузку страницы при отправке формы. В блоке try выполняется HTTP-запрос с помощью axios.post к серверу по адресу, передавая name и password в теле запроса. Если запрос успешен, из ответа извлекается token, который затем сохраняется в localStorage для дальнейшего использования (например, для аутентификации). После этого происходит перенаправление на страницу /admin. В блоке catch обрабатываются ошибки: если логин не удался, устанавливается сообщение об ошибке с помощью setError.
  3. Возвращаем разметку компонента.

Код компонента AdminPanel.jsx

JavaScript: Скопировать в буфер обмена
Code:
const AdminPanel = () => {
 const navigate = useNavigate();
 const [categoryName, setCategoryName] = useState('');
 const [message, setMessage] = useState('');

 // 1
 useEffect(() => {
  const token = localStorage.getItem('token');
  if (!token) {
   navigate('/');
  }
 }, [navigate]);

 // 2
 const handleAddCategory = async () => {
  const token = localStorage.getItem('token');

  try {
   const response = await axios.post(
    'http://localhost:8083/create/cat',
    { categoryName },
    {
     headers: {
      Authorization: `${token}`,
     },
    }
   );
   setMessage(`Category "${categoryName}" added successfully!`);
   setCategoryName('');
  } catch (error) {
   setMessage('Failed to add category. Please try again.');
  }
 };

 return (
  <div className="admin-panel-container">
   <h1 className="welcome-message">Welcome to Admin Panel</h1>
   <div className="add-category-section">
    <input
     type="text"
     placeholder="Category Name"
     value={categoryName}
     onChange={(e) => setCategoryName(e.target.value)}
     className="category-input"
    />
    <button onClick={handleAddCategory} className="add-category-button">
     Add Category
    </button>
   </div>
   {message && <p className="response-message">{message}</p>}
  </div>
 );
};

export default AdminPanel;

  1. useEffect используется для выполнения побочных эффектов после рендера компонента. В данном случае, при загрузке компонента мы получаем токен из localStorage. Если токена нет, пользователь перенаправляется на главную страницу (/), так как это означает, что он не авторизован. Это предотвращает несанкционированный доступ к панели администратора.
  2. Асинхронная функция для обработки запроса на создание категории. Токен снова извлекается из localStorage для аутентификации запроса. Отправляется POST-запрос на сервер по адресу localhost:8083/create/cat, где в теле запроса передаётся объект с categoryName. Заголовки запроса включают Authorization, чтобы передать токен для подтверждения прав администратора. Если запрос успешен, выводится сообщение о том, что категория была успешно добавлена, и состояние categoryName сбрасывается, очищая поле ввода. В случае ошибки отображается сообщение с просьбой повторить попытку.

Подключим библиотеку react-router-dom для создания роутинга приложения — код в файле App.jsx.

JavaScript: Скопировать в буфер обмена
Code:
import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Login from './Login';
import AdminPanel from './AdminPanel';

function App() {
 return (
  <Router>
   <Routes>
    <Route path="/" element={<Login />} />
    <Route path="/admin" element={<AdminPanel />} />
   </Routes>
  </Router>
 );
}

export default App;




Деплой

Docker

Наши сущности сейчас разбросаны по папкам, но как выполнить деплой в Интернет? Для этого потребуется VPS-сервер и подключение к нему по SSH. После этого достаточно установить Docker.

Docker всё ещё может работать нестабильно с Windows. Если это ваша среда разработки, могут возникнуть некоторые проблемы (например, может слететь WSL). Однако в целом процесс разработки следующий. Для каждой папки (сущности) пишется Dockerfile. Dockerfile — это текстовый файл, содержащий инструкции для создания Docker-образа. По сути, это скрипт, который определяет, как должно быть упаковано приложение в контейнер. В Dockerfile описываются среда выполнения (например, операционная система, библиотеки, зависимости) и шаги по настройке этой среды. После этого необходимо написать Docker Compose.

Docker Compose — это инструмент для определения и запуска многоконтейнерных приложений с использованием Docker. Вместо того чтобы запускать каждый контейнер отдельно и вручную задавать параметры, такие как порты, тома или переменные окружения, Docker Compose позволяет описать все необходимые контейнеры, их взаимодействие и параметры в одном файле — docker-compose.yml.

Примеры этих файлов можно найти в проекте!

Трям! Пока!

Вложения​


  • code.zip
    81.9 КБ · Просмотры: 3
 
Статья про то, как создать свой велосипед. С одной стороны для практики это конечно очень хорошо, но для дальнейшего написания рекомендую использовать github.com/tucnak/telebot - уже готовая библиотека, но без реализации FSM, но свой сделать легко.

Может если руки дойдут напишу сам..
 
Что-то навалил, навалил. Тут тебе и Golang, и JWT, и NoSQL, и REST API какого-то криптошлюза, и JS c React'ом затесался, синхронное/асинхронное, но бот почему-то сам опрашивает сервера телеги... Почему-то я думал, что бот в телеге в пару/тройку сотен строк укладывается. Голова заболела, пойду ибопрофенчика шлепну.
 
rand сказал(а):
Это все конечно хорошо, но что насчёт клиентской реализации бота на Golang?=)
Нажмите, чтобы раскрыть...
не знаю что ты имелл ввиду, но у меня появились интересные мысли после того как я подумал над этим вопросом, спасибо

setup сказал(а):
Статья про то, как создать свой велосипед. С одной стороны для практики это конечно очень хорошо, но для дальнейшего написания рекомендую использовать github.com/tucnak/telebot - уже готовая библиотека, но без реализации FSM, но свой сделать легко.

Может если руки дойдут напишу сам..
Нажмите, чтобы раскрыть...
Глянул код, да смотрится красиво, интересная библиотека, но когда не понимаешь как в целом работают тг боты это не очевидно
 
Top