0

Migrate to go project layout, replace logrus with slog, fix some errors

This commit is contained in:
eleksir
2025-10-20 01:21:26 +03:00
parent a7e7e35f11
commit e100b97ab5
47 changed files with 770 additions and 3713 deletions

View File

@@ -0,0 +1,70 @@
package anycollection
import (
"sync"
)
// Collection это sync.Map-ка, хранящая в себе произвольные итемы.
type Collection struct {
items sync.Map
close chan struct{}
}
// Item представляет собой интерфейс для хранения произвольных данных.
type item struct {
data any
}
// NewCollection создаёт инстанс коллекции.
func NewCollection() *Collection {
c := &Collection{
items: sync.Map{},
close: make(chan struct{}),
}
return c
}
// Get достаёт данные по заданному ключу из коллекции.
func (collection *Collection) Get(key any) (any, bool) {
obj, exists := collection.items.Load(key)
if !exists {
return nil, false
}
item := obj.(item)
return item.data, true
}
// Set сохраняет данные с заданным ключом в коллекцию.
func (collection *Collection) Set(key any, value any) {
collection.items.Store(key, item{
data: value,
})
}
// Range применяет функцию f ко всем ключам в коллекции.
func (collection *Collection) Range(f func(key, value any) bool) {
fn := func(key, value any) bool {
item := value.(item)
return f(key, item.data)
}
collection.items.Range(fn)
}
// Delete удаляет ключ и значение из коллекции данных.
func (collection *Collection) Delete(key any) {
collection.items.Delete(key)
}
// Close очищает и высвобождает ресурсы, занятые коллекцией.
func (collection *Collection) Close() {
collection.close <- struct{}{}
collection.items = sync.Map{}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

377
internal/jabber/commands.go Normal file
View File

@@ -0,0 +1,377 @@
package jabber
import (
"encoding/json"
"fmt"
"regexp"
"slices"
"strings"
"aleesa-jabber-go/internal/log"
"github.com/eleksir/go-xmpp"
)
func cmd(v xmpp.Chat) error {
var (
err error
message sMsg
data []byte
)
message.From = Config.Redis.MyChannel
message.Threadid = "" // Тредиков в jabber-е нету, поэтому это поле отправляем пустым
message.Message = v.Text
message.Plugin = Config.Redis.MyChannel
// Список команд, публично доступных, без каких-то специальных настроек
cmds := []string{"ping", "пинг", "пинх", "pong", "понг", "понх", "coin", "монетка", "roll", "dice", "кости",
"ver", "version", "версия", "хэлп", "halp", "kde", "кде", "lat", "лат", "friday", "пятница", "proverb",
"пословица", "пословиться", "fortune", "фортунка", "f", "ф", "anek", "анек", "анекдот", "buni", "cat",
"кис", "drink", "праздник", "fox", "лис", "frog", "лягушка", "horse", "лошадь", "лошадка", "monkeyuser",
"owl", "сова", "сыч", "rabbit", "bunny", "кролик", "snail", "улитка", "xkcd", "dig", "копать", "fish",
"fishing", "рыба", "рыбка", "рыбалка", "karma", "карма", "w", "п", "weather", "погода", "погодка", "погадка"}
// Список отключаемых команд проверяется в case-е про groupchat, чуть ниже
// Список "сложных" команд, это те, которые имеют ключевые данные на конце команды. Пока что это только погодка.
cmdsComplex := []string{"w ", "п ", "погода ", "погодка ", "погадка ", "weather "}
// Список команд бармэна. Основное отличие в том, что после команды ключевые данные могут быть, а могут и не быть.
// То есть рюмашку можно заказать себе или кому-то в чятике.
cmdsBartender := []string{"rum", "ром", "vodka", "водка", "beer", "пиво", "tequila", "текила", "whisky", "виски",
"absinthe", "абсент"}
// Есть ещё команда на изменение кармы. Это команда-суффикс ++ или --. Она проверяется по-месту, потому что она одна
// и вполне уникальна.
switch v.Type {
case "groupchat":
message.Mode = "public"
message.Chatid = strings.SplitN(v.Remote, "/", 2)[0]
userNick := strings.SplitN(v.Remote, "/", 2)[1]
if realJid := getRealJIDfromNick(v.Remote); realJid != "" {
message.Userid = realJid
} else {
message.Userid = v.Remote
}
message.Misc.Answer = 0
message.Misc.Fwdcnt = 0
message.Misc.Csign = Config.CSign
message.Misc.Username = userNick
message.Misc.Botnick = getBotNickFromRoomConfig(message.Chatid)
message.Misc.Msgformat = 0
msgLen := len(v.Text)
// TODO: а вот тут надо впилить парсер команд
if (msgLen > len(Config.CSign)) && (v.Text[:len(Config.CSign)] == Config.CSign) {
var (
cmd = v.Text[len(Config.CSign):]
room = message.Chatid
answer string
)
// И таких кейсов у нас минимум на команды !help и семейство команд !admin, а также на те команды, которые не всегда работают
switch {
case cmd == "help" || cmd == "помощь":
answer += fmt.Sprintf("%shelp | %sпомощь - это сообщение\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sanek | %sанек | %sанекдот - рандомный анекдот с anekdot.ru\n", Config.CSign, Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sbuni - комикс-стрип hapi buni\n", Config.CSign)
answer += fmt.Sprintf("%sbunny - кролик\n", Config.CSign)
answer += fmt.Sprintf("%srabbit | %sкролик - кролик\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%scat | %sкис - кошечка\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sdice | %sroll | %sкости - бросить кости\n", Config.CSign, Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sdig | %sкопать - заняться археологией\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sdrink | %sпраздник - какой сегодня праздник?\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sfish | %sfisher - порыбачить\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sрыба | %sрыбка | %sрыбалка - порыбачить\n", Config.CSign, Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sf | %sф - рандомная фраза из сборника цитат fortune_mod\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sfortune | %sфортунка - рандомная фраза из сборника цитат fortune_mod\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sfox | %sлис - лисичка\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sfriday | %sпятница - а не пятница ли сегодня?\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sfrog | %sлягушка - лягушка\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%shorse | %sлошадь | %sлошадка - лошадка\n", Config.CSign, Config.CSign, Config.CSign)
answer += fmt.Sprintf("%skarma фраза - посмотреть карму фразы\n", Config.CSign)
answer += fmt.Sprintf("%sкарма фраза - посмотреть карму фразы\n", Config.CSign)
answer += fmt.Sprintln("фраза++ | фраза-- - повысить или понизить карму фразы")
answer += fmt.Sprintf("%slat | %sлат - сгенерировать фразу из крылатого латинского выражения\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%smonkeyuser - комикс-стрип MonkeyUser\n", Config.CSign)
answer += fmt.Sprintf("%sowl | %sсова - сова\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sping | %sпинг - попинговать бота\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sproverb | %sпословица - рандомная русская пословица\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%ssnail | %sулитка - улитка\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%ssome_brew - выдать соответствующий напиток, бармен может налить rum, ром, vodka, водку, tequila, текила\n", Config.CSign)
answer += fmt.Sprintf("%sver | %sversion - написать что-то про версию ПО\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sверсия - написать что-то про версию ПО\n", Config.CSign)
answer += fmt.Sprintf("%sw <город> | %sп <город> - погода в городе\n", Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sxkcd - комикс-стрип с xkcb.ru\n", Config.CSign)
// Для овнера или админа надо показывать команду admin
if isMucAdmin(v.Remote) {
answer += fmt.Sprintf("%sadmin - настройки некоторых плагинов бота для комнаты\n", Config.CSign)
}
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
case cmd == "admin":
if isMucAdmin(v.Remote) {
answer += fmt.Sprintf("%sadmin oboobs # - где 1 - вкл, 0 - выкл плагина oboobs\n", Config.CSign)
answer += fmt.Sprintf("%sadmin oboobs - показываем ли сисечки по просьбе участников чата (команды %stits, %stities, %sboobs, %sboobies, %sсиси, %sсисечки)\n", Config.CSign, Config.CSign, Config.CSign, Config.CSign, Config.CSign, Config.CSign, Config.CSign)
answer += fmt.Sprintf("%sadmin obutts # - где 1 - вкл, 0 - выкл плагина obutts\n", Config.CSign)
answer += fmt.Sprintf("%sadmin obutts - показываем ли попки по просьбе участников чата (команды %sass, %sbutt, %sbooty, %sпопа, %sпопка)\n", Config.CSign, Config.CSign, Config.CSign, Config.CSign, Config.CSign, Config.CSign)
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
return err
case cmd == "admin oboobs":
if isMucAdmin(v.Remote) {
switch getSetting(room, "oboobs") {
case "0":
answer = "Плагин oboobs выключен"
case "1":
answer = "Плагин oboobs включен"
case "":
_ = saveSetting(room, "oboobs", "0")
answer = "Плагин oboobs выключен"
default:
answer = "Плагин oboobs выключен"
}
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "admin oboobs 1":
if isMucAdmin(v.Remote) {
err := saveSetting(room, "oboobs", "1")
if err != nil {
answer = "Плагин oboobs всё ещё выключен"
} else {
answer = "Плагин oboobs включен"
}
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "admin oboobs 0":
if isMucAdmin(v.Remote) {
_ = saveSetting(room, "oboobs", "0")
answer = "Плагин oboobs выключен"
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "admin obutts":
if isMucAdmin(v.Remote) {
switch getSetting(room, "obutts") {
case "0":
answer = "Плагин obutts выключен"
case "1":
answer = "Плагин obutts включен"
case "":
_ = saveSetting(room, "obutts", "0")
answer = "Плагин obutts выключен"
default:
answer = "Плагин obutts выключен"
}
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "admin obutts 1":
if isMucAdmin(v.Remote) {
err := saveSetting(room, "obutts", "1")
if err != nil {
answer = "Плагин obutts всё ещё выключен"
} else {
answer = "Плагин obutts включен"
}
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "admin obutts 0":
if isMucAdmin(v.Remote) {
_ = saveSetting(room, "obutts", "0")
answer = "Плагин obutts выключен"
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
case cmd == "butt" || cmd == "booty" || cmd == "ass" || cmd == "попа" || cmd == "попка":
if getSetting(room, "obutts") != "1" {
return err
}
message.Misc.Answer = 1
case cmd == "tits" || cmd == "boobs" || cmd == "tities" || cmd == "boobies" || cmd == "сиси" || cmd == "сисечки":
if getSetting(room, "oboobs") != "1" {
return err
}
message.Misc.Answer = 1
// Отлавливаем команды и им проставляем message.Misc.Answer = 1
default:
done := false
// Простые команды
for _, command := range cmds {
if cmd == command {
done = true
message.Misc.Answer = 1
break
}
}
// Сложные команды, например, погода
if !done {
for _, command := range cmdsComplex {
cmdLen := len(cmd)
if cmdLen > len(command) && cmd[0:len(command)] == command {
done = true
message.Misc.Answer = 1
break
}
}
}
// Сам себе заказал выпивку
if !done {
for _, command := range cmdsBartender {
if cmd == command {
done = true
message.Misc.Answer = 1
break
}
}
}
// Заказал выпивку кому-то ещё
if !done {
re := regexp.MustCompile(" +")
pile := re.Split(cmd, 2)
names := getMucNames(room)
for _, command := range cmdsBartender {
if pile[0] == command {
userNick := strings.TrimSpace(pile[1])
if userNick != "" {
if slices.Contains(names, userNick) {
message.Misc.Username = userNick
} else {
answer := fmt.Sprintf("Я тут не вижу участника с ником %s", userNick)
_, err = Talk.Send(xmpp.Chat{Remote: room, Type: v.Type, Text: answer}) //nolint:exhaustruct
return err
}
message.Misc.Answer = 1
break
}
}
}
}
}
}
// Ну, вот мы и добрались досюда, и... нам осталось понять, не обращаются ли к боту?
// Дело в том, что ответа требуют только команды и обращения к боту по нику
// или jid-у, в данном случае, по нику. А всё остальное должно попадать в craniac-а и оставаться там в качестве
// материала для бредогенератора.
// Попробуем выискать изменение кармы
// ++ или -- на конце фразы - это наш кандидат
if msgLen > len("++") {
if v.Text[msgLen-len("--"):msgLen] == "--" || v.Text[msgLen-len("++"):msgLen] == "++" {
// Предполагается, что менять карму мы будем для одной фразы, то есть для 1 строки
if len(strings.Split(v.Text, "\n")) == 1 {
// Костыль для кармы
message.Misc.Answer = 1
}
}
}
// Предполагается что в канале бот должен отвечать, только если к нему обратились, либо это была команда
botNick := getBotNickFromRoomConfig(message.Chatid)
if regexp.MustCompile(regexp.QuoteMeta(botNick)).Match([]byte(message.Message)) {
message.Misc.Answer = 1
}
message.Message = v.Text
data, err = json.Marshal(message)
if err != nil {
return fmt.Errorf("unable to to serialize message for redis: %w", err)
}
case "chat":
message.Mode = "private"
message.Chatid = v.Remote
// Jid у нас уникален, а вот nick может, во-первых, разниться от конфы к конфе, а во-вторых, его может взять
// себе другой пользователь конфы, пока оригинальный владелец ника отсутствует. Но мы предполагаем, что если
// комната анонимная, то владелец комнаты, приглашая бота, согласен на некоторые компромиссы.
if realJID := getRealJIDfromNick(v.Remote); realJID != "" {
message.Userid = realJID
} else {
message.Userid = v.Remote
}
message.Misc.Answer = 1
message.Misc.Fwdcnt = 0
message.Misc.Csign = Config.CSign
message.Misc.Username = message.Userid
message.Misc.Botnick = Config.Jabber.Nick
message.Misc.Msgformat = 0
data, err = json.Marshal(message)
if err != nil {
return fmt.Errorf("unable to to serialize message for redis: %w", err)
}
}
// Заталкиваем наш json в редиску
if err := RedisClient.Publish(Ctx, Config.Redis.Channel, data).Err(); err != nil {
return fmt.Errorf("unable to send data to redis channel %s: %w", Config.Redis.Channel, err)
}
log.Debugf("Sent msg to redis channel %s: %s", Config.Redis.Channel, string(data))
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,645 @@
package jabber
import (
"encoding/json"
"encoding/xml"
"fmt"
"sort"
"strings"
"time"
"aleesa-jabber-go/internal/log"
"github.com/davecgh/go-spew/spew"
"github.com/eleksir/go-xmpp"
"golang.org/x/exp/slices"
)
// ParseEvent парсит ивенты, прилетающие из модуля xmpp (изменения presence, фразы участников, ошибки).
func ParseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo
LastServerActivity = time.Now().Unix()
switch v := e.(type) {
// Сообщение в чяти
case xmpp.Chat:
log.Debugf("Looks like message, ChatType: %s, From: %s, Subject: %s Text: %s",
v.Type, v.Remote, v.Subject, v.Text)
// Топик чятика присылается в виде сообщения с subject, но без text
// В то же время сообщения от людей приходят с пустым subject, но с заполненным text
if v.Text != "" {
// Чятики бывают групповые и не групповые, от этого зависит Remote, куда направлять сообщение
switch v.Type { //nolint:wsl,whitespace
// Групповой чятик
case "groupchat":
log.Debugf("Message from public chat: %s", v.Text)
room := strings.SplitN(v.Remote, "/", 2)[0]
nick := strings.SplitN(v.Remote, "/", 2)[1]
if nick == getBotNickFromRoomConfig(room) {
log.Debug("Skipping message from myself")
return
}
if err := cmd(v); err != nil {
GTomb.Kill(err)
return
}
LastActivity = LastServerActivity
if muc, _ := strings.CutSuffix(v.Remote, "/"); muc != "" {
LastMucActivity.Set(muc, LastServerActivity)
}
// Приватный чятик.
case "chat":
// Здесь у нас может быть 2 вида From:
// slackware-current@conference.jabber.ru/eleksir - это если сообщение из публичного чятика
// eleksir@jabber.ru/array.lan - это если мы работаем через ростер
log.Debugf("Private message: %s", v.Text)
if err := cmd(v); err != nil {
GTomb.Kill(err)
return
}
LastActivity = LastServerActivity
// Внезапно, ошибки. По идее они должны ассоциироваться с отправляемыми сообщениями, но по ходу это
// не реализовано, поэтому мы получаем ошибки в форме отдельного чятика.
case "error":
// Это ошибка со стороны сервера, видимо, в ответ на наши действия и поскольку такие вещи происходят
// асинхронно, связать их с конкретными нашими действиями довольно сложно.
log.Warn(spew.Sdump(e))
}
}
// IQ-сообщение - пинг, понг, что-то ещё...
case xmpp.IQ:
// По правилам IQ обязательно должна содержать ID, причём не пустой.
if v.ID == "" {
log.Info("Got an IQ stanza with empty id, discarding")
return
}
switch v.Type {
case xmpp.IQTypeGet:
log.Debug("Looks like IQ get query")
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(RoomsConnected, muc) {
LastMucActivity.Set(muc, LastServerActivity)
}
}
var (
iqStruct jabberSimpleIqGetQuery
parseError bool
)
if err := xml.Unmarshal(v.Query, &iqStruct); err == nil {
switch iqStruct.Xmlns { //nolint:wsl,whitespace
// Запрос номера версии приложения
case "jabber:iq:version":
log.Infof("Got IQ get request for version from %s", v.From)
if id, err := Talk.IqVersionResponse(
v,
"aleesa-jabber-go",
"4.с_чем-то",
"линупс",
); err != nil {
err := fmt.Errorf(
"unable to send version info to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
// Нам прислали попингуй
case "urn:xmpp:ping":
log.Infof("Got IQ get request for pong from %s", v.From)
if id, err := Talk.PingResponse(v); err != nil {
err := fmt.Errorf(
"unable to send pong to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
// У нас запросили время последней активности. В нашем случае это либо время запуска клиента, либо время
// последней фразы в чяти. Xep-0012.
case "jabber:iq:last":
log.Infof("Got IQ get last activity time request from %s", v.From)
if id, err := Talk.JabberIqLastResponse(v, LastActivity); err != nil {
err := fmt.Errorf(
"unable to send last activity time to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
// Запрос на список поддерживаемых фич - версия, локальное время, итд
case xmpp.XMPPNS_DISCO_INFO:
log.Infof("Got IQ get disco#info request from %s", v.From)
answer := "<query xmlns=\"http://jabber.org/protocol/disco#info\">"
answer += "<feature var=\"jabber:iq:version\" />"
answer += "<feature var=\"urn:xmpp:time\" />"
answer += "<feature var=\"urn:xmpp:ping\" />"
answer += "<feature var=\"jabber:iq:last\" />"
answer += "<feature var=\"http://jabber.org/protocol/disco#info\" />"
answer += "</query>"
if id, err := Talk.RawInformation(
v.To,
v.From,
v.ID,
xmpp.IQTypeResult,
answer,
); err != nil {
err := fmt.Errorf(
"unable to send disco#info to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
// Спрашивают список команд, чтобы поуправлять этим клиентом (xep-0030)
case xmpp.XMPPNS_DISCO_ITEMS:
log.Infof("Got IQ get disco#items request from %s, answer service unavailable", v.From)
if _, err := Talk.ErrorServiceUnavailable(
v,
"http://jabber.org/protocol/disco#info",
"http://jabber.org/protocol/commands",
); err != nil {
err := fmt.Errorf(
"unable to send disco#items to jabber server: id=%s, err=%w",
v.ID,
err,
)
GTomb.Kill(err)
return
}
parseError = false
// Запрашивают что-то, о чём мы не имеем представления
default:
log.Info("Got an unknown IQ get request, discarding")
log.Info(spew.Sdump(e))
return
}
} else {
parseError = true
}
// Попробуем распарсить входящий запрос как urn:xmpp:time
if parseError {
var iqStruct jabberTimeIqGetQuery
if err := xml.Unmarshal(v.Query, &iqStruct); err == nil {
switch iqStruct.Xmlns {
case "urn:xmpp:time":
log.Infof("Got IQ get time request from %s", v.From)
if id, err := Talk.UrnXMPPTimeResponse(v, "+00:00"); err != nil {
err := fmt.Errorf(
"unable to send urn:xmpp:time to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
default:
log.Info("Got bogus IQ get time request, discarding")
log.Info(spew.Sdump(e))
return
}
} else {
parseError = true
}
}
// Попробуем распарсить входящий запрос как http://jabber.org/protocol/pubsub
if parseError {
var iqStruct jabberPubsubIQGetQuery
if err := xml.Unmarshal(v.Query, &iqStruct); err == nil {
switch iqStruct.Xmlns {
case xmpp.XMPPNS_PUBSUB:
log.Infof("Got IQ get pubsub request from %s, answer feature unimplemented", v.From)
if id, err := Talk.ErrorNotImplemented(
v,
"http://jabber.org/protocol/pubsub#errors",
"subscribe",
); err != nil {
err := fmt.Errorf("unable to send pubsub feature unimplemented to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
parseError = false
default:
log.Info("Got unknown IQ get pubsub something request, discarding")
log.Info(spew.Sdump(e))
parseError = true //nolint:wsl
}
} else {
parseError = true
}
}
// Попробуем распарсить входящий запрос как urn:xmpp:ping он приходит при MUC Self-Ping (Schrödinger's Chat)
// Предполагается, что такие респонсы должны приходить только для пинга без серверной оптимизации.
// Хотя идейно мы не поддерживаем работу без серверной оптимизации, но на пинг ответим, нам несложно.
if parseError {
var iqStruct jabberIqPing
if err := xml.Unmarshal(v.Query, &iqStruct); err == nil {
log.Debugf("Got IQ get request (actually, response) for MUC Self-Ping from %s", v.From)
if id, err := Talk.PingResponse(v); err != nil {
err := fmt.Errorf(
"unable to send pong to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
}
parseError = false
}
// Не знаю, как парсить... залоггируем это дело и пойдём дальше
if parseError {
log.Infof("Does not look like parsable via iqStruct")
log.Info(spew.Sdump(e))
}
case xmpp.IQTypeResult:
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(RoomsConnected, muc) {
LastMucActivity.Set(muc, LastServerActivity)
}
}
switch {
// Похоже на pong от сервера (по стандарту в ответе нету query, но go-xmpp нам подсовывает это)
case v.From == Config.Jabber.Server && v.To == Talk.JID() && string(v.Query) == "<XMLElement></XMLElement>":
log.Debugf("Got S2C pong answer from %s to %s", v.From, v.To)
ServerPingTimestampRx = time.Now().Unix() //nolint:wsl
// Похоже на понг второй стадии xep-0410 MUC-Ping-а, который у нас не реализован
case v.To == Talk.JID() && string(v.Query) == "<XMLElement></XMLElement>":
mucNameMatch := slices.Contains(RoomsConnected, v.From)
if mucNameMatch {
log.Debugf("Got server-optimized MUC pong answer (xep-0410) from %s to %s", v.From, v.To)
} else {
mucNickMatch := false
for _, room := range RoomsConnected {
mucNick := fmt.Sprintf("%s/%s", room, getBotNickFromRoomConfig(room))
if v.From == mucNick {
mucNickMatch = true
break
}
}
// Поскольку никакой логики у нас на этот счёт не предусмотрено, то просто пропускаем ответ.
if mucNickMatch {
log.Debugf("Got 2-nd stage MUC pong answer (xep-0410) from %s to %s", v.From, v.To)
} else {
log.Infof("Got unknown pong from %s to %s", v.From, v.To)
log.Debug(spew.Sdump(e))
}
}
default:
log.Info("Got an IQ result. Dunno how deal with it, discarding")
log.Debug(spew.Sdump(e))
}
// Этот бот не управляется со стороны сервера, поэтому все попытки порулить игнорируем.
case xmpp.IQTypeSet:
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(RoomsConnected, muc) {
LastMucActivity.Set(muc, LastServerActivity)
}
}
log.Info("Got an IQ request for set something. Answer not implemented")
if id, err := Talk.ErrorNotImplemented(
v,
"http://jabber.org/protocol/commands",
xmpp.IQTypeSet,
); err != nil {
err := fmt.Errorf(
"unable to send set feature unimplemented to jabber server: id=%s, err=%w",
id,
err,
)
GTomb.Kill(err)
return
}
log.Debug(spew.Sdump(e))
// Нам прилетело сообщение об ошибке.
case xmpp.IQTypeError:
// Если сервер не хочет пинговаться и отвечает ошибкой на пинг, то наверно он не умеет в пинги,
// хотя если мы его пингуем, значит он анонсировал такой capability. Вот, засранец!
var (
iqPingStruct jabberIqPing
parseError = true
)
if err := xml.Unmarshal(v.Query, &iqPingStruct); err == nil {
if iqPingStruct.Xmlns == "urn:xmpp:ping" {
if v.From == Config.Jabber.Server {
msg := "Server announced that it can answer c2s ping, but gives us an error to such query, "
msg += "fallback to keepalive whitespace pings"
log.Error(msg)
ServerCapsList.Set("urn:xmpp:ping", false)
} else {
log.Errorf("Got 'ping unsupported' message from: %s to: %s", v.From, v.To)
}
} else {
log.Error("Iq parsed as ping, but does not belong to xmlns urn:xmpp:ping")
log.Error(spew.Sdump(e))
}
parseError = false
}
// Это у нас пинг xep-0410 и мы не в комнате, предполагается, что надо бы заджойниться.
if parseError {
var iqErrorCancelNotAcceptable jabberIqErrorCancelNotAcceptable
if err := xml.Unmarshal(v.Query, &iqErrorCancelNotAcceptable); err == nil {
if v.To == Talk.JID() {
var (
room = strings.SplitN(v.From, "/", 2)[0]
nick = strings.SplitN(v.From, "/", 2)[1]
)
if slices.Contains(RoomsConnected, iqErrorCancelNotAcceptable.By) && nick == getBotNickFromRoomConfig(room) {
log.Errorf(
"Got Iq error message from: %s to: %s. Looks like i'm not in MUC anymore",
v.From, v.To,
)
time.Sleep(time.Duration(Config.Jabber.MucRejoinDelay) * time.Second)
if _, err := Talk.JoinMUCNoHistory(iqErrorCancelNotAcceptable.By, nick); err != nil {
err := fmt.Errorf(
"looks like connection to server also lost err=%w",
err,
)
GTomb.Kill(err)
return
}
} else {
log.Error(
"looks like message parsed as jabberIqErrorCancelNotAcceptable but we're not in given room",
)
log.Error(spew.Sdump(e))
}
} else {
log.Error(
"Looks like message parsed as jabberIqErrorCancelNotAcceptable but addressed not to us",
)
log.Error(spew.Sdump(e))
}
parseError = false
}
}
if parseError {
log.Error("Unhandled IQ Error message")
log.Error(spew.Sdump(e))
}
// Нам прилетело что-то неизвестное из семейства IQ stanza.
default:
log.Info("Got an unknown IQ request. Dunno how deal with it, discarding")
log.Info(spew.Sdump(e))
}
// Смена статуса участника
case xmpp.Presence:
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(RoomsConnected, muc) {
LastMucActivity.Set(muc, LastServerActivity)
}
}
if v.Type == xmpp.IQTypeError {
// Это событие происходит, когда из чятика выходит другой инстанс клиента.
// Такое бывает, когда 2 инстанса с одинаковым ресурсом, например, начинают "драться" за возможность
// остаться на сервере. Ситуация, которую допускать нельзя, на самом деле, потому что рано или поздно такого
// клиента забанят из-за спама "вошёл-вышел".
// Однако, это не значит, что такую ситуацию мы не должны корректным образом обрабатывать.
if v.Type == "unavailable" {
// Считаем, что мы больше не в комнате, поэтому не знаем, кто там есть.
RoomPresences.Delete(v.From)
log.Error("Presence notification - looks like another instance of client leaves room")
if slices.Contains(RoomsConnected, v.From) {
go joinMuc(v.From) //nolint: errcheck
}
} else {
log.Errorf("Presence notification, Type: %s, From: %s, Show: %s, Status: %s",
v.Type, v.From, v.Show, v.Status)
log.Error(spew.Sdump(v))
}
} else {
log.Infof(
"Presence notification, Type: %s, From: %s, To: %s Show: %s, Status: %s, Affiliation: %s, Role: %s, JID: %s",
v.Type, v.From, v.To, v.Show, v.Status, v.Affiliation, v.Role, v.JID,
)
room := strings.SplitN(v.From, "/", 2)[0]
nick := strings.SplitN(v.From, "/", 2)[1]
if nick == "" {
log.Infof("Presence stanza contains incorrect from attribute: %s", v.From)
return
}
// Это наш собственный Presence
if v.Show == "" && v.Status == "" {
if nick == Config.Jabber.Nick {
RoomsConnected = append(RoomsConnected, room)
// На всякий случай дедуплицируем список комнат, к которым мы заджойнились.
sort.Strings(RoomsConnected)
slices.Compact(RoomsConnected)
}
}
switch v.Role {
// Участник ушёл
case "none":
if presenceJSONInterface, present := RoomPresences.Get(room); present {
presenceJSONStrings := interfaceToStringSlice(presenceJSONInterface)
var newPresenceJSONStrings []string
for _, presenceJSONstring := range presenceJSONStrings {
var p xmpp.Presence
_ = json.Unmarshal([]byte(presenceJSONstring), &p)
if p.From == v.From {
continue
}
newPresenceJSONStrings = append(newPresenceJSONStrings, presenceJSONstring)
}
RoomPresences.Set(room, newPresenceJSONStrings)
}
// Участник пришёл
default:
var (
presenceJSONStrings []string
newPresenceJSONStrings []string
presenceJSONBytes []byte
)
if presenceJSONInterface, present := RoomPresences.Get(room); present {
presenceJSONStrings = interfaceToStringSlice(presenceJSONInterface)
}
for _, presenceJSONString := range presenceJSONStrings {
var p xmpp.Presence
_ = json.Unmarshal([]byte(presenceJSONString), &p)
// Если находим, что у нас уже есть клиент с таким же From, то есть полным nick-ом (для grouchat)
// просто замещаем его.
if p.From == v.From {
continue
}
newPresenceJSONStrings = append(newPresenceJSONStrings, presenceJSONString)
}
presenceJSONBytes, _ = json.Marshal(v) //nolint:errchkjson
newPresenceJSONStrings = append(newPresenceJSONStrings, string(presenceJSONBytes))
RoomPresences.Set(room, newPresenceJSONStrings)
}
}
// Ответ на запрос поддерживаемых фич, который "http://jabber.org/protocol/disco#info"
case xmpp.DiscoResult:
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(RoomsConnected, muc) {
LastMucActivity.Set(muc, LastServerActivity)
}
}
// Я видел 2 типа disco result и они отличались только []identities. Попробуем вытащить известный identity
// TODO: проверять адресата v.To
for _, ident := range v.Identities {
switch ident.Category {
case "server":
// Конкретно сейчас нас интересует только поддержка c2s ping
for _, feature := range v.Features {
log.Debugf("Server %s announced that it supports feature: %s", v.From, feature)
ServerCapsList.Set(feature, true)
}
ServerCapsQueried = true
case "conference":
mucCaps := make(map[string]bool)
for _, feature := range v.Features {
log.Debugf("MUC %s announced that it supports feature: %s", v.From, feature)
mucCaps[feature] = true //nolint:wsl
}
MucCapsList.Set(v.From, mucCaps)
case "pubsub":
log.Debugf("PubSub component %s reply to disco#info, skipping", v.From)
default:
log.Debug("Got unknown reply to disco#info")
log.Debug(spew.Sdump(e))
}
}
// Это что-то неизвестное, подампим событие в лог
default:
log.Info(spew.Sdump(e))
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,90 @@
package jabber
import (
"aleesa-jabber-go/internal/anycollection"
"context"
"os"
"github.com/cockroachdb/pebble"
"github.com/eleksir/go-xmpp"
"github.com/go-redis/redis/v8"
"gopkg.in/tomb.v2"
)
// Config - это у нас глобальная штука :) .
var Config myConfig
// Статичные пути, по которым у нас лежат конфиги. При запуске сюда дописываются и динамические пути тоже.
var ConfigLocations = []string{
"~/.aleesa-jabber-go.json",
"~/aleesa-jabber-go.json",
"/etc/aleesa-jabber-go.json",
}
// To break circular message forwarding we must set some sane default, it can be overridden via Config.
var forwardMax int64 = 5
// Ставится в true, если мы получили сигнал на выключение.
var Shutdown = false
// Чтобы не организовывать драку за установку коннекта.
var connecting = false
// Глобальное состояние соединения.
var IsConnected = false
// Канал, в который приходят уведомления для хэндлера сигналов от траппера сигналов.
var SigChan chan os.Signal
// Основной инстанс xmpp-клиента.
var Talk *xmpp.Client
// Опции подключения к xmpp-серверу.
var Options *xmpp.Options
// Список комнат, в которых находится бот.
var RoomsConnected []string
// Время последней активности, нужно для jabber:iq:last.
var LastActivity int64
// Время последней активности, нужно для c2s пингов - посылаем пинги, только если давненько ничего не приходило с
// сервера.
var LastServerActivity int64
// Время последней активности MUC-ов, нужно для пингов - посылаем пинги, только если давненько ничего не приходило из
// muc-ов.
var LastMucActivity *anycollection.Collection
// Получен ли ответ на запрос disco#info к серверу.
var ServerCapsQueried bool
// sync.Map-ка с капабилити сервера.
var ServerCapsList *anycollection.Collection
// sync.Map-ка с комнатами и их capability.
var MucCapsList *anycollection.Collection
// Время, когда был отправлен c2s ping.
var ServerPingTimestampTx int64
// Время, когда был принят s2c pong.
var ServerPingTimestampRx int64
// Объектик для хранения стейта утилизатора горутинок.
var GTomb tomb.Tomb
// sync.Map-ка со списком участников конференций (в json-формате, согласно структуре xmpp.Presence, "room".[]json).
var RoomPresences *anycollection.Collection
// Объектики клиента-редиски.
var RedisClient *redis.Client
var Subscriber *redis.PubSub
// Мапка с открытыми дескрипторами баз с настройками.
var settingsDB = make(map[string]*pebble.DB)
// Main context.
var Ctx = context.Background()
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

455
internal/jabber/lib.go Normal file
View File

@@ -0,0 +1,455 @@
package jabber
import (
"encoding/json"
"fmt"
"math/rand"
"reflect"
"strings"
"time"
"aleesa-jabber-go/internal/log"
"github.com/eleksir/go-xmpp"
"golang.org/x/exp/slices"
)
// EstablishConnection устанавливает соединение с jabber-сервером.
func EstablishConnection() error {
var err error
if connecting && !IsConnected {
return nil
}
// Проставляем глобальные переменные.
connecting = true
IsConnected = false
RoomsConnected = make([]string, 0)
Talk, err = Options.NewClient()
if err != nil {
return fmt.Errorf("unable to connect to %s: %w", Options.Host, err)
}
// По идее keepalive должен же проходить только, если мы уже на сервере, так?
if _, err := Talk.SendKeepAlive(); err != nil {
return fmt.Errorf("try to send initial KeepAlive, got error: %w", err)
}
log.Info("Connected")
// Джойнимся к чятикам, но делаем это в фоне, чтобы не блочиться на ошибках, например, если бота забанили
for _, room := range Config.Jabber.Channels {
GTomb.Go(func() error { return joinMuc(room.Name) })
}
GTomb.Go(func() error { return RotateStatus("") })
LastActivity = time.Now().Unix()
connecting = false
IsConnected = true
log.Debugf("Sending disco#info to %s", Config.Jabber.Server)
_, err = Talk.DiscoverInfo(Talk.JID(), Config.Jabber.Server)
if err != nil {
return fmt.Errorf("unable to send disco#info to jabber server: %w", err)
}
return nil
}
// joinMu джойнится к конференциям/каналам/комнатам в джаббере.
func joinMuc(room string) error {
log.Debugf("Sending disco#info from %s to %s", Talk.JID(), room)
if _, err := Talk.DiscoverInfo(Talk.JID(), room); err != nil {
return fmt.Errorf("unable to send disco#info to MUC %s: %w", room, err)
}
// Ждём, пока muc нам вернёт список фичей.
for i := 0; i < (20 * int(Config.Jabber.ConnectionTimeout)); i++ {
var (
myRoom interface{}
supported bool
exist bool
)
time.Sleep(50 * time.Millisecond)
if myRoom, exist = MucCapsList.Get(room); !exist {
// Пока не задискаверилась
continue
}
if supported, exist = myRoom.(map[string]bool)["muc_unsecured"]; exist {
if supported {
break
}
log.Infof("Unable to join to password-protected room. Don't know how to enter passwords :)")
return nil
}
}
// Пытаемся зайти в комнату
if _, err := Talk.JoinMUCNoHistory(room, getBotNickFromRoomConfig(room)); err != nil {
return fmt.Errorf("unable to join to MUC: %s, %w", room, err)
}
log.Infof("Joining to MUC: %s", room)
// Ждём, когда прилетит presence из комнаты, тогда мы точно знаем, что мы вошли.
entered := false
for i := 0; i < (20 * int(Config.Jabber.ConnectionTimeout)); i++ {
time.Sleep(50 * time.Millisecond)
if slices.Contains(RoomsConnected, room) {
entered = true
break
}
}
if !entered {
log.Errorf(
"Unable to enter to MUC %s, join timeout after %d seconds (server does not return my presence for this room)",
room,
20*int(Config.Jabber.ConnectionTimeout)+1,
)
return nil
}
// Вот теперь точно можно слать статус.
log.Infof("Joined to MUC: %s", room)
GTomb.Go(func() error { return RotateStatus(room) })
return nil
}
// ProbeServerLiveness проверяет живость соединения с сервером. Для многих серверов обязательная штука, без которой
// они выкидывают клиента через некоторое время неактивности.
func ProbeServerLiveness() error { //nolint:gocognit
for {
for {
if Shutdown {
return nil
}
sleepTime := time.Duration(Config.Jabber.ServerPingDelay) * 1000 * time.Millisecond
sleepTime += time.Duration(rand.Int63n(1000*Config.Jabber.PingSplayDelay)) * time.Millisecond
time.Sleep(sleepTime)
if !IsConnected {
continue
}
// Пингуем, только если не было никакой активности в течение > Config.Jabber.ServerPingDelay,
// в худшем случе это будет ~ (Config.Jabber.PingSplayDelay * 2) + Config.Jabber.PingSplayDelay
// if (time.Now().Unix() - lastServerActivity) < (Config.Jabber.ServerPingDelay + Config.Jabber.PingSplayDelay) {
// continue
// }
if ServerCapsQueried { // Сервер ответил на disco#info
var (
value interface{}
exist bool
)
value, exist = ServerCapsList.Get("urn:xmpp:ping")
switch {
// Сервер анонсировал, что умеет в c2s пинги
case exist && value.(bool):
// Таймаут c2s пинга. Возьмём сумму задержки между пингами, добавим таймаут коннекта и добавим
// максимальную корректировку разброса.
txTimeout := Config.Jabber.ServerPingDelay + Config.Jabber.ConnectionTimeout
txTimeout += Config.Jabber.PingSplayDelay
rxTimeout := txTimeout
rxTimeAgo := time.Now().Unix() - ServerPingTimestampRx
if ServerPingTimestampTx > 0 { // Первая пуля от нас ушла...
switch {
// Давненько мы не получали понгов от сервера, вероятно, соединение с сервером утеряно?
case rxTimeAgo > (rxTimeout * 2):
err := fmt.Errorf( //nolint:goerr113
"stall connection detected. No c2s pong for %d seconds",
rxTimeAgo,
)
return err
// По-умолчанию, мы отправляем c2s пинг
default:
log.Debugf("Sending c2s ping from %s to %s", Talk.JID(), Config.Jabber.Server)
if err := Talk.PingC2S(Talk.JID(), Config.Jabber.Server); err != nil {
return err
}
ServerPingTimestampTx = time.Now().Unix()
}
} else { // Первая пуля пока не вылетела, отправляем
log.Debugf("Sending first c2s ping from %s to %s", Talk.JID(), Config.Jabber.Server)
if err := Talk.PingC2S(Talk.JID(), Config.Jabber.Server); err != nil {
return err
}
ServerPingTimestampTx = time.Now().Unix()
}
// Сервер не анонсировал, что умеет в c2s пинги
default:
log.Debug("Sending keepalive whitespace ping")
if _, err := Talk.SendKeepAlive(); err != nil {
return err
}
}
} else { // Сервер не ответил на disco#info
log.Debug("Sending keepalive whitespace ping")
if _, err := Talk.SendKeepAlive(); err != nil {
return err
}
}
}
}
}
// ProbeMUCLiveness Пингует MUC-и, нужно для проверки, что клиент ещё находится в MUC-е.
func ProbeMUCLiveness() { //nolint:gocognit
for {
for {
for _, room := range RoomsConnected {
var (
exist bool
lastActivityTS interface{}
)
// Если записи про комнату нету, то пинговать её бессмысленно.
if lastActivityTS, exist = LastMucActivity.Get(room); !exist {
continue
}
// Если время последней активности в чятике не превысило
// Config.Jabber.ServerPingDelay + Config.Jabber.PingSplayDelay, ничего не пингуем.
if (time.Now().Unix() - lastActivityTS.(int64)) < (Config.Jabber.ServerPingDelay + Config.Jabber.PingSplayDelay) {
continue
}
/* Пинг MUC-а по сценарию без серверной оптимизации мы реализовывать не будем. Это как-то не надёжно.
go func(room string) {
// Небольшая рандомная задержка перед пингом комнаты
sleepTime := time.Duration(rand.Int63n(1000*Config.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec
time.Sleep(sleepTime)
if err := talk.PingS2S(talk.JID(), room+"/"+getBotNickFromRoomConfig(room)); err != nil {
gTomb.Kill(err)
continue
}
}(room)
*/
var roomMap interface{}
roomMap, exist = MucCapsList.Get(room)
// Пинги комнаты проводим, только если она записана, как прошедшая disco#info и поддерживающая
// Server Optimization.
if exist && roomMap.(map[string]bool)["http://jabber.org/protocol/muc#self-ping-optimization"] {
GTomb.Go(
func() error {
// Небольшая рандомная задержка перед пингом комнаты.
sleepTime := time.Duration(rand.Int63n(1000*Config.Jabber.PingSplayDelay)) * time.Millisecond
time.Sleep(sleepTime)
log.Debugf("Sending MUC ping from %s to %s", Talk.JID(), room)
if err := Talk.PingS2S(Talk.JID(), room); err != nil {
log.Errorf("unable to ping MUC %s: %s", room, err)
}
return nil
},
)
}
}
time.Sleep(time.Duration(Config.Jabber.MucPingDelay) * time.Second)
}
}
}
// RotateStatus периодически изменяет статус бота в MUC-е согласно настройкам из кофига.
func RotateStatus(room string) error {
for {
// TODO: Переделать на ticker-ы
totalSleepTime := time.Duration(Config.Jabber.RuntimeStatus.RotationTime) * time.Second
totalSleepTime += time.Duration(Config.Jabber.RuntimeStatus.RotationSplayTime) * time.Second
for {
status := RandomPhrase(Config.Jabber.RuntimeStatus.Text)
log.Debugf("Set status for MUC: %s to: %s", room, status)
var p xmpp.Presence
if room != "" {
p = xmpp.Presence{ //nolint:exhaustruct
To: room,
Status: status,
}
} else {
p = xmpp.Presence{ //nolint:exhaustruct
Status: status,
}
}
if _, err := Talk.SendPresence(p); err != nil {
return fmt.Errorf("unable to send presence to MUC %s: %w", room, err)
}
// Если мы не хотим ротировать, то цикл нам тут не нужен, просто выходим.
if Config.Jabber.RuntimeStatus.RotationTime == 0 {
return nil
}
time.Sleep(totalSleepTime)
}
}
}
// RandomPhrase Выдаёт одну рандомную фразу из даденного списка фраз.
func RandomPhrase(list []string) string {
phrase := ""
if listLen := len(list); listLen > 0 {
phrase = list[rand.Intn(listLen)]
}
return phrase
}
// interfaceToStringSlice превращает данный интерфейс в слайс строк.
// Если может, конечно :) .
func interfaceToStringSlice(iface interface{}) []string {
var mySlice []string
// А теперь мы начинаем дурдом, нам надо превратить ёбанный interface{} в []string
// Поскольку interface{} может быть чем угодно, перестрахуемся
if reflect.TypeOf(iface).Kind() == reflect.Slice {
shit := reflect.ValueOf(iface)
for i := 0; i < shit.Len(); i++ {
mySlice = append(mySlice, fmt.Sprint(shit.Index(i)))
}
}
return mySlice
}
// getRealJIDfromNick достаёт из запомненных presence-ов по даденному nick-у real jid с resource-ом. Nick должен
// содержать имя конфы, откуда участник.
func getRealJIDfromNick(fullNick string) string {
var p xmpp.Presence
room := (strings.SplitN(fullNick, "/", 2))[0]
// Достанем presence участника
presenceJSONInterface, present := RoomPresences.Get(room)
// Никого нет дома
if !present {
return ""
}
presenceJSONStrings := interfaceToStringSlice(presenceJSONInterface)
for _, presepresenceJSONString := range presenceJSONStrings {
_ = json.Unmarshal([]byte(presepresenceJSONString), &p)
if p.From == fullNick {
return p.JID
}
}
return ""
}
// isMucAdmin возращает true если указанный nick является овнером или админом в чяти.
func isMucAdmin(fullNick string) bool {
var p xmpp.Presence
room := (strings.SplitN(fullNick, "/", 2))[0]
// Достанем presence участника.
presenceJSONInterface, present := RoomPresences.Get(room)
// Никого нет дома
if !present {
return false
}
presenceJSONStrings := interfaceToStringSlice(presenceJSONInterface)
for _, presepresenceJSONString := range presenceJSONStrings {
_ = json.Unmarshal([]byte(presepresenceJSONString), &p)
if p.From == fullNick {
if p.Affiliation == "owner" || p.Affiliation == "admin" {
return true
}
}
}
return false
}
// getMucNames возвращает список участников конференции.
func getMucNames(room string) []string {
var (
names []string
p xmpp.Presence
)
// Достанем presence участников
presenceJSONInterface, present := RoomPresences.Get(room)
if !present {
return names
}
// Достанем presence участника
presenceJSONStrings := interfaceToStringSlice(presenceJSONInterface)
for _, presepresenceJSONString := range presenceJSONStrings {
_ = json.Unmarshal([]byte(presepresenceJSONString), &p)
names = append(names, p.From)
}
return names
}
// getBotNickFromRoomConfig достаёт (короткий) ник бота из настроек чата.
func getBotNickFromRoomConfig(room string) string {
for _, roomStruct := range Config.Jabber.Channels {
if roomStruct.Name == room {
return roomStruct.Nick
}
}
return Config.Jabber.Nick
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,249 @@
package jabber
import (
"encoding/json"
"errors"
"fmt"
"os"
"aleesa-jabber-go/internal/log"
"github.com/hjson/hjson-go"
)
// ReadConfig читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге
// нет.
func ReadConfig() error { //nolint:gocognit,gocyclo
var (
err error
configLoaded = false
)
for _, location := range ConfigLocations {
fileInfo, err := os.Stat(location)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём.
if err != nil {
continue
}
// Конфиг-файл длинноват для конфига, попробуем следующего кандидата.
if fileInfo.Size() > 65535 {
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.Redis.Server == "" {
sampleConfig.Redis.Server = "localhost"
log.Infof("Redis server is not defined in config, using localhost")
}
if sampleConfig.Redis.Port == 0 {
sampleConfig.Redis.Port = 6379
}
if sampleConfig.Redis.Channel == "" {
return fmt.Errorf("channel field in config file %s must be set", location) //nolint:goerr113
}
if sampleConfig.Redis.MyChannel == "" {
return fmt.Errorf("my_channel field in config file %s must be set", location) //nolint:goerr113
}
// Значения для Jabber-клиента
if sampleConfig.Jabber.Server == "" {
log.Error("Jabber server is not defined in config, using localhost")
sampleConfig.Jabber.Server = "localhost" //nolint:wsl
}
if sampleConfig.Jabber.Port == 0 {
sampleConfig.Jabber.Port = 5222
if sampleConfig.Jabber.Ssl {
if !sampleConfig.Jabber.StartTLS {
sampleConfig.Jabber.Port = 5223
log.Info("Jabber port is not defined in config, using 5223")
} else {
log.Info("Jabber port is not defined in config, using 5222")
}
}
}
if !sampleConfig.Jabber.Ssl {
sampleConfig.Jabber.StartTLS = false
}
if !sampleConfig.Jabber.Ssl || !sampleConfig.Jabber.StartTLS {
sampleConfig.Jabber.SslVerify = false
}
// sampleConfig.Jabber.InsecureAllowUnencryptedAuth = false, если не задан
if sampleConfig.Jabber.ConnectionTimeout == 0 {
sampleConfig.Jabber.ConnectionTimeout = 10
log.Info("Jabber server connection_timeout not defined in config, using 10 seconds")
}
if sampleConfig.Jabber.ReconnectDelay == 0 {
sampleConfig.Jabber.ReconnectDelay = 3
log.Info("Jabber server reconnect_delay not defined in config, using 3 seconds")
}
if sampleConfig.Jabber.ServerPingDelay == 0 {
sampleConfig.Jabber.ServerPingDelay = 60
log.Info("Jabber server_ping_delay not defined in config, using 60 seconds")
}
if sampleConfig.Jabber.MucPingDelay == 0 {
sampleConfig.Jabber.MucPingDelay = 900
log.Info("Jabber muc_ping_delay not defined in config, using 900 seconds")
}
if sampleConfig.Jabber.MucRejoinDelay == 0 {
sampleConfig.Jabber.MucRejoinDelay = 3
log.Info("Jabber muc_rejoin_delay not defined in config, using 3 seconds")
}
if sampleConfig.Jabber.PingSplayDelay == 0 {
sampleConfig.Jabber.PingSplayDelay = 3
log.Info("Jabber ping_splay_delay not defined in config, using 3 seconds")
}
if sampleConfig.Jabber.Nick == "" {
return errors.New("jabber nick is not defined in config, quitting") //nolint:goerr113
}
if sampleConfig.Jabber.Resource == "" {
sampleConfig.Jabber.Resource = "bot"
log.Info("Jabber resource not defined in config, using buny bot")
}
if sampleConfig.Jabber.User == "" {
sampleConfig.Jabber.User = fmt.Sprintf("%s@%s", sampleConfig.Jabber.Nick, sampleConfig.Jabber.Server)
log.Infof("Jabber user not defined in config, guessing, it can be %s", sampleConfig.Jabber.User)
}
// Если sampleConfig.Jabber.Password не задан, то авторизации не будет
// Если не задано ни одного мастера, то бот сам себе мастер
if len(sampleConfig.Jabber.BotMasters) == 0 {
sampleConfig.Jabber.BotMasters[0] = sampleConfig.Jabber.User
}
// Нам бот нужен в каких-то чат-румах, а не "просто так"
if len(sampleConfig.Jabber.Channels) < 1 {
return errors.New("no jabber channels/rooms defined in config, quitting") //nolint:goerr113
}
for n, channel := range sampleConfig.Jabber.Channels {
if channel.Name == "" {
return fmt.Errorf("no \"name\" entry in jabber channel config") //nolint: err113
}
if channel.Nick == "" {
sampleConfig.Jabber.Channels[n].Nick = sampleConfig.Jabber.Nick
}
// channel.Password может быть пустым, тогда пароля нет.
} //nolint:wsl
// Если список фраз с которыми стартует бот пустой, вносим в него 1 запись с пустой строкой
if len(sampleConfig.Jabber.StartupStatus) == 0 {
sampleConfig.Jabber.StartupStatus[0] = ""
}
// Если список статусов, с которыми работает бот пустой, вносим в него 1 запись с пустой строкой
if len(sampleConfig.Jabber.RuntimeStatus.Text) == 0 {
sampleConfig.Jabber.RuntimeStatus.Text[0] = ""
}
// Если sampleConfig.Jabber.RuntimeStatus.RotationTime не задан, то он равен 0
// Если sampleConfig.Jabber.RuntimeStatus.RotationSplayTime не задан, то он равен 0
if sampleConfig.ForwardsMax == 0 {
sampleConfig.ForwardsMax = forwardMax
}
if sampleConfig.DataDir == "" {
return fmt.Errorf("data_dir field in config file %s must be set", location) //nolint:goerr113
}
if sampleConfig.CSign == "" {
sampleConfig.CSign = "!"
}
if sampleConfig.Loglevel == "" {
sampleConfig.Loglevel = "info"
log.Info("loglevel not defined in config, using info")
}
// sampleConfig.Log = "" if not set
Config = sampleConfig
configLoaded = true
log.Infof("Using %s as config file", location)
break
}
if !configLoaded {
return fmt.Errorf("config was not loaded!") //nolint:goerr113,revive,stylecheck
}
return err //nolint:wrapcheck
}

145
internal/jabber/redis.go Normal file
View File

@@ -0,0 +1,145 @@
package jabber
import (
"encoding/json"
"fmt"
"aleesa-jabber-go/internal/log"
"github.com/eleksir/go-xmpp"
"github.com/go-redis/redis/v8"
)
// RedisLoop вычитывает сообщения из редиски.
func RedisLoop(redisMsgChan <-chan *redis.Message) error {
// Обработчик событий от редиски
for msg := range redisMsgChan {
if Shutdown {
return nil
}
if err := redisMsgParser(msg.Payload); err != nil {
log.Warnf("%s", err)
}
}
return nil
}
// redisMsgParser парсит json-чики прилетевшие из REDIS-ки.
func redisMsgParser(msg string) error {
var err error
if Shutdown {
// Если мы завершаем работу программы, то нам ничего обрабатывать не надо.
return err
}
var j rMsg
log.Debugf("Incoming 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 nil
}
// Validate our j
if exist := j.From; exist == "" {
log.Warnf("Incorrect msg from redis, no from field: %s", msg)
return nil
}
if exist := j.Chatid; exist == "" {
log.Warnf("Incorrect msg from redis, no chatid field: %s", msg)
return nil
}
if exist := j.Userid; exist == "" {
log.Warnf("Incorrect msg from redis, no userid field: %s", msg)
return nil
}
if exist := j.Message; exist == "" {
log.Warnf("Incorrect msg from redis, no message field: %s", msg)
return nil
}
if exist := j.Plugin; exist == "" {
log.Warnf("Incorrect msg from redis, no plugin field: %s", msg)
return nil
}
if exist := j.Mode; exist == "" {
log.Warnf("Incorrect msg from redis, no mode field: %s", msg)
return nil
}
// j.Misc.Answer может и не быть, тогда ответа на такое сообщение не будет
if j.Misc.Answer == 0 {
log.Debug("Field Misc->Answer = 0, skipping message")
return nil
}
// j.Misc.BotNick тоже можно не передавать, тогда будет записана пустая строка
// j.Misc.CSign если нам его не передали, возьмём значение из конфига
if exist := j.Misc.Csign; exist == "" {
j.Misc.Csign = Config.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 можно не передавать, тогда будет пустая строка
// Отвалидировались, теперь вернёмся к нашим баранам.
if j.Misc.Answer == 1 {
switch j.Mode {
case "public":
// Отправляем сообщение в чятик, а не тому, кто прислал нам исходное сообщение
if _, err := Talk.Send(
xmpp.Chat{ //nolint:exhaustruct
Remote: j.Chatid,
Type: "groupchat",
Text: j.Message,
},
); err != nil {
err = fmt.Errorf("unable to send phrase to room %s: %w", j.Chatid, err)
return err
}
case "private":
// Отправляем сообщение тому, кто прислал сообщение
if _, err := Talk.Send(
xmpp.Chat{ //nolint:exhaustruct
Remote: j.From,
Type: "chat",
Text: j.Message,
},
); err != nil {
err = fmt.Errorf("unable to send phrase to jid %s: %w", j.From, err)
return err
}
default:
log.Infof("Got message from redis that neither public or private: %s", msg)
}
}
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,127 @@
package jabber
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"aleesa-jabber-go/internal/log"
"github.com/cockroachdb/errors/oserror"
"github.com/cockroachdb/pebble"
)
// 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 string
)
vArray, closer, err := db.Get(kArray)
if err != nil {
return valueString, err
}
valueString = string(vArray)
err = closer.Close()
return valueString, err
}
// Достанем настройку из БД с настройками.
func getSetting(chatID string, setting string) string {
var (
err error
chatHash = sha256.Sum256([]byte(chatID))
database = fmt.Sprintf("settings_db/%x", chatHash)
)
// Если БД не открыта, откроем её
if _, ok := settingsDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений
options.L0CompactionFileThreshold = 8
settingsDB[database], err = pebble.Open(Config.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open settings db, %s: %s\n", database, err)
return ""
}
}
value, err := FetchV(settingsDB[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
}
// Сохраним настройку в БД с настройками.
func saveSetting(chatID string, setting string, value string) error {
var (
chatHash = sha256.Sum256([]byte(chatID))
database = fmt.Sprintf("settings_db/%x", chatHash)
err error
)
// Если БД не открыта, откроем её
if _, ok := settingsDB[database]; !ok {
var options pebble.Options
// По дефолту ограничение ставится на мегабайты данных, а не на количество файлов, поэтому с дефолтными
// настройками порождается огромное количество файлов. Умолчальное ограничение на количество файлов - 500 штук,
// что нас не устраивает, поэтому немного снизим эту цифру до более приемлемых значений
options.L0CompactionFileThreshold = 8
settingsDB[database], err = pebble.Open(Config.DataDir+"/"+database, &options)
if err != nil {
log.Errorf("Unable to open settings db, %s: %s\n", database, err)
return err
}
}
if err := StoreKV(settingsDB[database], setting, value); err != nil {
log.Errorf("Unable to save setting %s in database %s: %s", setting, database, err)
}
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,86 @@
package jabber
import (
"aleesa-jabber-go/internal/log"
"os"
"syscall"
"github.com/eleksir/go-xmpp"
)
// SigHandler Хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения.
func SigHandler() error {
log.Debug("Installing signal handler")
for s := range 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
}
var err error
// Чтобы не срать в логи ошибками, проставим shutdown state приложения в true.
Shutdown = true
// Отпишемся от всех каналов и закроем коннект к редиске
if err = Subscriber.Unsubscribe(Ctx); err != nil {
log.Errorf("Unable to unsubscribe from redis channels cleanly: %s", err)
} else {
log.Debug("Unsubscribe from all redis channels")
}
if err = Subscriber.Close(); err != nil {
log.Errorf("Unable to close redis connection cleanly: %s", err)
} else {
log.Debug("Close redis connection")
}
if IsConnected && !Shutdown {
log.Debug("Try to set our presence to Unavailable and status to Offline")
// Вот тут понадобится коллекция известных пользователей, чтобы им разослать presence, что бот свалил в offline
// Пока за неимением лучшего сообщим об этом самим себе.
for _, room := range RoomsConnected {
if _, err := Talk.SendPresence(
xmpp.Presence{ //nolint:exhaustruct
To: room,
Status: "Offline",
Type: "unavailable",
},
); err != nil {
log.Infof("Unable to send presence to jabber server: %s", err)
}
}
// И закрываем соединение.
log.Info("Closing connection to jabber server")
if err := Talk.Close(); err != nil {
log.Infof("Unable to close connection to jabber server: %s", err)
}
}
if len(settingsDB) > 0 {
log.Debug("Closing runtime jabber room settings db")
for _, db := range settingsDB {
_ = db.Close()
}
}
os.Exit(0)
}
return nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

143
internal/jabber/types.go Normal file
View File

@@ -0,0 +1,143 @@
package jabber
import (
"encoding/xml"
)
// myConfig прототип структурки с конфигом.
type myConfig struct {
Redis struct {
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Channel string `json:"channel,omitempty"`
MyChannel string `json:"my_channel,omitempty"`
} `json:"redis"`
Jabber struct {
Server string `json:"server,omitempty"`
Port int `json:"port,omitempty"`
Ssl bool `json:"ssl,omitempty"`
StartTLS bool `json:"starttls,omitempty"`
SslVerify bool `json:"ssl_verify,omitempty"`
InsecureAllowUnencryptedAuth bool `json:"insecureallowunencryptedauth,omitempty"`
ConnectionTimeout int64 `json:"connection_timeout,omitempty"`
ReconnectDelay int64 `json:"reconnect_delay,omitempty"`
ServerPingDelay int64 `json:"server_ping_delay,omitempty"`
MucPingDelay int64 `json:"muc_ping_delay,omitempty"`
MucRejoinDelay int64 `json:"muc_rejoin_delay,omitempty"`
PingSplayDelay int64 `json:"ping_splay_delay,omitempty"`
Nick string `json:"nick,omitempty"`
Resource string `json:"resource,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
BotMasters []string `json:"bot_masters"`
Channels []struct {
Name string `json:"name,omitempty"`
Nick string `json:"nick,omitempty"`
Password string `json:"password,omitempty"`
} `json:"channels,omitempty"`
StartupStatus []string `json:"startup_status,omitempty"`
RuntimeStatus struct {
Text []string `json:"text,omitempty"`
RotationTime int64 `json:"rotation_time,omitempty"`
RotationSplayTime int64 `json:"rotation_splay_time,omitempty"`
} `json:"runtime_status,omitempty"`
} `json:"jabber,omitempty"`
DataDir string `json:"data_dir,omitempty"`
ForwardsMax int64 `json:"forwards_max,omitempty"`
CSign string `json:"csign,omitempty"`
Loglevel string `json:"loglevel,omitempty"`
Log string `json:"log,omitempty"`
}
// jabberSimpleIqGetQuery прототип структурки для разбора запросов xmpp discovery query, например,
// https://xmpp.org/extensions/xep-0030.html#example-18 .
type jabberSimpleIqGetQuery struct {
XMLName xml.Name `xml:"query"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Node string `xml:"node,attr,omitempty"` // для xmlns="http://jabber.org/protocol/disco#items"
}
// jabberPubsubIQGetQuery прототип структурки для разбора запросов xmpp pubsub.
type jabberPubsubIQGetQuery struct {
XMLName xml.Name `xml:"pubsub"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Items struct {
Text string `xml:",chardata"`
Node string `xml:"node,attr"`
MaxItems string `xml:"max_items,attr"`
} `xml:"items"`
}
// jabberTimeIqGetQuery прототип структурки для разбора IQ запросов на локальное время клиента,
// https://xmpp.org/extensions/xep-0202.html
type jabberTimeIqGetQuery struct {
// <time xmlns="urn:xmpp:time"/>
XMLName xml.Name `xml:"time"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
}
// jabberIqPing прототип структурки для разбора IQ запросов на пинг клиента, https://xmpp.org/extensions/xep-0199.html
type jabberIqPing struct {
XMLName xml.Name `xml:"ping"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
}
// jabberIqErrorCancelNotAcceptable прототип структурки для разбора IQ ответов, когда сервис (сервер, клиент, etc) не
// может или не хочет принимать наш iq-запрос https://xmpp.org/extensions/xep-0099.html
type jabberIqErrorCancelNotAcceptable struct {
XMLName xml.Name `xml:"error"`
Text string `xml:",chardata"`
Type string `xml:"type,attr"`
By string `xml:"by,attr"`
NotAcceptable struct {
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
} `xml:"not-acceptable"`
}
// Входящее сообщение из 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"`
}
// Исходящее сообщение в 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: */

158
internal/log/log.go Normal file
View File

@@ -0,0 +1,158 @@
package log
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"time"
)
// Handler exposes slog handler struct.
var Handler slog.Handler
var Ctx = context.Background()
var Writer io.Writer
var reader io.Reader
// Init setup logger stuff.
// level can be error, warn, info, debug if something other supplied info level selected.
// fileDescriptor should be opened before supplying it to Init().
func Init(level string, fileDescriptor *os.File) {
var loglevel slog.Level
// no panic, no trace.
switch level {
case "debug":
loglevel = slog.LevelDebug
case "info":
loglevel = slog.LevelInfo
case "warn":
loglevel = slog.LevelWarn
case "error":
loglevel = slog.LevelError
default:
loglevel = slog.LevelInfo
}
opts := &slog.HandlerOptions{ //nolint: exhaustruct
// 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,
}
Handler = slog.NewTextHandler(fileDescriptor, opts)
slog.SetDefault(
slog.New(Handler),
)
reader, Writer = io.Pipe()
go readIoPipe()
}
// readIoPipe reads pipe and writes it to slog.
func readIoPipe() {
scanner := bufio.NewScanner(reader)
for {
for scanner.Scan() {
line := scanner.Text() // Get the current line as a string
slog.Debug(line)
}
if err := scanner.Err(); err != nil {
// io.EOF is expected when done reading, e.g. on exit.
if errors.Is(err, io.EOF) {
return
}
// something weird happen, we should it log to guess later what it was.
Errorf("Error during scanning log writer pipe: %v\n", err)
}
}
}
// GetLevel returns current loglevel.
func GetLevel() string {
switch {
case Handler.Enabled(Ctx, slog.LevelDebug):
return "debug"
case Handler.Enabled(Ctx, slog.LevelInfo):
return "info"
case Handler.Enabled(Ctx, slog.LevelWarn):
return "warn"
case Handler.Enabled(Ctx, slog.LevelError):
return "error"
default:
return "unknown"
}
}
// 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...))
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */