Программки 💻
August 20, 2022

✈️ Telegram бот на golang (дополнительная часть)

С момента прошлой статьи прошло некоторое время, актуальный код бота доступен на гитхабе. Рекомендую начать с основной статьи. Функции и структуры для отправки сообщений/стикеров и пр. я перенёс в файл sendSmth.go.

Итак, начнём

Практически все команды для бота я храню в пакете mods. Начнём с первых строчек после импорта и структур.

// Функция генерации псевдослучайных чисел
func Random(n int) int {
    rand.Seed(time.Now().Unix())
    return rand.Intn(n)
}

Первой идёт функция генерации псевдослучайных чисел. Она выдаёт псевдослучайный int от 0 до n. Сначала бот привязывает случайность к текущему времени в формате Unix, затем с помощью этой информации генерирует нужный int.

// Функция броска n-гранного кубика
func Dice(msg string) string {

	// Считывание числа граней
	num, err := strconv.Atoi(msg[2:])

	// Проверки на невозможное количество граней
	if err != nil {
		return "Это вообще кубик?🤨"
	}
	if num < 1 {
		return "как я по твоему кину такой кубик? Через четвёртое пространство?🤨"
	}

	// Проверка на d10 (единственный кубик, который имеет грань со значением "0")
	if num == 10 {
		return strconv.Itoa(Random(10))
	}

	// Бросок
	return strconv.Itoa(1 + Random(num))

} 

Далее находится функция для броска n-гранного кубика. Бот считывает количество граней из сообщения, а после этого проверяет корректность ввода. Далее идёт проверка на d10 и сам бросок.

Кто не играл в DnD и прочие настолки, поясняю:
d10 - единственный дайс, с цифрой 0 на грани. Бросая d10, игрок получает случайное число от 0 до 9 (у других дайсов диапазон чисел начинается с 1)
// Функция генерации случайных ответов
func Ball8(botUrl string, update Update) {

	// Массив ответов
	answers := [10]string{
		"Да, конечно!",
		"100%",
		"Да.",
		"100000000%",
		"Точно да!",
		"Нет, пфф",
		"Нееееееееееет",
		"Точно нет!",
		"Нет, нет, нет",
		"Нет.",
	}

	// Выбор случайного ответа
	SendMsg(botUrl, update, answers[Random(len(answers))])

}

Ну, тут всё просто: я оставил отсылку на бота на руби. Массив из 10ти ответов, 5 положительных, 5 отрицательных и выбор случайного.

// Функция броска монетки
func Coin(botUrl string, update Update) {
	if Random(2) == 0 {
		SendMsg(botUrl, update, "Орёл")
	} else {
		SendMsg(botUrl, update, "Решка")
	}
}

Простейшая функция на бросок монетки.

На этом все скучные команды заканчиваются, дальше будет интереснее.

SendFromReddit()

