Initial commit

This commit is contained in:
eleksir
2024-09-17 23:58:51 +03:00
commit a9e0095dc9
1569 changed files with 656940 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
package anekdotru
import (
"aleesa-webapp-go/internal/config"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand/v2"
"regexp"
"github.com/carlmjohnson/requests"
)
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
respBody bytes.Buffer
respBytes []byte
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
url = "https://www.anekdot.ru/rss/randomu.html"
)
err := requests.
URL(url).
UserAgent(userAgent).
ToBytesBuffer(&respBody).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
// Time to fix UTF-8, just in case.
respBytes = bytes.ToValidUTF8(respBody.Bytes(), []byte{0xef, 0xbf, 0xbd})
// Cut-off json.
re := regexp.MustCompile(`JSON.parse\('`)
aneks := re.Split(string(respBytes), 2)
if (len(aneks) >= 2) && (aneks[1] != "") {
re = regexp.MustCompile(`'\);`)
a := re.Split(aneks[1], 2)
if (len(a) >= 2) && (a[0] != "") {
anek := []byte(a[0])
re = regexp.MustCompile(`\\"`)
anek = re.ReplaceAll(anek, []byte(`"`))
re = regexp.MustCompile(`\\"`)
anekJSON := re.ReplaceAll(anek, []byte(`"`))
anekJSON = bytes.ToValidUTF8(anekJSON, []byte{0xef, 0xbf, 0xbd})
var anekMap []string
if err := json.Unmarshal(anekJSON, &anekMap); err != nil {
return fmt.Sprintf("RespBytes:%s\n\nAnekJSON: %s", respBytes, string(anekJSON)), err
}
re = regexp.MustCompile(`<br>`)
answer := re.ReplaceAll([]byte(anekMap[0]), []byte("\n"))
return string(answer), nil
}
fmt.Printf("a length: %d\nvalue: %v", len(a), a)
} else {
fmt.Printf("aneks length: %d\nvalue: %v", len(aneks), aneks)
}
err = errors.New("Unable to parse response from www.anekdot.ru: " + string(respBytes))
return "", err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,84 @@
package bunicomic
import (
"aleesa-webapp-go/internal/config"
"context"
"fmt"
"math/rand/v2"
"regexp"
"strings"
"github.com/carlmjohnson/requests"
"golang.org/x/net/html"
)
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
body string
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
url = "https://www.bunicomic.com/?random&nocache=1"
)
err := requests.
URL(url).
UserAgent(userAgent).
ToString(&body).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
// Парсим body ответа от сервера.
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return "", err
}
var (
metaTags []string
meta func(*html.Node)
)
meta = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "meta" {
for _, meta := range n.Attr {
if meta.Key == "content" {
// adds a new link entry when the attribute matches
metaTags = append(metaTags, meta.Val)
}
}
}
// traverses the HTML of the webpage from the first child node
for c := n.FirstChild; c != nil; c = c.NextSibling {
meta(c)
}
}
meta(doc)
// Возвращаем ссылку на картинку buni comic.
if len(metaTags) != 0 {
// А теперь среди полученнного мусора найдём ссылку на урл.
for _, buni := range metaTags {
match, err := regexp.MatchString(`wp-content/uploads`, buni) //nolint: staticcheck
if err != nil {
return "", err
}
if match {
return buni, nil
}
}
}
// Картинки не выпарсилось, это точно ошибка.
err = fmt.Errorf("unable to get link to buni comic strip image")
return "", err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

210
internal/config/config.go Normal file
View File

@@ -0,0 +1,210 @@
package config
import (
"aleesa-webapp-go/internal/defaults"
"bufio"
"encoding/json"
"fmt"
"os"
"unsafe"
"github.com/cockroachdb/pebble"
"github.com/go-redis/redis/v8"
"github.com/hjson/hjson-go"
log "github.com/sirupsen/logrus"
)
// MyConfig структурка, описывающая конфиг.
type MyConfig struct {
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Timeout int `json:"timeout,omitempty"`
Loglevel string `json:"loglevel,omitempty"`
Log string `json:"log,omitempty"`
Channel string `json:"channel,omitempty"`
DataDir string `json:"datadir,omitempty"`
Csign string `json:"csign,omitempty"`
ForwardsMax int64 `json:"forwards_max,omitempty"`
OpenWeatherMap struct {
Enabled bool `json:"enabled,omitempty"`
Country bool `json:"country,omitempty"`
Appid string `json:"appid,omitempty"`
} `json:"openweathermap,omitempty"`
Flickr struct {
Enabled bool `json:"enabled,omitempty"`
Key string `json:"key,omitempty"`
Secret string `json:"secret,omitempty"`
OAuthToken string `json:"oauth_token,omitempty"`
OAuthTokenSecret string `json:"oauth_token_secret,omitempty"`
} `json:"flickr,omitempty"`
UserAgents []string `json:"user_agents,omitempty"`
PcacheDB map[string]*pebble.DB
RedisClient *redis.Client
}
// ReadConfig читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге
// нет.
func ReadConfig() (*MyConfig, error) {
locations, err := defaults.DefaultConfigFileLocations()
if err != nil {
return &MyConfig{}, err
}
for _, location := range locations {
fileInfo, err := os.Stat(location)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём
if err != nil {
continue
}
// Конфиг-файл длинноват для конфига, попробуем следующего кандидата
if fileInfo.Size() > defaults.ConfigFileSize {
log.Warnf("Config file %s is too long for config, skipping", location)
continue
}
buf, err := os.ReadFile(location)
// Не удалось прочитать, попробуем следующего кандидата
if err != nil {
log.Warnf("Skip reading config file %s: %s", location, err)
continue
}
// Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку.
// Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку
// сериализуем в json, а потом json преврщааем в стркутурку. Не очень эффективно, но он и не часто требуется.
var (
sampleConfig *MyConfig
tmp map[string]interface{}
)
err = hjson.Unmarshal(buf, &tmp)
// Не удалось распарсить - попробуем следующего кандидата
if err != nil {
log.Warnf("Skip parsing config file %s: %s", location, err)
continue
}
tmpjson, err := json.Marshal(tmp)
// Не удалось преобразовать map-ку в json
if err != nil {
log.Warnf("Skip parsing config file %s: %s", location, err)
continue
}
if err := json.Unmarshal(tmpjson, &sampleConfig); err != nil {
log.Warnf("Skip parsing config file %s: %s", location, err)
continue
}
// Валидируем значения из конфига
if sampleConfig.Server == "" {
sampleConfig.Server = defaults.Host
}
if sampleConfig.Port == 0 {
sampleConfig.Port = defaults.RedisPort
}
if sampleConfig.Timeout == 0 {
sampleConfig.Timeout = defaults.NetworkTimeout
}
if sampleConfig.Loglevel == "" {
sampleConfig.Loglevel = defaults.Loglevel
}
// sampleConfig.Log = "" if not set
if sampleConfig.Channel == "" {
log.Errorf("Channel field in config file %s must be set", location)
continue
}
if sampleConfig.DataDir == "" {
sampleConfig.DataDir = defaults.DataDir
}
if sampleConfig.Csign == "" {
log.Errorf("Csign field in config file %s must be set", location)
continue
}
if sampleConfig.ForwardsMax == 0 {
sampleConfig.ForwardsMax = defaults.ForwardMax
}
if unsafe.Sizeof(sampleConfig.OpenWeatherMap) == 0 {
log.Errorf("Hash OpenWeatherMap in config file %s must be defined", location)
continue
}
if sampleConfig.OpenWeatherMap.Appid == "" {
log.Errorf("Openweathermap->appid in config file %s must be set to its valid value", location)
continue
}
// sampleConfig.OpenWeatherMap.Country is false by default
// Теперь надо бы попробовать загрузить список User Agent-ов, с которыми ходить в разные апишки.
uaFile := fmt.Sprintf("%s/useragents.txt", sampleConfig.DataDir)
uaList, err := ReadLines(uaFile)
if err != nil {
return &MyConfig{}, fmt.Errorf("unable to read %s: %w", uaFile, err)
}
sampleConfig.UserAgents = uaList
sampleConfig.PcacheDB = make(map[string]*pebble.DB)
log.Infof("Using %s as config file", location)
return sampleConfig, nil
}
return &MyConfig{}, fmt.Errorf("config was not loaded! Refusing to start")
}
// ReadLines читает даденный файл построчно в массив строк.
func ReadLines(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
log.Errorf("Unable to close file %s:%s", path, err)
}
}(file)
var (
lines []string
scanner = bufio.NewScanner(file)
)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,38 @@
package defaults
import (
"fmt"
"os"
"path/filepath"
)
var ForwardMax int64 = 5
var ConfigFileSize int64 = 65535
var DataDir = "data"
var Host = "localhost"
var Loglevel = "info"
var RedisPort = 6379
var NetworkTimeout = 10
func DefaultConfigFileLocations() ([]string, error) {
var locations []string
executablePath, err := os.Executable()
if err != nil {
return locations, fmt.Errorf("unable to get my executable path: %w", err)
}
configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath))
locations = []string{
"~/.aleesa-webapp-go.json",
"~/aleesa-webapp-go.json",
"/etc/aleesa-webapp-go.json",
configJSONPath,
}
return locations, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

