Initial commit

This commit is contained in:
eleksir
2026-03-14 22:51:43 +03:00
commit 0a73bca056
105 changed files with 24685 additions and 0 deletions

View 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: */

View 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
View 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
View 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
View 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: */

View 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
View 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
View 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: */