Содержание
  • Создаем бота
  • Пишем код
  • Разворачиваем бота
  • Заключение

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


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


Создаем бота


Идем к BotFather и создаем нового бота.



Далее добавляем ему команду с помощью которой мы сможем получить количество пользователей которые использовали бота.



Пишем код


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


Из нестандартных библиотек нам понадобятся.


go get github.com/Syfaro/telegram-bot-api

Для работы с TelegramAPI.


go get github.com/lib/pq

Для работы с БД Postgres.


Создадим структуры.


type SearchResults struct {
    ready   bool
    Query   string
    Results []Result
}

type Result struct {
    Name, Description, URL string
}

Обратите внимание на поле ready bool, если структура пустая то значение будет false.


Далее заносим данные в структуры.


func (sr *SearchResults) UnmarshalJSON(bs []byte) error {
    array := []interface{}{}
    if err := json.Unmarshal(bs, &array); err != nil {
        return err
    }
    sr.Query = array[0].(string)
    for i := range array[1].([]interface{}) {
        sr.Results = append(sr.Results, Result{
            array[1].([]interface{})[i].(string),
            array[2].([]interface{})[i].(string),
            array[3].([]interface{})[i].(string),
        })
    }
    return nil
}

Теперь нам нужна функция которая будет отправлять и получать данные.


func wikipediaAPI(request string) (answer []string) {

    //Создаем срез на 3 элемента
    s := make([]string, 3)

    //Отправляем запрос
    if response, err := http.Get(request); err != nil {
        s[0] = "Wikipedia is not respond"
    } else {
        defer response.Body.Close()

        //Считываем ответ
        contents, err := ioutil.ReadAll(response.Body)
        if err != nil {
            log.Fatal(err)
        }

        //Отправляем данные в структуру
        sr := &SearchResults{}
        if err = json.Unmarshal([]byte(contents), sr); err != nil {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проверяем не пустая ли наша структура
        if !sr.ready {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проходим через нашу структуру и отправляем данные в срез с ответом
        for i := range sr.Results {
            s[i] = sr.Results[i].URL
        }
    }

    return s
}

Полный код
type SearchResults struct {
    ready   bool
    Query   string
    Results []Result
}

type Result struct {
    Name, Description, URL string
}

func (sr *SearchResults) UnmarshalJSON(bs []byte) error {
    array := []interface{}{}
    if err := json.Unmarshal(bs, &array); err != nil {
        return err
    }
    sr.Query = array[0].(string)
    for i := range array[1].([]interface{}) {
        sr.Results = append(sr.Results, Result{
            array[1].([]interface{})[i].(string),
            array[2].([]interface{})[i].(string),
            array[3].([]interface{})[i].(string),
        })
    }
    return nil
}

func wikipediaAPI(request string) (answer []string) {

    //Создаем срез на 3 элемента
    s := make([]string, 3)

    //Отправляем запрос
    if response, err := http.Get(request); err != nil {
        s[0] = "Wikipedia is not respond"
    } else {
        defer response.Body.Close()

        //Считываем ответ
        contents, err := ioutil.ReadAll(response.Body)
        if err != nil {
            log.Fatal(err)
        }

        //Отправляем данные в структуру
        sr := &SearchResults{}
        if err = json.Unmarshal([]byte(contents), sr); err != nil {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проверяем не пустая ли наша структура
        if !sr.ready {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проходим через нашу структуру и отправляем данные в срез с ответом
        for i := range sr.Results {
            s[i] = sr.Results[i].URL
        }
    }

    return s
}

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


//Конвертируем запрос для использование в качестве части URL
func urlEncoded(str string) (string, error) {
    u, err := url.Parse(str)
    if err != nil {
        return "", err
    }
    return u.String(), nil
}

Теперь нам нужно как то взаимодействовать с БД. Создаем переменные в которых мы будем хранить данные переменных окружения для подключению к БД.


var host = os.Getenv("HOST")
var port = os.Getenv("PORT")
var user = os.Getenv("USER")
var password = os.Getenv("PASSWORD")
var dbname = os.Getenv("DBNAME")
var sslmode = os.Getenv("SSLMODE")

var dbInfo = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbname, sslmode)

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


//Создаем таблицу users в БД при подключении к ней
func createTable() error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Создаем таблицу users
    if _, err = db.Exec(`CREATE TABLE users(ID SERIAL PRIMARY KEY, TIMESTAMP TIMESTAMP DEFAULT CURRENT_TIMESTAMP, USERNAME TEXT, CHAT_ID INT, MESSAGE TEXT, ANSWER TEXT);`); err != nil {
        return err
    }

    return nil
}

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


//Собираем данные полученные ботом
func collectData(username string, chatid int64, message string, answer []string) error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Конвертируем срез с ответом в строку
    answ := strings.Join(answer, ", ")

    //Создаем SQL запрос
    data := `INSERT INTO users(username, chat_id, message, answer) VALUES($1, $2, $3, $4);`

    //Выполняем наш SQL запрос
    if _, err = db.Exec(data, `@`+username, chatid, message, answ); err != nil {
        return err
    }

    return nil
}

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


func getNumberOfUsers() (int64, error) {

    var count int64

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return 0, err
    }
    defer db.Close()

    //Отправляем запрос в БД для подсчета числа уникальных пользователей
    row := db.QueryRow("SELECT COUNT(DISTINCT username) FROM users;")
    err = row.Scan(&count)
    if err != nil {
        return 0, err
    }

    return count, nil
}

Полный код
var host = os.Getenv("HOST")
var port = os.Getenv("PORT")
var user = os.Getenv("USER")
var password = os.Getenv("PASSWORD")
var dbname = os.Getenv("DBNAME")
var sslmode = os.Getenv("SSLMODE")

var dbInfo = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbname, sslmode)

//Собираем данные полученные ботом
func collectData(username string, chatid int64, message string, answer []string) error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Конвертируем срез с ответом в строку
    answ := strings.Join(answer, ", ")

    //Создаем SQL запрос
    data := `INSERT INTO users(username, chat_id, message, answer) VALUES($1, $2, $3, $4);`

    //Выполняем наш SQL запрос
    if _, err = db.Exec(data, `@`+username, chatid, message, answ); err != nil {
        return err
    }

    return nil
}

//Создаем таблицу users в БД при подключении к ней
func createTable() error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Создаем таблицу users
    if _, err = db.Exec(`CREATE TABLE users(ID SERIAL PRIMARY KEY, TIMESTAMP TIMESTAMP DEFAULT CURRENT_TIMESTAMP, USERNAME TEXT, CHAT_ID INT, MESSAGE TEXT, ANSWER TEXT);`); err != nil {
        return err
    }

    return nil
}

func getNumberOfUsers() (int64, error) {

    var count int64

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return 0, err
    }
    defer db.Close()

    //Отправляем запрос в БД для подсчета числа уникальных пользователей
    row := db.QueryRow("SELECT COUNT(DISTINCT username) FROM users;")
    err = row.Scan(&count)
    if err != nil {
        return 0, err
    }

    return count, nil
}

Соединяем все воедино


Создаем бота.


    //Создаем бота
    bot, err := tgbotapi.NewBotAPI(os.Getenv("TOKEN"))
    if err != nil {
        panic(err)
    }

    //Устанавливаем время обновления
    u := tgbotapi.NewUpdate(0)
    u.Timeout = 60

    //Получаем обновления от бота
    updates, err := bot.GetUpdatesChan(u)

    for update := range updates {
        if update.Message == nil {
            continue
        }

Нам нужно проверить что от пользователя приходит именно текстовое сообщение этим займется наш if.


if reflect.TypeOf(update.Message.Text).Kind() == reflect.String && update.Message.Text != ""

В случае если пользователь отправил стикер или же видео то отправим пользователю сообщение которое укажет ему на то что подходят только текстовые сообщения.


//Отправлем сообщение
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Use the words for search.")
bot.Send(msg)

Внутрь if мы вложим switch который будет ловить команды:


/start


case "/start":

//Отправлем сообщение
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Hi, i'm a wikipedia bot, i can search information in a wikipedia, send me something what you want find in Wikipedia.")
bot.Send(msg)

/number_of_users


case "/number_of_users":

if os.Getenv("DB_SWITCH") == "on" {

//Присваиваем количество пользоватьелей использовавших бота в num переменную
num, err := getNumberOfUsers()
    if err != nil {

        //Отправлем сообщение
        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error.")
        bot.Send(msg)
        }

        //Создаем строку которая содержит колличество пользователей использовавших бота
        ans := fmt.Sprintf("%d peoples used me for search information in Wikipedia", num)

            //Отправлем сообщение
            msg := tgbotapi.NewMessage(update.Message.Chat.ID, ans)
            bot.Send(msg)
            } else {

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database not connected, so i can't say you how many peoples used me.")
                bot.Send(msg)
                }

и default который будет взаимодействовать с википедией и бд


default:

    //Устанавливаем язык для поиска в Википедии
    language := os.Getenv("LANGUAGE")

    //Создаем url для поиска
    ms, _ := urlEncoded(update.Message.Text)

    url := ms
    request := "https://" + language + ".wikipedia.org/w/api.php?action=opensearch&search=" + url + "&limit=3&origin=*&format=json"

    //Присваем данные среза с ответом в переменную message
    message := wikipediaAPI(request)

    if os.Getenv("DB_SWITCH") == "on" {

    //Отправляем username, chat_id, message, answer в БД
    if err := collectData(update.Message.Chat.UserName, update.Message.Chat.ID, update.Message.Text, message); err != nil {

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error, but bot still working.")
                bot.Send(msg)
                }
            }

            //Проходим через срез и отправляем каждый элемент пользователю
            for _, val := range message {

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, val)
                bot.Send(msg)
            }
        }

Создаем main.


func main() {

    time.Sleep(1 * time.Minute)

    //Создаем таблицу
    if os.Getenv("CREATE_TABLE") == "yes" {

        if os.Getenv("DB_SWITCH") == "on" {

            if err := createTable(); err != nil {

                panic(err)
            }
        }
    }

    time.Sleep(1 * time.Minute)

    //Вызываем бота
    telegramBot()
}

Полный код
func telegramBot() {

    //Создаем бота
    bot, err := tgbotapi.NewBotAPI(os.Getenv("TOKEN"))
    if err != nil {
        panic(err)
    }

    //Устанавливаем время обновления
    u := tgbotapi.NewUpdate(0)
    u.Timeout = 60

    //Получаем обновления от бота 
    updates, err := bot.GetUpdatesChan(u)

    for update := range updates {
        if update.Message == nil {
            continue
        }

        //Проверяем что от пользователья пришло именно текстовое сообщение
        if reflect.TypeOf(update.Message.Text).Kind() == reflect.String && update.Message.Text != "" {

            switch update.Message.Text {
            case "/start":

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Hi, i'm a wikipedia bot, i can search information in a wikipedia, send me something what you want find in Wikipedia.")
                bot.Send(msg)

            case "/number_of_users":

                if os.Getenv("DB_SWITCH") == "on" {

                    //Присваиваем количество пользоватьелей использовавших бота в num переменную
                    num, err := getNumberOfUsers()
                    if err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error.")
                        bot.Send(msg)
                    }

                    //Создаем строку которая содержит колличество пользователей использовавших бота
                    ans := fmt.Sprintf("%d peoples used me for search information in Wikipedia", num)

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, ans)
                    bot.Send(msg)
                } else {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database not connected, so i can't say you how many peoples used me.")
                    bot.Send(msg)
                }
            default:

                //Устанавливаем язык для поиска в википедии
                language := os.Getenv("LANGUAGE")

                //Создаем url для поиска
                ms, _ := urlEncoded(update.Message.Text)

                url := ms
                request := "https://" + language + ".wikipedia.org/w/api.php?action=opensearch&search=" + url + "&limit=3&origin=*&format=json"

                //Присваем данные среза с ответом в переменную message
                message := wikipediaAPI(request)

                if os.Getenv("DB_SWITCH") == "on" {

                    //Отправляем username, chat_id, message, answer в БД
                    if err := collectData(update.Message.Chat.UserName, update.Message.Chat.ID, update.Message.Text, message); err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error, but bot still working.")
                        bot.Send(msg)
                    }
                }

                //Проходим через срез и отправляем каждый элемент пользователю
                for _, val := range message {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, val)
                    bot.Send(msg)
                }
            }
        } else {

            //Отправлем сообщение
            msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Use the words for search.")
            bot.Send(msg)
        }
    }
}

func main() {

    time.Sleep(1 * time.Minute)

    //Создаем таблицу
    if os.Getenv("CREATE_TABLE") == "yes" {

        if os.Getenv("DB_SWITCH") == "on" {

            if err := createTable(); err != nil {

                panic(err)
            }
        }
    }

    time.Sleep(1 * time.Minute)

    //Вызываем бота
    telegramBot()
}

Итоговый код
package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "github.com/Syfaro/telegram-bot-api"
    _ "github.com/lib/pq"
    "io/ioutil"
    "log"
    "net/http"
    "net/url"
    "os"
    "reflect"
    "strings"
    "time"
)

type SearchResults struct {
    ready   bool
    Query   string
    Results []Result
}

type Result struct {
    Name, Description, URL string
}

func (sr *SearchResults) UnmarshalJSON(bs []byte) error {
    array := []interface{}{}
    if err := json.Unmarshal(bs, &array); err != nil {
        return err
    }
    sr.Query = array[0].(string)
    for i := range array[1].([]interface{}) {
        sr.Results = append(sr.Results, Result{
            array[1].([]interface{})[i].(string),
            array[2].([]interface{})[i].(string),
            array[3].([]interface{})[i].(string),
        })
    }
    return nil
}

func wikipediaAPI(request string) (answer []string) {

    //Создаем срез на 3 элемента
    s := make([]string, 3)

    //Отправляем запрос
    if response, err := http.Get(request); err != nil {
        s[0] = "Wikipedia is not respond"
    } else {
        defer response.Body.Close()

        //Считываем ответ
        contents, err := ioutil.ReadAll(response.Body)
        if err != nil {
            log.Fatal(err)
        }

        //Отправляем данные в структуру
        sr := &SearchResults{}
        if err = json.Unmarshal([]byte(contents), sr); err != nil {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проверяем не пустая ли наша структура
        if !sr.ready {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проходим через нашу структуру и отправляем данные в срез с ответом
        for i := range sr.Results {
            s[i] = sr.Results[i].URL
        }
    }

    return s
}

//Конвертируем запрос для использование в качестве части URL
func urlEncoded(str string) (string, error) {
    u, err := url.Parse(str)
    if err != nil {
        return "", err
    }
    return u.String(), nil
}

var host = os.Getenv("HOST")
var port = os.Getenv("PORT")
var user = os.Getenv("USER")
var password = os.Getenv("PASSWORD")
var dbname = os.Getenv("DBNAME")
var sslmode = os.Getenv("SSLMODE")

var dbInfo = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbname, sslmode)

//Собираем данные полученные ботом
func collectData(username string, chatid int64, message string, answer []string) error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Конвертируем срез с ответом в строку
    answ := strings.Join(answer, ", ")

    //Создаем SQL запрос
    data := `INSERT INTO users(username, chat_id, message, answer) VALUES($1, $2, $3, $4);`

    //Выполняем наш SQL запрос
    if _, err = db.Exec(data, `@`+username, chatid, message, answ); err != nil {
        return err
    }

    return nil
}

//Создаем таблицу users в БД при подключении к ней
func createTable() error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Создаем таблицу users
    if _, err = db.Exec(`CREATE TABLE users(ID SERIAL PRIMARY KEY, TIMESTAMP TIMESTAMP DEFAULT CURRENT_TIMESTAMP, USERNAME TEXT, CHAT_ID INT, MESSAGE TEXT, ANSWER TEXT);`); err != nil {
        return err
    }

    return nil
}

func getNumberOfUsers() (int64, error) {

    var count int64

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return 0, err
    }
    defer db.Close()

    //Отправляем запрос в БД для подсчета числа уникальных пользователей
    row := db.QueryRow("SELECT COUNT(DISTINCT username) FROM users;")
    err = row.Scan(&count)
    if err != nil {
        return 0, err
    }

    return count, nil
}

func telegramBot() {

    //Создаем бота
    bot, err := tgbotapi.NewBotAPI(os.Getenv("TOKEN"))
    if err != nil {
        panic(err)
    }

    //Устанавливаем время обновления
    u := tgbotapi.NewUpdate(0)
    u.Timeout = 60

    //Получаем обновления от бота 
    updates, err := bot.GetUpdatesChan(u)

    for update := range updates {
        if update.Message == nil {
            continue
        }

        //Проверяем что от пользователья пришло именно текстовое сообщение
        if reflect.TypeOf(update.Message.Text).Kind() == reflect.String && update.Message.Text != "" {

            switch update.Message.Text {
            case "/start":

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Hi, i'm a wikipedia bot, i can search information in a wikipedia, send me something what you want find in Wikipedia.")
                bot.Send(msg)

            case "/number_of_users":

                if os.Getenv("DB_SWITCH") == "on" {

                    //Присваиваем количество пользоватьелей использовавших бота в num переменную
                    num, err := getNumberOfUsers()
                    if err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error.")
                        bot.Send(msg)
                    }

                    //Создаем строку которая содержит колличество пользователей использовавших бота
                    ans := fmt.Sprintf("%d peoples used me for search information in Wikipedia", num)

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, ans)
                    bot.Send(msg)
                } else {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database not connected, so i can't say you how many peoples used me.")
                    bot.Send(msg)
                }
            default:

                //Устанавливаем язык для поиска в википедии
                language := os.Getenv("LANGUAGE")

                //Создаем url для поиска
                ms, _ := urlEncoded(update.Message.Text)

                url := ms
                request := "https://" + language + ".wikipedia.org/w/api.php?action=opensearch&search=" + url + "&limit=3&origin=*&format=json"

                //Присваем данные среза с ответом в переменную message
                message := wikipediaAPI(request)

                if os.Getenv("DB_SWITCH") == "on" {

                    //Отправляем username, chat_id, message, answer в БД
                    if err := collectData(update.Message.Chat.UserName, update.Message.Chat.ID, update.Message.Text, message); err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error, but bot still working.")
                        bot.Send(msg)
                    }
                }

                //Проходим через срез и отправляем каждый элемент пользователю
                for _, val := range message {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, val)
                    bot.Send(msg)
                }
            }
        } else {

            //Отправлем сообщение
            msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Use the words for search.")
            bot.Send(msg)
        }
    }
}

func main() {

    time.Sleep(1 * time.Minute)

    //Создаем таблицу
    if os.Getenv("CREATE_TABLE") == "yes" {

        if os.Getenv("DB_SWITCH") == "on" {

            if err := createTable(); err != nil {

                panic(err)
            }
        }
    }

    time.Sleep(1 * time.Minute)

    //Вызываем бота
    telegramBot()
}

Давайте разнесем код по отдельным файлам чтобы он был более читабельным.


wikipedia_api.go
package main

import (
    "encoding/json"
    "io/ioutil"
    "log"
    "net/http"
)

type SearchResults struct {
    ready   bool
    Query   string
    Results []Result
}

type Result struct {
    Name, Description, URL string
}

func (sr *SearchResults) UnmarshalJSON(bs []byte) error {
    array := []interface{}{}
    if err := json.Unmarshal(bs, &array); err != nil {
        return err
    }
    sr.Query = array[0].(string)
    for i := range array[1].([]interface{}) {
        sr.Results = append(sr.Results, Result{
            array[1].([]interface{})[i].(string),
            array[2].([]interface{})[i].(string),
            array[3].([]interface{})[i].(string),
        })
    }
    return nil
}

func wikipediaAPI(request string) (answer []string) {

    //Создаем срез на 3 элемента
    s := make([]string, 3)

    //Отправляем запрос
    if response, err := http.Get(request); err != nil {
        s[0] = "Wikipedia is not respond"
    } else {
        defer response.Body.Close()

        //Считываем ответ
        contents, err := ioutil.ReadAll(response.Body)
        if err != nil {
            log.Fatal(err)
        }

        //Отправляем данные в структуру
        sr := &SearchResults{}
        if err = json.Unmarshal([]byte(contents), sr); err != nil {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проверяем не пустая ли наша структура
        if !sr.ready {
            s[0] = "Something going wrong, try to change your question"
        }

        //Проходим через нашу структуру и отправляем данные в срез с ответом
        for i := range sr.Results {
            s[i] = sr.Results[i].URL
        }
    }

    return s
}

url_encoder.go
package main

import (
    "net/url"
)

//Конвертируем запрос для использование в качестве части URL
func urlEncoded(str string) (string, error) {
    u, err := url.Parse(str)
    if err != nil {
        return "", err
    }
    return u.String(), nil
}

db.go
package main

import (
    "database/sql"
    "fmt"
    _ "github.com/lib/pq"
    "os"
    "strings"
)

var host = os.Getenv("HOST")
var port = os.Getenv("PORT")
var user = os.Getenv("USER")
var password = os.Getenv("PASSWORD")
var dbname = os.Getenv("DBNAME")
var sslmode = os.Getenv("SSLMODE")

var dbInfo = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", host, port, user, password, dbname, sslmode)

//Собираем данные полученные ботом
func collectData(username string, chatid int64, message string, answer []string) error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Конвертируем срез с ответом в строку
    answ := strings.Join(answer, ", ")

    //Создаем SQL запрос
    data := `INSERT INTO users(username, chat_id, message, answer) VALUES($1, $2, $3, $4);`

    //Выполняем наш SQL запрос
    if _, err = db.Exec(data, `@`+username, chatid, message, answ); err != nil {
        return err
    }

    return nil
}

//Создаем таблицу users в БД при подключении к ней
func createTable() error {

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return err
    }
    defer db.Close()

    //Создаем таблицу users
    if _, err = db.Exec(`CREATE TABLE users(ID SERIAL PRIMARY KEY, TIMESTAMP TIMESTAMP DEFAULT CURRENT_TIMESTAMP, USERNAME TEXT, CHAT_ID INT, MESSAGE TEXT, ANSWER TEXT);`); err != nil {
        return err
    }

    return nil
}

func getNumberOfUsers() (int64, error) {

    var count int64

    //Подключаемся к БД
    db, err := sql.Open("postgres", dbInfo)
    if err != nil {
        return 0, err
    }
    defer db.Close()

    //Отправляем запрос в БД для подсчета числа уникальных пользователей
    row := db.QueryRow("SELECT COUNT(DISTINCT username) FROM users;")
    err = row.Scan(&count)
    if err != nil {
        return 0, err
    }

    return count, nil
}

telegrambot.go
package main

import (
    "fmt"
    "github.com/Syfaro/telegram-bot-api"
    "os"
    "reflect"
    "time"
)

func telegramBot() {

    //Создаем бота
    bot, err := tgbotapi.NewBotAPI(os.Getenv("TOKEN"))
    if err != nil {
        panic(err)
    }

    //Устанавливаем время обновления
    u := tgbotapi.NewUpdate(0)
    u.Timeout = 60

    //Получаем обновления от бота 
    updates, err := bot.GetUpdatesChan(u)

    for update := range updates {
        if update.Message == nil {
            continue
        }

        //Проверяем что от пользователья пришло именно текстовое сообщение
        if reflect.TypeOf(update.Message.Text).Kind() == reflect.String && update.Message.Text != "" {

            switch update.Message.Text {
            case "/start":

                //Отправлем сообщение
                msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Hi, i'm a wikipedia bot, i can search information in a wikipedia, send me something what you want find in Wikipedia.")
                bot.Send(msg)

            case "/number_of_users":

                if os.Getenv("DB_SWITCH") == "on" {

                    //Присваиваем количество пользоватьелей использовавших бота в num переменную
                    num, err := getNumberOfUsers()
                    if err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error.")
                        bot.Send(msg)
                    }

                    //Создаем строку которая содержит колличество пользователей использовавших бота
                    ans := fmt.Sprintf("%d peoples used me for search information in Wikipedia", num)

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, ans)
                    bot.Send(msg)
                } else {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database not connected, so i can't say you how many peoples used me.")
                    bot.Send(msg)
                }
            default:

                //Устанавливаем язык для поиска в википедии
                language := os.Getenv("LANGUAGE")

                //Создаем url для поиска
                ms, _ := urlEncoded(update.Message.Text)

                url := ms
                request := "https://" + language + ".wikipedia.org/w/api.php?action=opensearch&search=" + url + "&limit=3&origin=*&format=json"

                //Присваем данные среза с ответом в переменную message
                message := wikipediaAPI(request)

                if os.Getenv("DB_SWITCH") == "on" {

                    //Отправляем username, chat_id, message, answer в БД
                    if err := collectData(update.Message.Chat.UserName, update.Message.Chat.ID, update.Message.Text, message); err != nil {

                        //Отправлем сообщение
                        msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Database error, but bot still working.")
                        bot.Send(msg)
                    }
                }

                //Проходим через срез и отправляем каждый элемент пользователю
                for _, val := range message {

                    //Отправлем сообщение
                    msg := tgbotapi.NewMessage(update.Message.Chat.ID, val)
                    bot.Send(msg)
                }
            }
        } else {

            //Отправлем сообщение
            msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Use the words for search.")
            bot.Send(msg)
        }
    }
}

func main() {

    time.Sleep(1 * time.Minute)

    //Создаем таблицу
    if os.Getenv("CREATE_TABLE") == "yes" {

        if os.Getenv("DB_SWITCH") == "on" {

            if err := createTable(); err != nil {

                panic(err)
            }
        }
    }

    time.Sleep(1 * time.Minute)

    //Вызываем бота
    telegramBot()
}

Теперь когда у нас есть наш бот напишем Dockerfile для него.


FROM alpine
ENV LANGUAGE="en"
COPY /code/code .
RUN apk add --no-cache ca-certificates &&    chmod +x code
EXPOSE 80/tcp
CMD [ "./code" ]

А так же docker-compose.yml для того чтобы нам не приходилось руками поднимать БД в случае если БД будет у нас на той же машине что и бот.


version: '3.5'

services:

  db:
    image: postgres
    environment:
      POSTGRES_PASSWORD: test

  bot:
    image: trigun117/wikipedia-telegram-bot
    environment:
      CREATE_TABLE: "yes"
      DB_SWITCH: "on"
      TOKEN: 
      HOST: db
      PORT: 5432
      USER: postgres
      PASSWORD: test
      DBNAME: postgres
      SSLMODE: disable

Разворачиваем бота


Развернем бота на локальном ПК, Amazon Web Service: EC2 и БД RDS, Google Cloud Platform: GCE и БД SQL.


Docker-Compose


Вставляем в наш docker-compose.yml токен для бота.



И запускаем docker-compose.



Ждем примерно 3 минуты и наш бот готов к работе.


Google Cloud Platform


Переходим в раздел SQL в GCP и создаем БД.



В разрешенных сетях укажем что трафик может идти с любого источника.



Теперь у нас есть БД и IP для подключения к ней.



Далее переходим на вкладку Compute Engine и создаем инстанс на котором будет развернут бот.



Так как у Google Compute Engine есть такая штука как Автоматизация, которая позволяет нам выполнить какие либо команды на инстансе после его создания без нашего участия то воспользуемся ею для комфортной развертки.


На вкладке Автоматизация укажем команды для установки docker и запуска контейнера с нашим ботом.



curl -s https://raw.githubusercontent.com/trigun117/TelegramBot-Go/master/installdocker.sh | bash && sudo docker run -e CREATE_TABLE=yes -e DB_SWITCH=on -e TOKEN=497014514:AAGKayv3tUxNrFWCmqtEIxKAS1TMvhXGdLE -e HOST=35.189.116.57 -e PORT=5432 -e USER=postgres -e PASSWORD=postgres -e DBNAME=postgres -e SSLMODE=disable -d trigun117/wikipedia-telegram-bot

Ждем около 5 минут и наш бот готов к использованию.


Amazon Web Service


Создаем БД.





Ждем пока создастся БД и переходим к ней для получения endpoint и дальнейшего подключения к БД с помощью него.



Далее переходим на вкладку EC2, Security Groups и выбираем группу в которой находится наша БД.



Внизу на вкладке Inbound жмем Edit и изменяем наше правило так чтобы входящие соединения шли с любого источника



и жмем Save.


На вкладке Instances, создаем новый инстанс и проходим все шаги создания. После создания подключаемся удобным для вас способом и выполняем комманду которую мы уже видели ранее.



Ждем около 3 минут и наш бот готов к работе.


curl -s https://raw.githubusercontent.com/trigun117/TelegramBot-Go/master/installdocker.sh | bash && sudo docker run -e CREATE_TABLE=yes -e DB_SWITCH=on -e TOKEN=497014514:AAGKayv3tUxNrFWCmqtEIxKAS1TMvhXGdLE -e HOST=test-habr.c2ugekubflbp.eu-west-2.rds.amazonaws.com -e PORT=5432 -e USER=postgres -e PASSWORD=postgres -e DBNAME=postgres -e SSLMODE=disable -d trigun117/wikipedia-telegram-bot

Заключение


Подводя итог, хочется сказать, что написание бота на Go было довольно-таки интересным и познавательным опытом. А также дало возможность подтянуть старые и получить новые знания.


Репозиторий на GitHub


Wikipedia Search RU


Благодарю за уделенное внимание и приглашаю всех поделится своим мнением и мыслями в комментариях.