// Функция отправки случайного поста с Reddit
func SendFromReddit(botUrl string, update Update, board string) error {

	// Отправка реквеста
	url := "https://meme-api.herokuapp.com/gimme/" + board
	req, _ := http.NewRequest("GET", url, nil)
	res, err := http.DefaultClient.Do(req)

	// Проверка на ошибку
	if err != nil {
		fmt.Println("Meme API error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return err
	}

	// Запись респонса
	defer res.Body.Close()
	body, _ := ioutil.ReadAll(res.Body)
	var response = new(RedditResponse)
	json.Unmarshal(body, &response)

	// Проверка на запрещёнку
	if response.Nsfw || response.Spoiler {
		response.Url = "https://belikebill.ga/billgen-API.php?default=1"
		response.Title = "Картинка оказалась со спойлером или nsfw-контентом, поэтому я заменил её на это"
	}

	// Формирование сообщения
	botImageMessage := SendPhoto{
		ChatId:   update.Message.Chat.ChatId,
		PhotoUrl: response.Url,
		Caption:  response.Title,
	}

	// Отправка результата
	SendPict(botUrl, update, botImageMessage)
	return nil

}

SendFromReddit() отвечает сразу за несколько команд (/meme /parrot /cat). Она с помощью meme-api получает случайный пост с Reddit с нескольких досок.

	// Отправка реквеста
	url := "https://meme-api.herokuapp.com/gimme/" + board
	req, _ := http.NewRequest("GET", url, nil)
	res, err := http.DefaultClient.Do(req)

Сначала бот отправляет реквест к meme-api.

	// Проверка на ошибку
	if err != nil {
		fmt.Println("Meme API error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return err
	}

Далее находится проверка на ошибку.

Функцию SendErrorMessage() я разберу позже.
	// Запись респонса
	defer res.Body.Close()
	body, _ := ioutil.ReadAll(res.Body)
	var response = new(RedditResponse)
	json.Unmarshal(body, &response)

Запись респонса. Создаётся структура типа RedditResponse, после чего бот записывает туда информацию из полученного json.

type RedditResponse struct {
	Title   string `json:"title"`
	Url     string `json:"url"`
	Nsfw    bool   `json:"nsfw"`
	Spoiler bool   `json:"spoiler"`
}

У этой структуры есть поля Nsfw и Spoiler. Я решил воспользоваться этим и написать проверку на деликатный контент.

	// Проверка на запрещёнку
	if response.Nsfw || response.Spoiler {
	response.Url = "https://belikebill.ga/billgen-API.php?default=1"
	response.Title = "Картинка оказалась со спойлером или nsfw-контентом, поэтому я заменил её на это"
}

Если от meme-api бот получит деликатный контент, вместо него он отправит случайную картинку с Биллом. Belikebill.ga генерирует случайную картинку по типу такой:

	// Формирование сообщения
	botImageMessage := SendPhoto{
		ChatId:   update.Message.Chat.ChatId,
		PhotoUrl: response.Url,
		Caption:  response.Title,
	}

	// Отправка результата
	SendPict(botUrl, update, botImageMessage)
	return nil

Далее бот сформирует сообщение и отправит его.

SendCryptoData()

// Функция вывода курса криптовалюты SHIB
func SendCryptoData(botUrl string, update Update) {

	// Отправка реквеста
	req, _ := http.NewRequest("GET", "https://api2.binance.com/api/v3/ticker/24hr?symbol=SHIBBUSD", nil)
	res, err := http.DefaultClient.Do(req)

	// Проверка на ошибку
	if err != nil {
		fmt.Println("Binance API error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return
	}

	// Запись респонса
	defer res.Body.Close()
	body, _ := ioutil.ReadAll(res.Body)
	var response = new(CryptoResponse)
	json.Unmarshal(body, &response)

	// Формирование и отправка результата
	if response.ChangePercent[0] == '-' {
		SendMsg(botUrl, update, "За сегодняшний день "+response.Symbol+" упал на "+response.ChangePercent[1:]+"%\n"+
			"до отметки в "+response.LastPrice+"$\n\n")
		SendRandomShibaSticker(botUrl, update, true)
	} else {
		SendMsg(botUrl, update, "За сегодняшний день "+response.Symbol+" вырос на "+response.ChangePercent+"%\n"+
			"до отметки в "+response.LastPrice+"$\n\n")
		SendRandomShibaSticker(botUrl, update, false)
	}

}

Точно также с помощью GET бот получает json с респонсом, проверяет всё ли хорошо и выводит результат. Результат зависит оттого, укрепился ли курс или нет. Тут используется функция SendRandomShibaSticker(), её я рассмотрю позже.

SendErrorMessage()

// Функция отправки сообщений об ошибках
func SendErrorMessage(botUrl string, update Update, errorCode int) {

	// Генерация текста ошибки по коду
	var result string
	switch errorCode {
	case 1:
		result = "Ошибка работы API"
	case 2:
		result = "Ошибка работы json.Marshal()"
	case 3:
		result = "Ошибка работы метода SendSticker"
	case 4:
		result = "Ошибка работы метода SendPhoto"
	case 5:
		result = "Ошибка работы метода SendMessage"
	case 6:
		result = "Ошибка работы stickers.json"
	default:
		result = "Неизвестная ошибка"
	}

	// Анонимное оповещение меня
	var updateDanya Update
	updateDanya.Message.Chat.ChatId = viper.GetInt("DanyaChatId")
	SendMsg(botUrl, updateDanya, "Дань, тут у одного из пользователей "+result+", надеюсь он скоро тебе о ней напишет.")

	// Вывод ошибки пользователю с просьбой связаться со мной для её устранения
	result += ", пожалуйста свяжитесь с моим создателем для устранения проблемы \n\nhttps://vk.com/hud0shnik\nhttps://vk.com/hud0shnik\nhttps://vk.com/hud0shnik"
	SendMsg(botUrl, update, result)
}

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

SendInfo()

// Функция вывода информации о пользователе GitHub
func SendInfo(botUrl string, update Update, parameters string) {

	// Отправка запроса моему API
	resp, err := http.Get("https://hud0shnikgitapi.herokuapp.com/user/" + parameters)

	// Проверка на ошибку
	if err != nil {
		fmt.Println("GithubGoAPI error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return
	}

	// Запись респонса
	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)
	var user = new(InfoResponse)
	json.Unmarshal(body, &user)

	// Отправка данных пользователю
	SendPict(botUrl, update, SendPhoto{
		PhotoUrl: user.Avatar,
		ChatId:   update.Message.Chat.ChatId,
		Caption: "Информация о " + user.Username + ":\n" +
			"Имя " + user.Name + "\n" +
			"Поставленных звезд " + user.Stars + " ⭐\n" +
			"Подписчиков " + user.Followers + " 🤩\n" +
			"Подписок " + user.Following + " 🕵️\n" +
			"Репозиториев " + user.Repositories + " 📘\n" +
			"Пакетов " + user.Packages + " 📦\n" +
			"Контрибуций за год " + user.Contributions + " 🟩\n" +
			"Ссылка на аватар:\n " + user.Avatar,
	})

}

Наконец-то я дошёл до своих апих. Итак, снова http.Get, проверка и запись респонса. Далее находится вывод всей информации в весьма приятном стиле.

SendCommits()

// Функция вывода количества коммитов пользователя GitHub
func SendCommits(botUrl string, update Update, parameters string) {

	// Индекс пробела и дата
	i, date := 0, ""

	// Поиск конца юзернейма и начала даты
	for ; i < len(parameters); i++ {
		if parameters[i] == ' ' {
			break
		}
	}

	// Если дата задана, записывает её
	if i != len(parameters) {
		date = parameters[i+1:]
	}

	// Отправка запроса моему API
	resp, err := http.Get("https://hud0shnikgitapi.herokuapp.com/commits/" + parameters[:i] + "/" + date)

	// Проверка на ошибку
	if err != nil {
		fmt.Println("GithubGoAPI error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return
	}

	// Запись респонса
	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)
	var user = new(CommitsResponse)
	json.Unmarshal(body, &user)

	// Если поле пустое, меняет date на "сегодня"
	if date == "" {
		date = "сегодня"
	}

	// Вывод данных пользователю
	switch user.Color {
	case 1:
		SendMsg(botUrl, update, "Коммитов за "+date+" "+strconv.Itoa(user.Commits))
		SendStck(botUrl, update, "CAACAgIAAxkBAAIYwmG11bAfndI1wciswTEVJUEdgB2jAAI5AAOtZbwUdHz8lasybOojBA")
	case 2:
		SendMsg(botUrl, update, "Коммитов за "+date+" "+strconv.Itoa(user.Commits)+", неплохо!")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIXWmGyDE1aVXGUY6lcjKxx9bOn0JA1AAJOAAOtZbwUIWzOXysr2zwjBA")
	case 3:
		SendMsg(botUrl, update, "Коммитов за "+date+" "+strconv.Itoa(user.Commits)+", отлично!!")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIYymG11mMdODUQUZGsQO97V9O0ZLJCAAJeAAOtZbwUvL_TIkzK-MsjBA")
	case 4:
		SendMsg(botUrl, update, "Коммитов за "+date+" "+strconv.Itoa(user.Commits)+", прекрасно!!!")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIXXGGyDFClr69PKZXJo9dlYMbyilXLAAI1AAOtZbwU9aVxXMUw5eAjBA")
	default:
		SendMsg(botUrl, update, "Коммитов нет")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIYG2GzRVNm_d_mVDIOaiLXkGukArlTAAJDAAOtZbwU_-iXZG7hfLsjBA")
	}

}

Так, ну тут поинтереснее. Команда для получения количества коммитов за конкретную дату имеет следующий синтаксис

/commits username date

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

Параметр color - цвет ячейки (от серого до ярко-зелёного)

CheckIPAddres()

// Функция нахождения местоположения по IP адресу
func CheckIPAdress(botUrl string, update Update, IP string) {

	// Проверка на localhost
	if IP == "127.0.0.1" {
		SendMsg(botUrl, update, "Айпишник локалхоста, ага")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIYLGGzR7310Hqf8K2sljgcQF8kgOpYAAJTAAOtZbwUo9c59oswVBQjBA")
		return
	}

	// Проверка корректности ввода
	ipArray := strings.Split(IP, ".")
	if len(ipArray) != 4 {
		SendMsg(botUrl, update, "Не могу обработать этот IP")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIY4mG13Vr0CzGwyXA1eL3esZVCWYFhAAJIAAOtZbwUgHOKzxQtAAHcIwQ")
		return
	}
	for _, ipPart := range ipArray {
		num, err := strconv.Atoi(ipPart)
		if err != nil || num < 0 || num > 255 || (ipPart != fmt.Sprint(num)) {
			SendMsg(botUrl, update, "Неправильно набран IP")
			SendStck(botUrl, update, "CAACAgIAAxkBAAIY4GG13SepKZJisWVrMrzQ9JyRpWFrAAJKAAOtZbwUiXsNXgiPepIjBA")
			return
		}
	}

	// Отправка запроса
	SendMsg(botUrl, update, "Ищу...")
	url := "https://api.ip2country.info/ip?" + IP
	req, _ := http.NewRequest("GET", url, nil)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("IP2Country API error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return
	}

	// Запись респонса
	defer res.Body.Close()
	body, _ := ioutil.ReadAll(res.Body)
	var response = new(IP2CountryResponse)
	json.Unmarshal(body, &response)

	// Вывод сообщения для спец айпишников
	if response.CountryName == "" {
		SendMsg(botUrl, update, "Не могу найти этот IP")
		SendStck(botUrl, update, "CAACAgIAAxkBAAIY4mG13Vr0CzGwyXA1eL3esZVCWYFhAAJIAAOtZbwUgHOKzxQtAAHcIwQ")
		return
	}

	// Вывод результатов поиска
	SendMsg(botUrl, update, "Нашёл! Страна происхождения - "+response.CountryName+" "+response.CountryEmoji+
		"\n\nМы не храним IP, которые просят проверить пользователи, весь код можно найти на гитхабе.")
	SendStck(botUrl, update, "CAACAgIAAxkBAAIXqmGyGtvN_JHUQVDXspAX5jP3BvU9AAI5AAOtZbwUdHz8lasybOojBA")

}

Сначала бот очень тщательно проверяет вводные данные, после чего отправляет реквест к api2country, проверяет ответ апихи и выводит результат.

SendOsuInfo()

// Функция вывода информации о пользователе Osu!
func SendOsuInfo(botUrl string, update Update, parameters string) {

	// Отправка запроса моему API
	resp, err := http.Get("https://osustatsapi.herokuapp.com/user/" + parameters)

	// Проверка на ошибку
	if err != nil {
		fmt.Println("OsuStatsAPI error: ", err)
		SendErrorMessage(botUrl, update, 1)
		return
	}

	// Запись респонса
	defer resp.Body.Close()
	body, _ := ioutil.ReadAll(resp.Body)
	var user = new(OsuUserInfo)
	json.Unmarshal(body, &user)

	// Формирование текста респонса
	responseText := "Информация о " + user.Username + "\n"

	if user.Names != "" {
		responseText += "Aka " + user.Names + "\n"
	}

	responseText += "Код страны " + user.CountryCode + "\n" +
		"Рейтинг в мире " + user.GlobalRank + "\n" +
		"Рейтинг в стране " + user.CountryRank + "\n" +
		"Точность попаданий " + user.Accuracy + "%\n" +
		"PP " + user.PP + "\n" +
		"-------карты---------\n" +
		"SSH: " + user.SSH + "\n" +
		"SH: " + user.SH + "\n" +
		"SS: " + user.SS + "\n" +
		"S: " + user.S + "\n" +
		"A: " + user.A + "\n" +
		"---------------------------\n" +
		"Рейтинговые очки " + user.RankedScore + "\n" +
		"Количество игр " + user.PlayCount + "\n" +
		"Всего очков " + user.TotalScore + "\n" +
		"Всего попаданий " + user.TotalHits + "\n" +
		"Максимальное комбо " + user.MaximumCombo + "\n" +
		"Реплеев просмотрено другими " + user.Replays + "\n" +
		"Уровень " + user.Level + "\n" +
		"---------------------------\n" +
		"Время в игре " + user.PlayTime + "\n" +
		"Уровень подписки " + user.SupportLvl + "\n"

	if user.IsOnline == "true" {
		responseText += "Сейчас онлайн \n"
	} else {
		responseText += "Сейчас не в сети \n"
	}

	if user.IsActive == "true" {
		responseText += "Аккаунт активен \n"
	} else {
		responseText += "Аккаунт не активен \n"
	}

	if user.IsDeleted == "true" {
		responseText += "Аккаунт удалён \n"
	}

	if user.IsBot == "true" {
		responseText += "Это аккаунт бота \n"
	}

	if user.IsNat == "true" {
		responseText += "Это аккаунт члена команды оценки номинаций \n"
	}

	if user.IsModerator == "true" {
		responseText += "Это аккаунт модератора \n"
	}

	if user.ProfileColor != "null" {
		responseText += "Цвет профиля" + user.ProfileColor + "\n"
	}

	// Отправка данных пользователю
	SendPict(botUrl, update, SendPhoto{
		PhotoUrl: user.AvatarUrl,
		ChatId:   update.Message.Chat.ChatId,
		Caption:  responseText,
	})

}

Ещё одна моя апиха, функция же выводит статистику игрока в "OSU!". Здесь я очень детально подошёл к формированию вывода пользователю (не просто же вывалить все данные) так как от этой апихи количество информации запредельное (и это ещё не конец, планирую добавить bbm). Так как вывод типа "Цвет профиля: null" меня не устраивает, я добавил проверки на полученные значения ради красивого отображения статистики.

Check()

// Функция проверки всех команд
func Check(botUrl string, update Update) {

	// Проверка на мой id
	if update.Message.Chat.ChatId == viper.GetInt("DanyaChatId") {

		// Временная метка начала проверки
		start := time.Now()

		// Вызов всех команд
		SendCryptoData(botUrl, update)
		SendFromReddit(botUrl, update, "")
		Coin(botUrl, update)
		Help(botUrl, update)
		SendCommits(botUrl, update, "hud0shnik")
		SendMsg(botUrl, update, Dice("/d20"))
		Ball8(botUrl, update)
		SendRandomSticker(botUrl, update)
		SendFromReddit(botUrl, update, "parrots")

		// Отправка ошибок
		for i := 1; i < 7; i++ {
			SendErrorMessage(botUrl, update, i)
		}

		// Отправка результата
		SendMsg(botUrl, update, "Проверка заняла "+time.Since(start).String())
		return
	}

	// Вывод для других пользователей
	SendMsg(botUrl, update, "Error 403! Beep Boop... Forbidden! Access denied 🤖")

}

Обычная функция проверки функции (извините). Доступна только мне (пользователю она незачем). Исполняет все команды, замеряет время, выводит результат.

Итог

В целом that's all. Сразу предупрежу, что скорее всего код из статьи уже неактуален. Пока писал эту статью нашёл пару моментов, которые можно оптимизировать/улучшить, а переписывать эту простыню каждый раз мне лень (это всё злой Agile). В этом боте я что-то меняю и дописываю уже около года (и не собираюсь прекращать), так что скорее всего будет третья статья на этот счёт (или не будет).