Initial commit
This commit is contained in:
77
internal/anekdotru/anekdotru.go
Normal file
77
internal/anekdotru/anekdotru.go
Normal 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: */
|
||||
84
internal/bunicomic/bunicomic.go
Normal file
84
internal/bunicomic/bunicomic.go
Normal 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
210
internal/config/config.go
Normal 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: */
|
||||
38
internal/defaults/defaults.go
Normal file
38
internal/defaults/defaults.go
Normal 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
230
internal/flickr/flickr.go
Normal 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: */
|
||||
537
internal/lib/aleesa-webapp-lib.go
Normal file
537
internal/lib/aleesa-webapp-lib.go
Normal 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
43
internal/lib/types.go
Normal 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: */
|
||||
124
internal/monkeyuser/monkeyuser.go
Normal file
124
internal/monkeyuser/monkeyuser.go
Normal 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
50
internal/oboobs/oboobs.go
Normal 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
50
internal/obutts/obutts.go
Normal 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: */
|
||||
360
internal/openweathermap/openweathermap.go
Normal file
360
internal/openweathermap/openweathermap.go
Normal 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: */
|
||||
123
internal/pcachedb/pcachedb.go
Normal file
123
internal/pcachedb/pcachedb.go
Normal 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: */
|
||||
180
internal/prazdnikisegodnyaru/prazdnikisegodnyaru.go
Normal file
180
internal/prazdnikisegodnyaru/prazdnikisegodnyaru.go
Normal 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: */
|
||||
44
internal/randomfox/randomfox.go
Normal file
44
internal/randomfox/randomfox.go
Normal 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: */
|
||||
44
internal/thecatapi/thecatapi.go
Normal file
44
internal/thecatapi/thecatapi.go
Normal 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
71
internal/xkcdru/xkcdru.go
Normal 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: */
|
||||
Reference in New Issue
Block a user