Initial commit
This commit is contained in:
110
internal/holy/celebratoday.go
Normal file
110
internal/holy/celebratoday.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Package holy implements main business logic of bot service.
|
||||
package holy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/carlmjohnson/requests"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// APIClient клиент сервиса https://celebratoday.com/.
|
||||
func APIClient() (string, error) {
|
||||
var (
|
||||
ctx = context.Background()
|
||||
url = "https://celebratoday.com/ru/today"
|
||||
respBody string
|
||||
)
|
||||
|
||||
// Формальная попытка притвориться браузером.
|
||||
_, _, day := time.Now().Date()
|
||||
userAgent := Config.UserAgents[day]
|
||||
|
||||
if err := requests.URL(url).UserAgent(userAgent).
|
||||
Header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8").
|
||||
Header("Accept-Language", "en-US,en;q=0.5").
|
||||
Header("User-Agent", "ru-RU").
|
||||
Header("Accept-Charset", "utf-8").ToString(&respBody).Fetch(ctx); err != nil {
|
||||
return "", fmt.Errorf("Unable to GET %s: %w", url, err) //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
holidays, err := parser(respBody)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Unable to parse response from celebratoday.com: %w", err) //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
if len(holidays) == 0 {
|
||||
return "", errors.New("Unable to parse response from celebratoday.com: no holidays found") //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
slices.Sort(holidays)
|
||||
|
||||
holidays = slices.Compact(holidays)
|
||||
|
||||
return "* " + strings.Join(holidays, "\n* "), nil
|
||||
}
|
||||
|
||||
// parser разбирает буфер с html-ем, выискивая праздники.
|
||||
func parser(data string) ([]string, error) {
|
||||
var (
|
||||
holidays = make([]string, 0, 1)
|
||||
err error
|
||||
s = make([]Holy, 0, 1)
|
||||
)
|
||||
|
||||
doc, err := html.Parse(strings.NewReader(data))
|
||||
|
||||
if err != nil {
|
||||
return holidays, err
|
||||
}
|
||||
|
||||
traverse(doc, &s)
|
||||
|
||||
// А теперь пробежимся по полученной структуре и вытянем из неё все строки с праздниками.
|
||||
for _, ass := range s {
|
||||
for _, entity := range ass.MainEntity {
|
||||
for _, sugestedAnswer := range entity.SuggestedAnswer {
|
||||
holidays = append(holidays, sugestedAnswer.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return holidays, err
|
||||
}
|
||||
|
||||
// traverse рекурсивная функция, ищущая <script type="application/ld+json"> в структуре документа. Там внутри будет json
|
||||
// со списком праздников и это и есть цель нашего поиска.
|
||||
func traverse(n *html.Node, h *[]Holy) {
|
||||
// Если найденный элемент в поле Data содержит script (то есть это тэг <script>).
|
||||
if n.Type == html.ElementNode && n.Data == "script" {
|
||||
// Ищем атрибут type со значением application/ld+json.
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == "type" && attr.Val == "application/ld+json" {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.TextNode {
|
||||
// Добавлять рановато, надо вначале попробовать распарсить этот json
|
||||
// используя целевую структуру.
|
||||
var holiday Holy
|
||||
|
||||
if err := json.Unmarshal([]byte(c.Data), &holiday); err == nil {
|
||||
*h = append(*h, holiday)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
traverse(c, h)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
30
internal/holy/configLocations.go
Normal file
30
internal/holy/configLocations.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package holy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// defaultConfigFileLocations выдаёт варианты путей, по которым может находиться конфиг-файл.
|
||||
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) //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
configJSONPath := filepath.Dir(executablePath) + "/data/config.json"
|
||||
|
||||
locations = []string{
|
||||
"~/.tg-holy-bot.json",
|
||||
"/etc/tg-holy-bot.json",
|
||||
configJSONPath,
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
24
internal/holy/cronTask.go
Normal file
24
internal/holy/cronTask.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package holy
|
||||
|
||||
import (
|
||||
"tg-holy-bot/internal/log"
|
||||
)
|
||||
|
||||
// CronTask sends generated hiliday list to the channel.
|
||||
func CronTask() {
|
||||
log.Info("Cron Task triggered")
|
||||
|
||||
phrase, err := APIClient()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("Unable to get holidays: %s", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = Bot.SendMessage(phrase, Config.ChannelID, nil); err != nil {
|
||||
log.Errorf("Unable to send message to channel: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
36
internal/holy/globals.go
Normal file
36
internal/holy/globals.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package holy
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
var (
|
||||
// Bot структура-объект с тележным ботом.
|
||||
Bot echotron.API
|
||||
|
||||
// ConfigFileSize максимальная длина конфиг-файла, предполагается что 64кб хватит всем.
|
||||
ConfigFileSize int64 = 65535
|
||||
|
||||
// Loglevel уровень логгирования, по-умолчанию, info.
|
||||
Loglevel = "info"
|
||||
|
||||
// NetworkTimeout таймаут сетевого соединения.
|
||||
NetworkTimeout = 10
|
||||
|
||||
// Config структурка, содержащая данные конфига.
|
||||
Config *MyConfig
|
||||
|
||||
// Shutdown ставится в true, если мы получили сигнал на выключение.
|
||||
Shutdown = false
|
||||
|
||||
// SigChan канал, в который приходят уведомления для хэндлера сигналов от траппера сигналов.
|
||||
SigChan = make(chan os.Signal, 1)
|
||||
|
||||
// Run канал, чтобы залочить исполнение основной задачи, в него приходит значение в момент его закрытия и по задумке
|
||||
// это событие должно разлочить main(), после чего программа завершится.
|
||||
Run = make(chan bool, 1)
|
||||
)
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
116
internal/holy/readConfig.go
Normal file
116
internal/holy/readConfig.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package holy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"tg-holy-bot/internal/log"
|
||||
|
||||
"github.com/hjson/hjson-go"
|
||||
)
|
||||
|
||||
// ReadConfig читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге
|
||||
// нет.
|
||||
func ReadConfig() error {
|
||||
locations, err := defaultConfigFileLocations()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, location := range locations {
|
||||
fileInfo, err := os.Stat(location)
|
||||
|
||||
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём.
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Конфиг-файл длинноват для конфига, попробуем следующего кандидата.
|
||||
if fileInfo.Size() > 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]any
|
||||
)
|
||||
|
||||
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.Token == "" {
|
||||
return errors.New("Config option token is empty. Refusing to start") //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
if sampleConfig.ChannelID == 0 {
|
||||
return errors.New("Config option channel_id is empty. Refusing to start") //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
if sampleConfig.Loglevel == "" {
|
||||
sampleConfig.Loglevel = Loglevel
|
||||
|
||||
log.Warnf("Config option loglevel not set, fallback to %s", sampleConfig.Loglevel)
|
||||
}
|
||||
|
||||
if sampleConfig.Log == "" {
|
||||
log.Warn("Config option log is not set, fallback to stderr")
|
||||
}
|
||||
|
||||
if sampleConfig.When == "" {
|
||||
sampleConfig.When = "CRON_TZ=Europe/Moscow 01 00 * * *"
|
||||
|
||||
log.Warnf("Config option when is not set, fallback to %s", sampleConfig.When)
|
||||
}
|
||||
|
||||
if len(sampleConfig.UserAgents) != 31 {
|
||||
return errors.New("Amount of user_agent strings in config must be exactly 31! Refusing to start") //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
log.Infof("Using %s as config file", location)
|
||||
|
||||
Config = sampleConfig
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("Config was not loaded! Refusing to start") //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
32
internal/holy/sigHandler.go
Normal file
32
internal/holy/sigHandler.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package holy
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"tg-holy-bot/internal/log"
|
||||
)
|
||||
|
||||
// SigHandler хэндлер сигналов - сваливает из приложения.
|
||||
func SigHandler() {
|
||||
for {
|
||||
var s = <-SigChan
|
||||
switch s {
|
||||
case syscall.SIGINT:
|
||||
log.Info("Got SIGINT, quitting")
|
||||
case syscall.SIGTERM:
|
||||
log.Info("Got SIGTERM, quitting")
|
||||
case syscall.SIGQUIT:
|
||||
log.Info("Got SIGQUIT, quitting")
|
||||
|
||||
// Заходим на новую итерацию, если у нас "неинтересный" сигнал.
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
Shutdown = true
|
||||
|
||||
close(Run)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
37
internal/holy/types.go
Normal file
37
internal/holy/types.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package holy
|
||||
|
||||
type (
|
||||
// Holy структура, описывающая искомый json-чик.
|
||||
Holy struct {
|
||||
AtContext string `json:"@context"`
|
||||
AtType string `json:"@type"`
|
||||
MainEntity []struct {
|
||||
AtType string `json:"@type"`
|
||||
Name string `json:"name"`
|
||||
AcceptedAnswer struct {
|
||||
AtType string `json:"@type"`
|
||||
Text string `json:"text"`
|
||||
UpVoteCount int `json:"upvoteCount"`
|
||||
URL string `json:"url,omitempty"`
|
||||
} `json:"acceptedAnswer"`
|
||||
SuggestedAnswer []struct {
|
||||
AtType string `json:"@type"`
|
||||
Text string `json:"text"`
|
||||
UpVoteCount int `json:"upvoteCount"`
|
||||
URL string `json:"url,omitempty"`
|
||||
} `json:"suggestedAnswer"`
|
||||
} `json:"mainEntity"`
|
||||
}
|
||||
|
||||
// MyConfig структурка, описывающая конфиг.
|
||||
MyConfig struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
ChannelID int64 `json:"channel_id,omitempty"`
|
||||
When string `json:"when,omitempty"`
|
||||
Loglevel string `json:"loglevel,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
UserAgents []string `json:"user_agents,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
148
internal/log/log.go
Normal file
148
internal/log/log.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Package log implements logging facility as golang slog module wrapper.
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Log contains *[os.File] pointing to log file descriptor.
|
||||
Log *os.File
|
||||
)
|
||||
|
||||
// Init setup logger stuff.
|
||||
// level can be error, warn, info, debug if something other supplied info level selected.
|
||||
func Init(level, filename string) error {
|
||||
var (
|
||||
err error
|
||||
loglevel slog.Level
|
||||
)
|
||||
|
||||
if filename != "" {
|
||||
Log, err = os.OpenFile(filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to open log file: %w", err) //nolint: staticcheck // Looks like golangci-lint ignoring settings about fucking ST1005 check. We want readable errors despite what wants author of linter or assholes from go dev team.
|
||||
}
|
||||
} else {
|
||||
Log = os.Stderr
|
||||
}
|
||||
|
||||
// no panic, no trace.
|
||||
switch level {
|
||||
case "error":
|
||||
loglevel = slog.LevelError
|
||||
|
||||
case "warn":
|
||||
loglevel = slog.LevelWarn
|
||||
|
||||
case "info":
|
||||
loglevel = slog.LevelInfo
|
||||
|
||||
case "debug":
|
||||
loglevel = slog.LevelDebug
|
||||
|
||||
default:
|
||||
loglevel = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
// Use the ReplaceAttr function on the handler options
|
||||
// to be able to replace any single attribute in the log output.
|
||||
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { //nolint: revive
|
||||
// Check that we are handling the time key.
|
||||
if a.Key != slog.TimeKey {
|
||||
return a
|
||||
}
|
||||
|
||||
t := a.Value.Time()
|
||||
|
||||
// change the value from a time.Time to a String
|
||||
// where the string has the correct time format.
|
||||
a.Value = slog.StringValue(t.Format(time.DateTime))
|
||||
|
||||
return a
|
||||
},
|
||||
|
||||
Level: loglevel,
|
||||
}
|
||||
|
||||
slog.SetDefault(
|
||||
slog.New(
|
||||
slog.NewTextHandler(
|
||||
Log,
|
||||
opts,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Error passes message directly to [slog.Error()].
|
||||
func Error(message string) {
|
||||
slog.Error(message)
|
||||
}
|
||||
|
||||
// Errorf logs message on error log level and allows to supply arguments in printf() style.
|
||||
func Errorf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Warn passes message directly to [slog.Warn()].
|
||||
func Warn(message string) {
|
||||
slog.Warn(message)
|
||||
}
|
||||
|
||||
// Warnf logs message on warn log level and allows to supply arguments in printf() style.
|
||||
func Warnf(format string, a ...any) {
|
||||
slog.Warn(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Info passes message directly to [slog.Info()].
|
||||
func Info(message string) {
|
||||
slog.Info(message)
|
||||
}
|
||||
|
||||
// Infof logs message on info log level and allows to supply arguments in printf() style.
|
||||
func Infof(format string, a ...any) {
|
||||
slog.Info(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Debug passes message directly to [slog.Debug()].
|
||||
func Debug(message string) {
|
||||
slog.Debug(message)
|
||||
}
|
||||
|
||||
// Debugf logs message on debug log level and allows to supply arguments in printf() style.
|
||||
func Debugf(format string, a ...any) {
|
||||
slog.Debug(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
// Fatal passes message directly to [slog.Error()] and quits with status code 1.
|
||||
func Fatal(message string) {
|
||||
slog.Error(message)
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Fatalf logs message on error log level and allows to supply arguments in printf() style. After that it quits with
|
||||
// status code 1.
|
||||
func Fatalf(format string, a ...any) {
|
||||
slog.Error(fmt.Sprintf(format, a...))
|
||||
Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Close closes log file descriptor.
|
||||
func Close() {
|
||||
if err := Log.Close(); err != nil {
|
||||
msg := fmt.Sprintln("Unable to close log file: ", err)
|
||||
slog.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|
||||
Reference in New Issue
Block a user