Комментарии (19)


  1. Bidofan
    13.03.2018 12:06

    Thanks, very interesting


  1. grozaman
    13.03.2018 12:20

    Тоже пишу своих ботов на Golang. Очень приятные получаются, особенно после JavaScript (Node.js), правда предпочитаю NoSQL базы.

    Не знаю интересно ли кому-то тут почитать про какие-то связки с MTProto?


    1. OstaninKI
      13.03.2018 12:36

      Конечно, пишите!


    1. AlexComin
      13.03.2018 16:24

      По мне на JS(Node.js) делается куда проще и по приятнее. Но это дело вкуса.


      1. grozaman
        13.03.2018 16:37

        Писать может быстрее и может проще. Но на Go приложения получаются с ощущением «железобетонной» надежности, а главное намного быстрей.


        1. gudvinr
          13.03.2018 19:45

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


          В зависимости от задачи это может быть и не так.


          1. grozaman
            13.03.2018 20:03

            Хорошо :)
            В случае с ботами я работал с генерацией графики, там прирост был очень большой. Буквально в два-три раза. В случае с Node использовал Fabric.js, в случае с Go использовал библиотеку gg.

            Остальное мне показалось субъективно быстрей, но какие-то замеры не делал.


  1. Biblusha
    13.03.2018 14:19

    if response, err := http.Get(request); err != nil {
            log.Fatal(err)
    }
    

    Еесли http.Get вернёт ошибку, сервис закончит работу.


    1. trigun117 Автор
      13.03.2018 17:11

      Спасибо, исправил


  1. KirEv
    13.03.2018 15:08

    а почему sql.Open не вынести из функций?


    1. trigun117 Автор
      13.03.2018 18:20

      Потому что вне функций можно использовать только (package, import, var, const, type, func), а мне нужно вдруг чего ловить err.

      db, err := sql.Open("postgres", dbInfo)
      if err != nil {
      	return err
      }
      defer db.Close()

      Как вариант можно создать функцию openDB и в нее засунуть код который подключается к БД.


  1. Laney1
    13.03.2018 15:22

    в функции wikipediaAPI — зачем читать тело запроса в строку, когда можно сразу сделать Decode в нужную структуру? Я конечно понимаю, что есть статьи "никогда не пользуйтесь json.Decode, это небезопасно", но ИМХО это дуть на воду.


    Еще не нравится куча кастов к типу interface{} в функции UnmarshalJSON — это очень медленно


  1. HoberMallow
    13.03.2018 16:24

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


    1. trigun117 Автор
      13.03.2018 17:36

      Вы правы, вебхук тратит намного меньше ресурсов, так же он намного надежнее, ибо при обновлениях вам их присылают сервера телеграма т.е. вашему приложению не нужно открывать соединение с серверами каждые n секунды чтобы получить новые данные. Проще говоря, используя long polling вашему приложению самому нужно запрашивать обновления у API, а используя вебхуки — сервера телеграма сами будут отправлять на ваш сервер каждое обновление. Мною был выбран long polling ибо он проще, однако для боевого бота который шел бы в продакшен однозначно вебхук.


    1. grozaman
      13.03.2018 20:09

      Не во всех случаях Телеграм может достучаться до вашего сервера, например если он за NAT. Long-polling более универсальный и работает даже в закрытых сетях. Но как уже отметил trigun117, на продакшене лучше использовать в любом случае вебхуки.


  1. scab
    14.03.2018 08:49

    Мне одному кажется что токен бота надо бы замазывать в скринах?


    1. Mr_Tabrest_3115
      14.03.2018 10:32

      Бот ведь создан для примера, никакого вреда автору не принести использованием токена. Тем более он легко меняется, буквально одной командой, направленной к botfather


      1. scab
        15.03.2018 15:47

        О таких вещах легко забыть, учитывая что это исключительно ЧФ. Да и вообще приучить себя не выкладывать такие вещи в публичный доступ, как по мне, хорошая привычка, а то зайдешь на clip2net в публичную галерею и ужасаешься =)


  1. astec
    14.03.2018 11:58

    Мой фрэймфорк на Go для написания ботов под разные мессенджеры с единой логикой — https://github.com/strongo/bots-framework — может кому пригодится.


    В основном заточен на Телеграм и работает на Google AppEngine.