230
internal/flickr/flickr.go Normal file
View File

@@ -0,0 +1,230 @@
package flickr
import (
"aleesa-webapp-go/internal/config"
"aleesa-webapp-go/internal/pcachedb" //nolint: gosec // Just go fuck yourself if you don't see difference between hashing and cryptography.
"context"
"fmt"
"math/rand/v2"
"net/url"
"strings"
"github.com/carlmjohnson/requests"
"gopkg.in/masci/flickr.v3"
"gopkg.in/masci/flickr.v3/test"
)
// SearchResult структура, описывающая ответ api на поисковой запрос.
type SearchResult struct {
Photos struct {
Page int64 `json:"page,omitempty"`
Pages int64 `json:"pages,omitempty"`
Perpage int64 `json:"perpage,omitempty"`
Total int64 `json:"total,omitempty"`
Photo []struct {
Farm int64 `json:"farm,omitempty"`
ID string `json:"id,omitempty"`
Isfamily int64 `json:"isfamily,omitempty"`
Isfriend int64 `json:"isfriend,omitempty"`
Ispublic int64 `json:"ispublic,omitempty"`
Owner string `json:"owner,omitempty"`
Secret string `json:"secret,omitempty"`
Server string `json:"server,omitempty"`
Title string `json:"title,omitempty"`
} `json:"photo,omitempty"`
} `json:"photos,omitempty"`
Stat string `json:"stat,omitempty"`
}
// APIClientInit генерит и сохраняет token и token secret с помощью даденных пользователем key и secret.
// В процессе выдаём юзеру в консоль урл и запрос открыть его в браузере, чтобы получить verifier key, который нам
// понадобится в дальшейшем для генерации рабчочих token и token secret.
func APIClientInit(cfg *config.MyConfig) error {
var err error
client := flickr.NewFlickrClient(cfg.Flickr.Key, cfg.Flickr.Secret)
// Первым делом достанем request token
tok, err := flickr.GetRequestToken(client)
if err != nil {
return fmt.Errorf("unable to get Request Token: %w", err)
}
// Соорудим авторизационный URL
url, err := flickr.GetAuthorizeUrl(client, tok)
if err != nil {
return fmt.Errorf("unable to get auth URL: %w", err)
}
// А теперь ручные действия пользователя.
// Выдаём ему url, который надо ввести в адресную строку браузера, в браузере сгенерится verifier code, который нам
// нужен, чтобы вынуть из flicker api oauth token, secret, access token
var oauthVerifier string
fmt.Println("Open your browser at this url:", url)
fmt.Print("Then, insert the code: ")
_, _ = fmt.Scanln(&oauthVerifier)
// Наконец, достаём Access Token и Aceess Token Secret и сохраняем их в базку на диске.
accessTok, err := flickr.GetAccessToken(client, tok, oauthVerifier)
if err != nil {
return fmt.Errorf("unable to make Aceess Token and Access Token Secret, API answers: %w", err)
}
fmt.Println("Successfully retrieved OAuth token and secret")
// Проверим, что всё работает.
resp, err := test.Login(client)
if err != nil {
return fmt.Errorf("unable to perform test login: %w", err)
}
fmt.Printf("Test login successful: %s, %s\n", resp.Status, resp.User)
// И где-то тут надо сохранить токен куда-то на диск.
if err = pcachedb.SaveKeyWithValue(cfg, "flickr", "token", accessTok.OAuthToken); err != nil {
return fmt.Errorf("unable to save Aceess Token: %w", err)
}
if err = pcachedb.SaveKeyWithValue(cfg, "flickr", "secret", accessTok.OAuthTokenSecret); err != nil {
return fmt.Errorf("unable to save Aceess Token Secret: %w", err)
}
return nil
}
// Populate заполняет кэш ключами.
func Populate(cfg *config.MyConfig) error {
if err := pcachedb.SaveKeyWithValue(cfg, "flickr", "token", cfg.Flickr.OAuthToken); err != nil {
return fmt.Errorf("unable to save Aceess Token: %w", err)
}
if err := pcachedb.SaveKeyWithValue(cfg, "flickr", "secret", cfg.Flickr.OAuthTokenSecret); err != nil {
return fmt.Errorf("unable to save Aceess Token Secret: %w", err)
}
return nil
}
// SearchByTags делает поисковый запрос по даденным тэгам.
func SearchByTags(cfg *config.MyConfig, tags []string) (string, error) {
var (
apiKey = cfg.Flickr.Key
apiSecret = cfg.Flickr.Secret
oauthToken = pcachedb.GetValue(cfg, "flickr", "token")
oauthSecret = pcachedb.GetValue(cfg, "flickr", "secret")
c = flickr.NewFlickrClient(apiKey, apiSecret)
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
ctx = context.Background()
tagsString = strings.Join(tags, ",")
)
// Заполняем поля для оаутха.
c.Init() // c.EndpointUrl
c.OAuthToken = oauthToken
c.OAuthTokenSecret = oauthSecret
// Добавляем параметры. В данном случае это всё, что описано в доке вот тут:
// https://www.flickr.com/services/api/flickr.photos.search.html
args := url.Values{
"format": {"json"},
"method": {"flickr.photos.search"},
"nojsoncallback": {"1"},
"content_types": {"0"},
"media": {"photos"},
"per_page": {"100"},
"tags": {tagsString},
}
c.Args = args
// Подписываем запрос oauth-говном.
c.OAuthSign()
// А мы не будем пользоваться flickr.DoGet() по 2-м причинам, во-первых он хочет xml, а это говно, а не язык
// разметки, а во-вторых, этот самый flickr.DoGet() валится с segfault-ом, просто потому что. Схема не такая, на
// которую он рассчитан. Но нам повезло, что можно просто вытащить подписанный url и заправить его в свой
// http-клиент.
url := c.GetUrl()
var response SearchResult
err := requests.
URL(url).
UserAgent(userAgent).
ToJSON(&response).
Fetch(ctx)
if response.Stat == "fail" {
return "", fmt.Errorf("search request returns no results and fails")
}
// Ну, вот мы и получили ответ на вопрос - а сколько же у нас результатов поиска всего, по заданному критерию.
// Теперь мы повторим запрос, но на cей раз попросим flickr выдать нам рандомный результат из этой кучи.
if err != nil {
return "", fmt.Errorf("unable to get results for tags %s from flickr api: %w", tagsString, err)
}
randomPage := rand.Int64N(response.Photos.Pages)
c.Args.Set("page", fmt.Sprintf("%d", randomPage))
// Подписываем запрос oauth-говном.
c.OAuthSign()
url = c.GetUrl()
err = requests.
URL(url).
UserAgent(userAgent).
ToJSON(&response).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to get results for tags %s from flickr api: %w", tagsString, err)
}
if response.Stat == "fail" {
return "", fmt.Errorf("search request returns no results and fails")
}
// Частенько выборка не очень уникальна, много дублей. Чтобы почистить выборку, неплохо бы выбирать только те фотки,
// у которых response.Photos.Photo[exactResult].Title уникальный.
var u SearchResult
for i := 0; i < len(response.Photos.Photo); i++ {
skip := false
for j := 0; j < len(u.Photos.Photo); j++ {
if response.Photos.Photo[i].Title == u.Photos.Photo[j].Title {
skip = true
break
}
}
if skip {
continue
}
u.Photos.Photo = append(u.Photos.Photo, response.Photos.Photo[i])
}
exactResult := rand.IntN(len(u.Photos.Photo))
result := fmt.Sprintf(
"https://live.staticflickr.com/%s/%s_%s_z.jpg",
u.Photos.Photo[exactResult].Server,
u.Photos.Photo[exactResult].ID,
u.Photos.Photo[exactResult].Secret,
)
return result, err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,537 @@
package lib
import (
"aleesa-webapp-go/internal/anekdotru"
"aleesa-webapp-go/internal/bunicomic"
"aleesa-webapp-go/internal/config"
"aleesa-webapp-go/internal/flickr"
"aleesa-webapp-go/internal/monkeyuser"
"aleesa-webapp-go/internal/oboobs"
"aleesa-webapp-go/internal/obutts"
"aleesa-webapp-go/internal/openweathermap"
"aleesa-webapp-go/internal/prazdnikisegodnyaru"
"aleesa-webapp-go/internal/randomfox"
"aleesa-webapp-go/internal/thecatapi"
"aleesa-webapp-go/internal/xkcdru"
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"regexp"
"time"
log "github.com/sirupsen/logrus"
)
// MsgParser горутинка, которая парсит json-чики прилетевшие из REDIS-ки.
func MsgParser(cfg *config.MyConfig, ctx context.Context, msg string) {
var (
sendTo string
answer string
j RMsg
err error
)
regexpWeather := regexp.MustCompile("(w|weather|п|погода|погодка|погадка)5?[[:space:]]+.+")
log.Debugf("Incomming raw json: %s", msg)
if err := json.Unmarshal([]byte(msg), &j); err != nil {
log.Warnf("Unable to to parse message from redis channel: %s", err)
return
}
j, err = ValidateRmsg(cfg, j, msg)
if err != nil {
log.Warn(err)
return
}
// Если у нас циклическая пересылка сообщения, попробуем её тут разорвать, отбросив сообщение
if j.Misc.Fwdcnt > cfg.ForwardsMax {
log.Warnf("Discarding msg with fwd_cnt exceeding max value: %s", msg)
return
}
j.Misc.Fwdcnt++
sendTo = j.Plugin
// Классифицируем входящие сообщения. Первым делом, попробуем определить команды
if j.Message[0:len(j.Misc.Csign)] == j.Misc.Csign {
var cmd = j.Message[len(j.Misc.Csign):]
switch {
case cmd == "cat" || cmd == "кис":
// Пытаемся вычитать ответ thecatapi 3 раза
for i := 0; i < 3; i++ {
answer, err = thecatapi.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query api.thecatapi.com: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету кошечек, все разбежались."
} else {
// for telegram return answer in markdown format
if j.Plugin == "telegram" {
cats := []string{"龴ↀ◡ↀ龴", "=^..^=", "≧◔◡◔≦", "^ↀᴥↀ^"}
j.Message = fmt.Sprintf("[%s](%s)", cats[rand.IntN(len(cats))], answer)
} else {
j.Message = answer
}
}
case cmd == "fox" || cmd == "лис":
// Пытаемся вычитать ответ randonfox.ca 3 раза
for i := 0; i < 3; i++ {
answer, err = randomfox.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query randomfox.ca: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету лисичек, все разбежались."
} else {
if j.Plugin == "telegram" {
j.Message = fmt.Sprintf("[-^^,--,~](%s)", answer)
} else {
j.Message = answer
}
}
case cmd == "xkcd":
// Пытаемся вычитать ответ xkcd.ru 3 раза
for i := 0; i < 3; i++ {
answer, err = xkcdru.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query xkcd.ru: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Комикс-стрип нарисовать не так-то просто :("
} else {
if j.Plugin == "telegram" {
j.Message = fmt.Sprintf("[xkcdru](%s)", answer)
} else {
j.Message = answer
}
}
case cmd == "buni":
// Пытаемся вычитать ответ www.bunicomic.com 3 раза
for i := 0; i < 3; i++ {
answer, err = bunicomic.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query www.bunicomic.com: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету Buni :("
} else {
if j.Plugin == "telegram" {
j.Message = fmt.Sprintf("[buni](%s)", answer)
} else {
j.Message = answer
}
}
case cmd == "anek" || cmd == "анек" || cmd == "анекдот":
// Пытаемся вычитать ответ www.anekdot.ru 3 раза
for i := 0; i < 3; i++ {
answer, err = anekdotru.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query www.anekdot.ru: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Все рассказчики анекдотов отдыхают"
} else {
if j.Plugin == "telegram" {
j.Message = fmt.Sprintf("```\n%s\n```", answer)
} else {
j.Message = answer
}
}
case cmd == "monkeyuser":
// Пытаемся вычитать ответ www.bunicomic.com 3 раза
for i := 0; i < 3; i++ {
answer, err = monkeyuser.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query monkeyuser.com: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету Monkey User-ов, они все спрятались."
} else {
if j.Plugin == "telegram" {
j.Message = fmt.Sprintf("[monkeyuser](%s)", answer)
} else {
j.Message = answer
}
}
case cmd == "butt" || cmd == "booty" || cmd == "ass" || cmd == "попа" || cmd == "попка":
// Пытаемся вычитать ответ api.obutts.ru 3 раза
for i := 0; i < 3; i++ {
answer, err = obutts.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query api.obutts.ru: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету попок, все разбежались."
} else {
if j.Plugin == "telegram" {
arts := []string{"(__(__)", "(_!_)", "(__.__)"}
j.Message = fmt.Sprintf("[%s](%s)", arts[rand.IntN(len(arts))], answer)
} else {
j.Message = answer
}
}
case cmd == "titts" || cmd == "boobs" || cmd == "tities" || cmd == "boobies" || cmd == "сиси" || cmd == "сисечки":
// Пытаемся вычитать ответ api.oboobs.ru 3 раза
for i := 0; i < 3; i++ {
answer, err = oboobs.APIClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query api.oboobs.ru: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету cисичек, все разбежались."
} else {
if j.Plugin == "telegram" {
arts := []string{"(. )( .)", "( . Y . )", "(o)(o)", "( @ )( @ )", "(.)(.)"}
j.Message = fmt.Sprintf("[%s](%s)", arts[rand.IntN(len(arts))], answer)
} else {
j.Message = answer
}
}
case cmd == "drink" || cmd == "праздник":
// Пытаемся вычитать ответ prazdniki-segodnya.ru 3 раза
for i := 0; i < 3; i++ {
answer, err = prazdnikisegodnyaru.PsrClient(cfg)
if err != nil {
log.Errorf("Try %d/3 unable to query prazdniki-segodnya.ru: %v", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Не знаю праздников - вджобываю весь день на шахтах, как проклятая."
} else {
j.Message = answer
}
case cmd == "frog" || cmd == "лягушка":
for i := 0; i < 3; i++ {
answer, err = flickr.SearchByTags(cfg, []string{"frog", "toad", "amphibian"})
if err != nil {
log.Errorf("Try %d/3 unable to query flickr api about frogs: %s", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Лягушки упрыгали, ни одной не нашлось."
} else {
// Костыль для красивого ответа в telegram.
if j.Plugin == "telegram" {
art := []string{"frog", "toad", "лягушка"}
answer = fmt.Sprintf("[%s](%s)", art[rand.IntN(len(art))], answer)
}
j.Message = answer
}
case cmd == "owl" || cmd == "сова" || cmd == "сыч":
for i := 0; i < 3; i++ {
answer, err = flickr.SearchByTags(cfg, []string{"owlet", "owl", "raptor", "bird of prey", "nocturnal bird", "barn owl", "tawny owl", "brown owl"})
if err != nil {
log.Errorf("Try %d/3 unable to query flickr api about owls: %s", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету сов, все разлетелись."
} else {
// Костыль для красивого ответа в telegram.
if j.Plugin == "telegram" {
answer = fmt.Sprintf("[{ O v O }](%s)", answer)
}
j.Message = answer
}
case cmd == "horse" || cmd == "лошадь" || cmd == "лошадка":
for i := 0; i < 3; i++ {
answer, err = flickr.SearchByTags(cfg, []string{"horse", "equine", "stallion", "mare", "steed", "thoroughbred"})
if err != nil {
log.Errorf("Try %d/3 unable to query flickr api about horses: %s", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету коняшек, все разбежались."
} else {
// Костыль для красивого ответа в telegram.
if j.Plugin == "telegram" {
art := []string{"horse", "лошадь", "лошадка"}
answer = fmt.Sprintf("[%s](%s)", art[rand.IntN(len(art))], answer)
}
j.Message = answer
}
case cmd == "rabbit" || cmd == "bunny" || cmd == "кролик":
for i := 0; i < 3; i++ {
answer, err = flickr.SearchByTags(cfg, []string{"bunny", "rabbit", "buck"})
if err != nil {
log.Errorf("Try %d/3 unable to query flickr api about rabbits: %s", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету кроликов, все разбежались."
} else {
// Костыль для красивого ответа в telegram.
if j.Plugin == "telegram" {
answer = fmt.Sprintf("[(\\_/)](%s)", answer)
}
j.Message = answer
}
case cmd == "snail" || cmd == "улитка":
for i := 0; i < 3; i++ {
answer, err = flickr.SearchByTags(cfg, []string{"snail", "slug"})
if err != nil {
log.Errorf("Try %d/3 unable to query flickr api about snails: %s", i+1, err)
time.Sleep(1 * time.Second)
continue
}
break
}
if answer == "" {
j.Message = "Нету улиток, все расползлись."
} else {
// Костыль для красивого ответа в telegram.
if j.Plugin == "telegram" {
art := []string{"'-'_@_", "@╜", "@_'-'"}
answer = fmt.Sprintf("[%s](%s)", art[rand.IntN(len(art))], answer)
}
j.Message = answer
}
case regexpWeather.MatchString(cmd):
re := regexp.MustCompile("[[:space:]]+")
cmdStr := re.Split(cmd, 2)[0]
city := re.Split(cmd, 2)[1]
if regexp.MustCompile(`5$`).MatchString(cmdStr) {
if answer, err := openweathermap.OwmClient(cfg, city, 5); err != nil {
log.Errorf("Unable to handle city %s in openweartermap api: %s", city, err)
j.Message = fmt.Sprintf("Я не знаю, какая погода в %s", city)
} else {
j.Message = answer
}
} else {
if answer, err := openweathermap.OwmClient(cfg, city, 0); err != nil {
log.Errorf("Unable to handle city %s in openweartermap api: %s", city, err)
j.Message = fmt.Sprintf("Я не знаю, какая погода в %s", city)
} else {
j.Message = answer
}
}
default:
log.Errorf("Unknown command %s, unable to handle, skipping", j.Message)
return
}
} else {
log.Errorf("Message is not a command: %s, unable to handle, skipping", j.Message)
return
}
// Настало время формировать json и засылать его в дальше
var message SMsg
message.From = j.From
message.Userid = j.Userid
message.Chatid = j.Chatid
message.Threadid = j.Threadid
message.Message = j.Message
message.Plugin = j.Plugin
message.Mode = j.Mode
message.Misc.Fwdcnt = j.Misc.Fwdcnt
message.Misc.Csign = j.Misc.Csign
message.Misc.Username = j.Misc.Username
message.Misc.Answer = j.Misc.Answer
message.Misc.Botnick = j.Misc.Botnick
message.Misc.Msgformat = j.Misc.Msgformat
message.Misc.GoodMorning = j.Misc.GoodMorning
data, err := json.Marshal(message)
if err != nil {
log.Warnf("Unable to to serialize message for redis: %s", err)
return
}
// Заталкиваем наш json в редиску
if err := cfg.RedisClient.Publish(ctx, sendTo, data).Err(); err != nil {
log.Warnf("Unable to send data to redis channel %s: %s", sendTo, err)
} else {
log.Debugf("Send msg to redis channel %s: %s", sendTo, string(data))
}
}
// ValidateRmsg валидирует входящее сообщение.
func ValidateRmsg(cfg *config.MyConfig, j RMsg, msg string) (RMsg, error) {
if exist := j.From; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no from field: %s", msg)
return j, emsg
}
if exist := j.Chatid; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no chatid field: %s", msg)
return j, emsg
}
if exist := j.Userid; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no userid field: %s", msg)
return j, emsg
}
// j.Threadid может быть пустым, значит либо нам его не дали, либо дали пустым. Это нормально.
if exist := j.Message; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no message field: %s", msg)
return j, emsg
}
if exist := j.Plugin; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no plugin field: %s", msg)
return j, emsg
}
if exist := j.Mode; exist == "" {
emsg := fmt.Errorf("incorrect msg from redis, no mode field: %s", msg)
return j, emsg
}
// j.Misc.Answer может и не быть, тогда ответа на такое сообщение не будет
// j.Misc.Botnick тоже можно не передавать, тогда будет записана пустая строка
// j.Misc.Csign если нам его не передали, возьмём значение из конфига
if exist := j.Misc.Csign; exist == "" {
j.Misc.Csign = cfg.Csign
}
// j.Misc.Fwdcnt если нам его не передали, то будет 0
if exist := j.Misc.Fwdcnt; exist == 0 {
j.Misc.Fwdcnt = 1
}
// j.Misc.GoodMorning может быть быть 1 или 0, по-умолчанию 0
// j.Misc.Msgformat может быть быть 1 или 0, по-умолчанию 0
// j.Misc.Username можно не передавать, тогда будет пустая строка
return j, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

43
internal/lib/types.go Normal file
View File

@@ -0,0 +1,43 @@
package lib
// RMsg структурка, описывающая входящее сообщение из pubsub-канала redis-ки.
type RMsg struct {
From string `json:"from,omitempty"`
Chatid string `json:"chatid,omitempty"`
Userid string `json:"userid,omitempty"`
Threadid string `json:"threadid,omitempty"`
Message string `json:"message,omitempty"`
Plugin string `json:"plugin,omitempty"`
Mode string `json:"mode,omitempty"`
Misc struct {
Answer int64 `json:"answer,omitempty"`
Botnick string `json:"bot_nick,omitempty"`
Csign string `json:"csign,omitempty"`
Fwdcnt int64 `json:"fwd_cnt,omitempty"`
GoodMorning int64 `json:"good_morning,omitempty"`
Msgformat int64 `json:"msg_format,omitempty"`
Username string `json:"username,omitempty"`
} `json:"Misc"`
}
// SMsg структурка, описывающая исходящее сообщение в pubsub-канал redis-ки.
type SMsg struct {
From string `json:"from"`
Chatid string `json:"chatid"`
Userid string `json:"userid"`
Threadid string `json:"threadid"`
Message string `json:"message"`
Plugin string `json:"plugin"`
Mode string `json:"mode"`
Misc struct {
Answer int64 `json:"answer"`
Botnick string `json:"bot_nick"`
Csign string `json:"csign"`
Fwdcnt int64 `json:"fwd_cnt"`
GoodMorning int64 `json:"good_morning"`
Msgformat int64 `json:"msg_format"`
Username string `json:"username"`
} `json:"misc"`
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,124 @@
package monkeyuser
import (
"aleesa-webapp-go/internal/config"
"bytes"
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"strings"
"github.com/carlmjohnson/requests"
"golang.org/x/net/html"
)
// MonkeyUsers описывает структуру json-массива ответа www.monkeyusers.com.
type MonkeyUsers []struct {
Title string `json:"title"`
URL string `json:"url"`
}
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
respBody bytes.Buffer
respBytes []byte
body string
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
baseURL = "https://www.monkeyuser.com"
indexURL = fmt.Sprintf("%s/index.json", baseURL)
)
err := requests.
URL(indexURL).
UserAgent(userAgent).
ToBytesBuffer(&respBody).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", indexURL, err)
}
// На всякий случай валидируем utf-8.
respBytes = bytes.ToValidUTF8(respBody.Bytes(), []byte{0xef, 0xbf, 0xbd})
var monkeyusers MonkeyUsers
if err := json.Unmarshal(respBytes, &monkeyusers); err != nil {
err = fmt.Errorf("unable to to parse response from %s: %w", indexURL, err)
return "", err
}
amountOfUsers := len(monkeyusers)
if amountOfUsers == 0 {
err = fmt.Errorf("no links found in %s", indexURL)
return "", err
}
ofChoice := rand.IntN(amountOfUsers)
pageURL := fmt.Sprintf("%s%s", baseURL, monkeyusers[ofChoice].URL)
// Делаем второй запрос к серверу.
err = requests.
URL(pageURL).
UserAgent(userAgent).
ToString(&body).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", pageURL, err)
}
// Парсим body ответа от сервера
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return "", err
}
var (
hyperlink string
link func(*html.Node)
)
link = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for _, a_href := range n.Attr { //nolint: revive,stylecheck
// Если у нас class == "lazyload small-image", то после него ключ data-src должен содержать
// искомый relative url
if a_href.Key == "class" && a_href.Val == "logo" {
continue
}
var v = a_href.Val
if a_href.Key == "src" && v != "/images/logo.png" {
hyperlink = v
break
}
}
}
// traverses the HTML of the webpage from the first child node
for c := n.FirstChild; c != nil; c = c.NextSibling {
link(c)
}
}
link(doc)
// Возвращаем ссылку на картинку buni comic
if hyperlink != "" {
return fmt.Sprintf("%s%s", baseURL, hyperlink), nil
}
err = fmt.Errorf("no links found on monkeyusers.com")
return "", err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

50
internal/oboobs/oboobs.go Normal file
View File

@@ -0,0 +1,50 @@
package oboobs
import (
"aleesa-webapp-go/internal/config"
"context"
"fmt"
"math/rand/v2"
"github.com/carlmjohnson/requests"
)
// Oboobs структура, возвращаемая на запрос в oboobs.ru.
type Oboobs struct {
ID int `json:"id,omitempty"`
Author string `json:"author,omitempty"`
Rank int `json:"rank,omitempty"`
Model string `json:"model,omitempty"`
Preview string `json:"preview"`
}
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
url = "http://api.oboobs.ru/boobs/0/1/random"
boobs []Oboobs
)
err := requests.
URL(url).
UserAgent(userAgent).
ToJSON(&boobs).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
if len(boobs) == 0 {
err = fmt.Errorf("empty json array returned from %s", url)
return "", err
}
answer := fmt.Sprintf("https://media.oboobs.ru/%s", boobs[0].Preview)
return answer, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

50
internal/obutts/obutts.go Normal file
View File

@@ -0,0 +1,50 @@
package obutts
import (
"aleesa-webapp-go/internal/config"
"context"
"fmt"
"math/rand/v2"
"github.com/carlmjohnson/requests"
)
// Obutts структура, возвращаемая на запрос в obutts.ru.
type Obutts struct {
ID int `json:"id,omitempty"`
Author string `json:"author,omitempty"`
Rank int `json:"rank,omitempty"`
Model string `json:"model,omitempty"`
Preview string `json:"preview"`
}
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
url = "http://api.obutts.ru/butts/0/1/random"
butts []Obutts
)
err := requests.
URL(url).
UserAgent(userAgent).
ToJSON(&butts).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
if len(butts) == 0 {
err = fmt.Errorf("empty json array returned from %s", url)
return "", err
}
answer := fmt.Sprintf("http://media.obutts.ru/%s", butts[0].Preview)
return answer, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,360 @@
package openweathermap
import (
"aleesa-webapp-go/internal/config"
"aleesa-webapp-go/internal/pcachedb"
"fmt"
"net/http"
"strconv"
"strings"
"time"
owm "github.com/briandowns/openweathermap"
log "github.com/sirupsen/logrus"
)
// Структурка для передачи в обрабтчик запросов в openweathermap api.
type OwmItem struct {
City string
FakeName string
Where string
Lon float64
Lat float64
}
// OwmClient обёртка с кэшированием для openweatermap.org.
func OwmClient(cfg *config.MyConfig, city string, days uint32) (string, error) {
var (
answer string
err error
item OwmItem
)
if city == "" {
return "Мне нужно ИМЯ города.", err
}
if len(city) > 80 {
return "Длинновато для имени города", err
}
// "Нормализуем" название города.
city = strings.TrimSpace(city)
firstLetter := strings.SplitN(city, "", 2)[0]
city = fmt.Sprintf("%s%s", strings.ToUpper(firstLetter), city[1:])
city = strings.ToValidUTF8(city, "")
switch city {
case "Msk", "Default", "Dc", "Мск", "Dc-Universe":
item.City = "Москва"
case "Spb", "Спб":
item.City = "Санкт-Петербург"
case "Ект", "Ёбург", "Ебург", "Екат", "Ekt", "Eburg", "Ekat":
item.City = "Екатеринбург"
case "Кудыкина гора":
// Координаты Килиманжаро (-3.066524619045895, 37.35588473583672).
item.Lat = -3.066524619045895
item.Lon = 37.35588473583672
item.FakeName = "Килиманжаро"
item.Where = "на горе"
default:
item.City = city
item.Where = "в городе"
}
if days == 0 {
answer, err = QueryOwmCache(cfg, item)
}
if err != nil {
return "", err
}
return answer, nil
}
// OwmAPIClient делает запрос непосредственно в openweathermap.com api.
func OwmAPIClient(cfg *config.MyConfig, item OwmItem) (string, error) {
var (
answer string
cityName string
client = http.DefaultClient
)
w, err := owm.NewCurrent(
"C",
"ru",
cfg.OpenWeatherMap.Appid,
owm.WithHttpClient(client),
)
if err != nil {
err := fmt.Errorf("OWM-Api: %w", err)
return answer, err
}
if item.City != "" {
if err = w.CurrentByName(item.City); err != nil {
// TODO: extend error
return answer, err
}
cityName = item.City
} else {
coord := owm.Coordinates{Longitude: item.Lon, Latitude: item.Lat}
if err = w.CurrentByCoordinates(&coord); err != nil {
// TODO: extend error
return answer, err
}
if item.FakeName == "" {
cityName = w.Name
} else {
cityName = item.FakeName
}
}
// Тута парсер - направление ветра, градусы итд.
wind := "разный"
switch {
case w.Wind.Deg == 0:
wind = "северный"
case w.Wind.Deg > 0 && w.Wind.Deg <= 30:
wind = "северо-северо-восточный"
case w.Wind.Deg > 30 && w.Wind.Deg <= 60:
wind = "северо-восточный"
case w.Wind.Deg > 60 && w.Wind.Deg < 90:
wind = "восточно-северо-восточный"
case w.Wind.Deg == 90:
wind = "восточный"
case w.Wind.Deg > 90 && w.Wind.Deg <= 120:
wind = "восточно-юго-восточный"
case w.Wind.Deg > 120 && w.Wind.Deg <= 150:
wind = "юговосточный"
case w.Wind.Deg > 150 && w.Wind.Deg < 180:
wind = "юго-юго-восточный"
case w.Wind.Deg == 180:
wind = "южный"
case w.Wind.Deg > 180 && w.Wind.Deg <= 210:
wind = "юго-юго-западный"
case w.Wind.Deg > 210 && w.Wind.Deg <= 240:
wind = "юго-западный"
case w.Wind.Deg > 240 && w.Wind.Deg < 270:
wind = "западно-юго-западный"
case w.Wind.Deg == 270:
wind = "западный"
case w.Wind.Deg > 270 && w.Wind.Deg <= 300:
wind = "западно-северо-западный"
case w.Wind.Deg > 300 && w.Wind.Deg <= 330:
wind = "северо-западный"
case w.Wind.Deg > 330 && w.Wind.Deg < 360:
wind = "северо-северо-западный"
case w.Wind.Deg == 360:
wind = "северный"
}
if int(w.Main.TempMin) == int(w.Main.TempMax) {
if cfg.OpenWeatherMap.Country {
answer = fmt.Sprintf(
"Погода %s %s, %s:\\n%s, ветер %s %.1f м/c, температура %d°C, ощущается как %d°C, относительная влажность %d%%, давление %d мм.рт.ст",
item.Where,
cityName,
w.Sys.Country,
w.Weather[0].Description,
wind,
w.Wind.Speed,
int(w.Main.TempMin),
int(w.Main.FeelsLike),
w.Main.Humidity,
int(w.Main.Pressure*0.75006375541921),
)
} else {
answer = fmt.Sprintf(
"Погода %s %s, (ш:%f, д:%f):\n%s, ветер %s %.1f м/c, температура %d°C, ощущается как %d°C, относительная влажность %d%%, давление %d мм.рт.ст",
item.Where,
cityName,
w.GeoPos.Latitude,
w.GeoPos.Longitude,
w.Weather[0].Description,
wind,
w.Wind.Speed,
int(w.Main.TempMin),
int(w.Main.FeelsLike),
w.Main.Humidity,
int(w.Main.Pressure*0.75006375541921),
)
}
} else {
if cfg.OpenWeatherMap.Country {
answer = fmt.Sprintf(
"Погода %s %s, %s:\n%s, ветер %s %.1f м/c, температура от %d до %d°C, ощущается как %d°C, относительная влажность %d%%, давление %d мм.рт.ст",
item.Where,
cityName,
w.Sys.Country,
w.Weather[0].Description,
wind,
w.Wind.Speed,
int(w.Main.TempMin),
int(w.Main.TempMax),
int(w.Main.FeelsLike),
w.Main.Humidity,
int(w.Main.Pressure*0.75006375541921),
)
} else {
answer = fmt.Sprintf(
"Погода %s %s, (ш:%f, д:%f):\n%s, ветер %s %.1f м/c, температура от %d до %d°C, ощущается как %d°C, относительная влажность %d%%, давление %d мм.рт.ст",
item.Where,
cityName,
w.GeoPos.Latitude,
w.GeoPos.Longitude,
w.Weather[0].Description,
wind,
w.Wind.Speed,
int(w.Main.TempMin),
int(w.Main.TempMax),
int(w.Main.FeelsLike),
w.Main.Humidity,
int(w.Main.Pressure*0.75006375541921),
)
}
}
return answer, err
}
// UpdateOwmCache обновляет кэш.
func UpdateOwmCache(cfg *config.MyConfig, tsNowUnix int64, entry string, value string) error {
key := fmt.Sprintf("%s+timestamp", entry)
// "Кэшируем" на 3 часа.
ts := tsNowUnix + int64((3 * time.Hour).Seconds())
timestamp := fmt.Sprintf("%d", ts)
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, timestamp); err != nil {
return fmt.Errorf("OWM-Cache: unable to save timestamp to cache: %w", err)
}
key = fmt.Sprintf("%s+value", entry)
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, value); err != nil {
return fmt.Errorf("OWM-Cache: unable to save answer to cache: %w", err)
}
return nil
}
// QueryOwmCache пытается вынуть из кэша ответ относительно данного города или координат.
// Если кэш пуст, делает запрос к api owm.
func QueryOwmCache(cfg *config.MyConfig, item OwmItem) (string, error) {
var (
answer string
err error
tsNow = time.Now().Unix()
key string
)
if item.City != "" {
key = item.City + "+timestamp"
} else {
key = fmt.Sprintf("%f+%f+timestamp", item.Lon, item.Lat)
}
// А вот тут мы реализовываем механику кэширования.
tsCacheString := pcachedb.GetValue(cfg, "cache", key)
// Для этого города в кэше пока ничего нету, вероятно это первый запрос.
if tsCacheString == "" {
log.Debugf("OWM no cache entry for city %s", key)
answer, err := OwmAPIClient(cfg, item)
// Здесь мы ничего сделать не можем.
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш.
if err := UpdateOwmCache(cfg, tsNow, key, answer); err != nil {
return "", err
}
return answer, err
}
tsCache, err := strconv.ParseInt(tsCacheString, 10, 64)
// Если конверсия не удалась, значит значение в кэше хреновое, надо делать запрос в api и обновлять кэш.
// Первое, что приходит в голову - коллизия хэшей sha256.
if err != nil {
log.Warnf("OWM-Cache: unable to parse string as int64 (sha256 collision?): %s", err)
answer, err := OwmAPIClient(cfg, item)
// Бинго! у нас ещё и ошибка при обращении к api openwethermap, здесь мы ничего сделать не можем.
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш.
if err := UpdateOwmCache(cfg, tsNow, key, answer); err != nil {
return "", err
}
return answer, err
}
// Кэш просрочен
if tsCache < tsNow {
log.Debugf("OWM cache miss for city: %s", key)
answer, err := OwmAPIClient(cfg, item)
// Здесь мы ничего сделать не можем.
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш.
if err := UpdateOwmCache(cfg, tsNow, key, answer); err != nil {
log.Error(err)
}
return answer, err
}
key += "+value"
answer = pcachedb.GetValue(cfg, "cache", key)
// Какие-то проблемы с кэшом - ts есть, но самого кэша нету. Более подробная инфа по идее уже попала в лог.
// Или в кэше что-то не то - длина ответа *точно* не может быть меньше 140 символов.
// Попробуем достать данные из api и сохранить в тот самый многострадальный кэш.
if len(answer) < 140 {
log.Debugf("OWM cache miss for city: %s", key)
if len(answer) > 0 {
log.Errorf("OWM-Cache: record for city %s in cache is too short, less than 140 chars", key)
}
answer, err = OwmAPIClient(cfg, item)
// Здесь мы ничего сделать не можем.
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш.
if err := UpdateOwmCache(cfg, tsNow, key, answer); err != nil {
return "", err
}
return answer, err
}
log.Debugf("OWM cache hit for city: %s", key)
return answer, err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,123 @@
package pcachedb
import (
"aleesa-webapp-go/internal/config"
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"github.com/cockroachdb/errors/oserror"
"github.com/cockroachdb/pebble"
log "github.com/sirupsen/logrus"
)
// StoreKV сохраняет в указанной бд ключ и значение.
func StoreKV(db *pebble.DB, key string, value string) error {
var (
kArray = []byte(key)
vArray = []byte(value)
)
err := db.Set(kArray, vArray, pebble.Sync)
return err
}
// FetchV достаёт значение по ключу.
func FetchV(db *pebble.DB, key string) (string, error) {
var (
kArray = []byte(key)
vArray []byte
valueString = ""
)
vArray, closer, err := db.Get(kArray)
if err != nil {
return valueString, err
}
valueString = string(vArray)
err = closer.Close()
return valueString, err
}
// GetValue достанем данные из БД.
func GetValue(cfg *config.MyConfig, cacheID string, setting string) string {
var err error
cacheHash := sha256.Sum256([]byte(cacheID))
database := fmt.Sprintf("cache_db/%x", cacheHash)
// Если БД не открыта, откроем её
if _, ok := cfg.PcacheDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений
options.L0CompactionFileThreshold = 8
cfg.PcacheDB[database], err = pebble.Open(cfg.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open db, %s: %s\n", database, err)
return ""
}
}
value, err := FetchV(cfg.PcacheDB[database], setting)
// Если из базы ничего не вынулось, по каким-то причинам, то просто вернём пустую строку.
if err != nil {
switch {
case errors.Is(err, pebble.ErrNotFound):
log.Debugf("Unable to get value for %s: no record found in db %s", setting, database)
case errors.Is(err, fs.ErrNotExist):
log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, database)
case errors.Is(err, oserror.ErrNotExist):
log.Debugf("Unable to get value for %s: db dir %s does not exist", setting, database)
default:
log.Errorf("Unable to get value for %s in db dir %s: %s", setting, database, err)
}
return ""
}
return value
}
// saveKeyWithValue сохраним данные в БД.
func SaveKeyWithValue(cfg *config.MyConfig, cacheID string, setting string, value string) error {
var (
cacheHash = sha256.Sum256([]byte(cacheID))
database = fmt.Sprintf("cache_db/%x", cacheHash)
err error
)
// Если БД не открыта, откроем её
if _, ok := cfg.PcacheDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений.
options.L0CompactionFileThreshold = 8
cfg.PcacheDB[database], err = pebble.Open(cfg.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open settings db, %s: %s\n", database, err)
return err
}
}
if err := StoreKV(cfg.PcacheDB[database], setting, value); err != nil {
log.Errorf("Unable to save setting %s in database %s: %s", setting, database, err)
}
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,180 @@
package prazdnikisegodnyaru
import (
"aleesa-webapp-go/internal/config"
"aleesa-webapp-go/internal/pcachedb"
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/carlmjohnson/requests"
log "github.com/sirupsen/logrus"
"golang.org/x/net/html"
)
func PsrClient(cfg *config.MyConfig) (string, error) {
tsNow := time.Now()
// Вычислим expiration time для MSK+0 в формате unix timestamp
loc, _ := time.LoadLocation("Europe/Moscow")
year, month, day := tsNow.In(loc).Date()
eTime := time.Date(year, month, day, 0, 0, 0, 0, loc).AddDate(0, 0, +1).Unix()
tsStr := pcachedb.GetValue(cfg, "cache", "prazdnikisegodhyaru_timestamp")
// Кэш пустой, запросов пока не было
if tsStr == "" {
log.Debugf("PSR no cache entry for prazdnikisegodhyaru")
answer, err := PsrAPIClient(cfg)
if err != nil {
return "", err
}
if err := UpdatePsrCache(cfg, eTime, answer); err != nil {
log.Error(err)
}
return answer, nil
}
tsCache, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
log.Debug("PSR cache miss")
log.Warnf("PSR-Cache: unable to parse string as int64 (sha256 collision?): %s", err)
answer, err := PsrAPIClient(cfg)
// Бинго! у нас ещё и ошибка при обращении к "api" prazdnikisegodhyaru, здесь мы ничего сделать не можем
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш
if err := UpdatePsrCache(cfg, eTime, answer); err != nil {
log.Error(err)
}
return answer, nil
}
// Текущее время в Unix timestamp
tsNowUnix := tsNow.Unix()
// Кэш просрочен
if tsCache < tsNowUnix {
log.Debug("PSR cache miss")
answer, err := PsrAPIClient(cfg)
// Здесь мы ничего сделать не можем
if err != nil {
return "", err
}
// Если всё хорошо, надо обновить кэш
if err := UpdatePsrCache(cfg, eTime, answer); err != nil {
log.Error(err)
}
return answer, nil
}
// TODO: как-то научиться определять ситуацию с отсутствующим значением в кэше
// TODO: Научиться определять ситуацию, когда значением является unix timestamp
log.Debug("PSR cache hit")
return pcachedb.GetValue(cfg, "cache", "prazdnikisegodhyaru_value"), nil
}
func PsrAPIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
url = "https://prazdniki-segodnya.ru/"
respBody string
)
// Формальная попытка притвориться браузером
_, _, day := time.Now().Date()
userAgent := cfg.UserAgents[day]
err := requests.
URL(url).
UserAgent(userAgent).
Header("User-Agent", "ru-RU").
Header("Accept-Charset", "utf-8").
ToString(&respBody).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
var holidays []string
// Парсим body ответа от сервера
tokenizer := html.NewTokenizer(strings.NewReader(respBody))
for {
tokenType := tokenizer.Next()
if tokenType == html.ErrorToken {
err := tokenizer.Err()
if errors.Is(err, io.EOF) {
// end of the file, break out of the loop
break
}
return "", err
}
if tokenType == html.StartTagToken {
token := tokenizer.Token()
if token.Data == "div" {
for _, attribute := range token.Attr {
if attribute.Key == "class" && attribute.Val == "list-group-item text-monospace" {
tokenType = tokenizer.Next()
if tokenType == html.TextToken {
str := tokenizer.Token().Data
holidays = append(holidays, strings.TrimSpace(str))
}
}
}
}
}
}
if len(holidays) == 0 {
err = errors.New("unable to parse response from prazdniki-segodnya.ru: no holidays found")
return "", err
}
return "* " + strings.Join(holidays, "\n* "), nil
}
func UpdatePsrCache(cfg *config.MyConfig, eTime int64, value string) error {
key := "prazdnikisegodhyaru_timestamp"
timestamp := fmt.Sprintf("%d", eTime)
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, timestamp); err != nil {
return fmt.Errorf("PSR-Cache: unable to save timestamp to cache: %w", err)
}
key = "prazdnikisegodhyaru_value"
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, value); err != nil {
return fmt.Errorf("PSR-Cache: unable to save answer to cache: %w", err)
}
return nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,44 @@
package randomfox
import (
"aleesa-webapp-go/internal/config"
"context"
"fmt"
"math/rand/v2"
"regexp"
"github.com/carlmjohnson/requests"
)
// RandomFox структура, возвращаемая на запрос в randomfox.ca API.
type RandomFox struct {
Image string `json:"image,omitempty"`
Link string `json:"link"`
}
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
url = "https://randomfox.ca/floof/"
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
fox RandomFox
)
err := requests.
URL(url).
UserAgent(userAgent).
ToJSON(&fox).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
var re = regexp.MustCompile(`\\`)
answer := re.ReplaceAll([]byte(fox.Link), []byte(""))
return string(answer), nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,44 @@
package thecatapi
import (
"aleesa-webapp-go/internal/config"
"context"
"fmt"
"math/rand/v2"
"github.com/carlmjohnson/requests"
)
// TheCatAPIinnerStruct структурка, описывающая элемент массива, возвращемый Тhe cat api.
type TheCatAPIinnerStruct struct {
ID string `json:"id,omitempty"`
URL string `json:"url"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// TheCatAPI структурка, описывающая массив, возвращаемый на запрос урла картинки из The Cat API.
type TheCatAPI []TheCatAPIinnerStruct
func APIClient(cfg *config.MyConfig) (string, error) {
var (
ctx = context.Background()
url = "https://api.thecatapi.com/v1/images/search"
userAgent = cfg.UserAgents[rand.IntN(len(cfg.UserAgents))]
cat TheCatAPI
)
err := requests.
URL(url).
UserAgent(userAgent).
ToJSON(&cat).
Fetch(ctx)
if err != nil {
return "", fmt.Errorf("unable to GET %s: %w", url, err)
}
return cat[0].URL, nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

71
internal/xkcdru/xkcdru.go Normal file
View File

@@ -0,0 +1,71 @@
package xkcdru
import (
"aleesa-webapp-go/internal/config"
"fmt"
"io"
"math/rand/v2"
"net/http"
"time"
log "github.com/sirupsen/logrus"
)
func APIClient(cfg *config.MyConfig) (string, error) {
var (
err error
url = "https://xkcd.ru/random/"
c = http.Client{
Timeout: 10 * time.Second,
// Возвращаем ответ сразу же, не переходя по редиректам.
CheckRedirect: func(req *http.Request, via []*http.Request) error { //nolint: revive // Ну, не используется req и что из этого?
return http.ErrUseLastResponse
},
}
)
req, err := http.NewRequest(http.MethodGet, url, nil) //nolint: noctx
if err != nil {
return "", err
}
req.Header.Set("User-Agent", cfg.UserAgents[rand.IntN(len(cfg.UserAgents))])
var resp *http.Response
resp, err = c.Do(req) //nolint: bodyclose // Посмотри ниже, блять, тело запроса закрывается.
if err != nil {
return "", err
}
// Типа, надо закрывать Body в любом случае, как рекомендуют в документации https://pkg.go.dev/net/http .
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Errorf("Unable to close response body for request to %s: %s", url, err)
}
}(resp.Body)
// Response body нам не интересен, нам интересен статус и нам интересен заголовок Location
if resp.StatusCode == 302 {
location := resp.Header.Get("Location")
if len(location) <= 3 {
err = fmt.Errorf("location header too short")
return "", err
}
location = location[1:(len(location) - 1)]
return fmt.Sprintf("https://xkcd.ru/i/%s_v1.png", location), nil
}
err = fmt.Errorf("resp.StatusCode: %d", resp.StatusCode)
return "", err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */