From ca384b5165ee6d34930ffb8170adb6f9efb1a99b Mon Sep 17 00:00:00 2001 From: eleksir Date: Sun, 3 Sep 2023 14:52:35 +0300 Subject: [PATCH] =?UTF-8?q?Implement=20help,=20=D0=BF=D0=BE=D0=BC=D0=BE?= =?UTF-8?q?=D1=89=D1=8C,=20rehash=20commands,=20restrict=20presence=20coll?= =?UTF-8?q?ection=20precedure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 7 +- commands.go | 141 ++++++++++++++++++++++++++++++++++++++++ data/sample_config.json | 50 +++++++++++--- event_parser.go | 34 ++++++++-- lib.go | 123 +++++++++++++++++++++++++---------- main.go | 19 +++++- types.go | 2 + 7 files changed, 322 insertions(+), 54 deletions(-) create mode 100644 commands.go diff --git a/Makefile b/Makefile index 7a91e40..4429f57 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ #!/usr/bin/env gmake -f BUILDOPTS=-ldflags="-s -w" -a -gcflags=all=-l -trimpath -pgo=auto +FILELIST=collection.go types.go globals.go lib.go event_parser.go buny.go commands.go main.go all: clean build @@ -9,15 +10,15 @@ ifeq ($(OS),Windows_NT) # powershell ifeq ($(SHELL),sh.exe) SET CGO_ENABLED=0 - go build ${BUILDOPTS} -o "buny-jabber-bot" collection.go types.go globals.go lib.go event_parser.go buny.go main.go + go build ${BUILDOPTS} -o "buny-jabber-bot" ${FILELIST} else # jetbrains golang CGO_ENABLED=0 - go build ${BUILDOPTS} -o "buny-jabber-bot" collection.go types.go globals.go lib.go event_parser.go buny.go main.go + go build ${BUILDOPTS} -o "buny-jabber-bot" ${FILELIST} endif # bash/git bash else - CGO_ENABLED=0 go build ${BUILDOPTS} -o "buny-jabber-bot" collection.go types.go globals.go lib.go event_parser.go buny.go main.go + CGO_ENABLED=0 go build ${BUILDOPTS} -o "buny-jabber-bot" ${FILELIST} endif clean: diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..1e1288d --- /dev/null +++ b/commands.go @@ -0,0 +1,141 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/davecgh/go-spew/spew" + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" +) + +func cmd(v xmpp.Chat) error { + var err error + + switch { + case v.Text == fmt.Sprintf("%shelp", config.CSign) || v.Text == fmt.Sprintf("%sпомощь", config.CSign): + var ( + answer string + chosenOneTalks = false + ) + + realJID := getRealJIDfromNick(v.Remote) + + for _, master := range config.Jabber.BotMasters { + if (strings.SplitN(realJID, "/", 2))[0] == master { + chosenOneTalks = true + } + } + + if chosenOneTalks { + answer = fmt.Sprintf("%sпомощь - этот список команд\n", config.CSign) + answer += fmt.Sprintf("%shelp - this commands list\n", config.CSign) + answer += fmt.Sprintf("%srehash - reload white and black lists", config.CSign) + } else { + answer = "Ничем помочь не могу. Луна не светит на тебя." + } + + dest := v.Remote + + if v.Type == "groupchat" { + dest = (strings.SplitN(v.Remote, "/", 2))[0] + } + + if _, err := talk.Send( + xmpp.Chat{ //nolint:exhaustruct + Remote: dest, + Text: strings.TrimSpace(answer), + Type: v.Type, + }, + ); err != nil { + err = fmt.Errorf("unable to send message to %s: %w", v.Remote, err) + + return err + } + + case v.Text == fmt.Sprintf("%srehash", config.CSign): + /* Эта команда может прилетать из приватной беседы с realjid-ом, из приватной беседы с chat-nick-ом, а также из + * чятика. + * Groupchat мы можем определить по v.Type (groupchat) и тогда понятно что делать. + * А вот, например, приватные притязания определить несколько сложно-вато. Но можно попробовать, например, + * пытаться по списку presence-ов находить персонажа и его real jid и уже из этого определять master он или нет. + */ + + // Попробуем определить каков алгоритм наших действий, в зависимости от типа сообщения + switch v.Type { + case "groupchat": + // Проверим, является ли "командир" избранным или это самозванец? + var ( + chosenOneTalks = false + listsLoaded = true + ) + + realJID := getRealJIDfromNick(v.Remote) + + for _, master := range config.Jabber.BotMasters { + if (strings.SplitN(realJID, "/", 2))[0] == master { + chosenOneTalks = true + } + } + + if !chosenOneTalks { + log.Infof( + "Command %srehash given by non-bot_master user %s(%s), ignoring", + config.CSign, + realJID, + v.Remote, + ) + + return err + } + + if err := readWhitelist(); err != nil { + listsLoaded = false + + var msg xmpp.Chat + msg.Remote = v.Remote + msg.Type = "chat" // private message + msg.Text = fmt.Sprint(err) + + if _, err := talk.Send(msg); err != nil { + return err + } + } + + if err := readBlacklist(); err != nil { + listsLoaded = false + + var msg xmpp.Chat + msg.Remote = v.Remote + msg.Type = "chat" // private message + msg.Text = fmt.Sprint(err) + + if _, err := talk.Send(msg); err != nil { + return err + } + } + + if listsLoaded { + room := (strings.SplitN(v.Remote, "/", 2))[0] + + var msg xmpp.Chat + msg.Remote = room + msg.Type = v.Type + msg.Text = "Сделано" + + if _, err := talk.Send(msg); err != nil { + return err + } + } + case "chat": + default: + log.Infof("Rehash command from outer space: %s", spew.Sdump(v)) + } + + return err + default: + return err + } + + return err +} diff --git a/data/sample_config.json b/data/sample_config.json index be0f1c5..cadb73f 100644 --- a/data/sample_config.json +++ b/data/sample_config.json @@ -3,46 +3,80 @@ "jabber": { # Если не задан, будет localhost "server": "jabber.tld", + # Если не задано - 5222 для нешифрованного соединения или для starttls и 5223 для голого (старорежимного) ssl "port": 5222, + # Включить шифрование "ssl": true, + # Это более современный протокол и как раз на нём работет большинство jabber-серверов, порт 5222, # jabber.ru по этому протоколу как-то странно работает, если его включить, я получаю # tls: first record does not look like a TLS handshake "starttls" : true, + # Не проверяем сертификаты по-умолчанию. # Вам правда хочется всё это проверять? Там по идее могут быть всякие Lets Encrypt, которые хоть и работают, но # создают кучу проблем "ssl_verify": false, + # Позволять авторизацию логин-паролем по незашифрованному каналу связи "insecureallowunencryptedauth": false, + # Таймаут установления соединения с сервером, в секундах. 10 секунд, если не задано. "connection_timeout": 10, + # Задержка перед пере-коннекчиванием. 3 секунды, если не задано. "reconnect_delay": 3, + # Промежуток между пингами сервера, для определения его живости. По-умолчанию 60 секунд. # Пинги посылаются, если в течение этого времени не было активности со стороны сервера. "server_ping_delay": 60, + # Промежуток между пингами muc-ов. По-умолчанию 900 секунд. (Согласно рекомендации в xep-0410.) # Пинги посылаются, если в течение этого времени не было активности со стороны muc-а. "muc_ping_delay": 900, + # Сколько ждать перед переджоиванием к комнате. По-умолчанию, 3 секунды. "muc_rejoin_delay": 3, + # Ping-to-ping splay delay. Варьируемая задержка между пингами, чтобы не набирать высокий message rate. # По-умолчанию, 3 секунды. Под капотом преобразуются в миллисекунды. Важна для пингов MUC-ов, особенно, # если их МНОГО. "ping_splay_delay": 3, + # Ник бота, с которым он будет заходить в комнаты "nick": "buny", + # Уникальная часть jid-а, специфичная для клиента "resource" : "Лунная призма", + + # Имя пользователя для авторизации на сервере + "user": "buny@jabber.tld", + + # Пароль для авторизации + "password": "secret", + + # Список JID-ов, которым позволено управлять ботом (например, давать отмашку на rehash). + # В списке jid-ы без resource-а. + # Если не задано ни одного мастера, то бот сам себе мастер. + "bot_masters": [ + "luna@earth.tld" + ], + + # Jabber-каналы, к которым бот попробует присоединиться при старте + "channels": [ + "my_channel@conference.jabber.tld", + "another_channel@conference.jabber.tld" + ], + # Статус, с которым бот запускается. (если всё нормально, этот статус будет видать только в логах сервера и # на долю секунды он промелькнёт у всех в ростере, у кого бот в знакомых. Может отсуствовать, тогда статус будет # пустой строкой "startup_status": [ "bunny bot is starting" ], + # Статус, который демонстрируется во время работы бота, как xa # (по задумке - extended away, на практике, к away обычно отношения не имеет) "runtime_status": { @@ -62,15 +96,19 @@ "Звезда нашей надежды никогда не исчезнет. Пока она горит, всё будет в порядке. Мы не сможем проиграть", "Голова с хвостиками… Не вмешивайся в наши дела! Играть в войну — это опасно" ], + # Период смены статуса, сек. Если параметра нет или он выставлен в 0, ротации не будет # 3 дня "rotation_time": 259200, + # Рандомная составляющая, добавляемая к периоду смены статуса, сек. # 4 дня "rotation_splay_time": 345600 }, + # Произносим ли что-то пафосное в момоент бана "ban_phrases_enable": false, + # Пафосные фраза, произносимые перед баном "ban_phrases": [ "Во имя Луны, я накажу тебя!", @@ -103,18 +141,12 @@ "Мы сражаемся за мир и равенство!", "Мы — надежда для всех, кто нуждается в защите!", "Мы — воительницы справедливости, и ничто не остановит нас!" - ], - # Имя пользователя для авторизации на сервере - "user": "buny@jabber.tld", - # Пароль для авторизации - "password": "secret", - # Jabber-каналы, к которым бот попробует присоединиться при старте - "channels": [ - "my_channel@conference.jabber.tld", - "another_channel@conference.jabber.tld" ] }, + # command sign символ-суффикс команды, например !rehash или !help. По-умолчанию "!". + "csign" : "!", + # Многословность логов. Если не задано, то info. Debug - ДЕЙСТВИТЕЛЬНО вербозный уровень логгирования. # Есть ещё trace :) "loglevel": "debug", diff --git a/event_parser.go b/event_parser.go index f2d67af..580585e 100644 --- a/event_parser.go +++ b/event_parser.go @@ -42,6 +42,12 @@ func parseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo return } + if err := cmd(v); err != nil { + gTomb.Kill(err) + + return + } + lastActivity = lastServerActivity if muc, _ := strings.CutSuffix(v.Remote, "/"); muc != "" { @@ -55,6 +61,12 @@ func parseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo // eleksir@jabber.ru/array.lan - это если мы работаем через ростер log.Debugf("Private message: %s", v.Text) + if err := cmd(v); err != nil { + gTomb.Kill(err) + + return + } + lastActivity = lastServerActivity // Внезапно, ошибки. По идее они должны ассоциироваться с отправляемыми сообщениями, но по ходу это @@ -539,7 +551,7 @@ func parseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo var p xmpp.Presence _ = json.Unmarshal([]byte(presenceJSONstring), &p) - if p.JID == v.JID { + if p.From == v.From { continue } @@ -551,15 +563,29 @@ func parseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo // Участник пришёл default: var presenceJSONStrings []string + var newPresenceJSONStrings []string var presenceJSONBytes []byte if presenceJSONInterface, present := roomPresences.Get(room); present { presenceJSONStrings = interfaceToStringSlice(presenceJSONInterface) } - presenceJSONBytes, _ = json.Marshal(v) - presenceJSONStrings = append(presenceJSONStrings, string(presenceJSONBytes)) - roomPresences.Set(room, presenceJSONStrings) + 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) } // Проверяем, а не злодей ли зашёл? Сделать это мы можем, только если мы находимся в комнате. diff --git a/lib.go b/lib.go index 256528e..a3bdb3d 100644 --- a/lib.go +++ b/lib.go @@ -2,11 +2,13 @@ package main import ( "encoding/json" + "errors" "fmt" "math/rand" "os" "path/filepath" "reflect" + "strings" "syscall" "time" @@ -68,14 +70,19 @@ func sigHandler() { // readConfig читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге // нет. -func readConfig() { //nolint:gocognit,gocyclo - configLoaded := false - executablePath, err := os.Executable() +func readConfig() error { //nolint:gocognit,gocyclo + var ( + err error + executablePath string + configLoaded = false + ) + + executablePath, err = os.Executable() if err != nil { - log.Errorf("Unable to get current executable path: %s", err) + err := fmt.Errorf("unable to get current executable path: %w", err) - os.Exit(1) + return err } configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath)) @@ -210,8 +217,7 @@ func readConfig() { //nolint:gocognit,gocyclo } if sampleConfig.Jabber.Nick == "" { - log.Errorf("Jabber nick is not defined in config, quitting") - os.Exit(1) + return errors.New("jabber nick is not defined in config, quitting") //nolint:goerr113 } if sampleConfig.Jabber.Resource == "" { @@ -227,13 +233,15 @@ func readConfig() { //nolint:gocognit,gocyclo } // Если sampleConfig.Jabber.Password не задан, то авторизации не будет - // Если sampleConfig.Jabber.Sasl не задан, то авторизация происходит через NickServ - // Если sampleConfig.Jabber.BanPhrasesEnable не задан, то он false + // Если не задано ни одного мастера, то бот сам себе мастер + if len(sampleConfig.Jabber.BotMasters) == 0 { + sampleConfig.Jabber.BotMasters[0] = sampleConfig.Jabber.User + } - // Если список фраз, с которыми банят пустой, то вносим в него одну позицию с пустой строкой - if len(sampleConfig.Jabber.BanPhrases) == 0 { - sampleConfig.Jabber.BanPhrases[0] = "" + // Нам бот нужен в каких-то чат-румах, а не "просто так" + if len(sampleConfig.Jabber.Channels) < 1 { + return errors.New("no jabber channels/rooms defined in config, quitting") //nolint:goerr113 } // Если список фраз с которыми стартует бот пустой, вносим в него 1 запись с пустой строкой @@ -249,10 +257,15 @@ func readConfig() { //nolint:gocognit,gocyclo // Если sampleConfig.Jabber.RuntimeStatus.RotationTime не задан, то он равен 0 // Если sampleConfig.Jabber.RuntimeStatus.RotationSplayTime не задан, то он равен 0 - // Нам бот нужен в каких-то чат-румах, а не "просто так" - if len(sampleConfig.Jabber.Channels) < 1 { - log.Errorf("No jabber channels/rooms defined in config, quitting") - os.Exit(1) + // Если sampleConfig.Jabber.BanPhrasesEnable не задан, то он false + + // Если список фраз, с которыми банят пустой, то вносим в него одну позицию с пустой строкой + if len(sampleConfig.Jabber.BanPhrases) == 0 { + sampleConfig.Jabber.BanPhrases[0] = "" + } + + if sampleConfig.CSign == "" { + sampleConfig.CSign = "!" } if sampleConfig.Loglevel == "" { @@ -271,19 +284,26 @@ func readConfig() { //nolint:gocognit,gocyclo } if !configLoaded { - log.Error("Config was not loaded! Refusing to start.") - os.Exit(1) + return errors.New("config was not loaded!") //nolint:goerr113 } + + return err //nolint:wrapcheck } // readWhitelist читает и валидирует белые списки пользователей. -func readWhitelist() { - whitelistLoaded := false - executablePath, err := os.Executable() +func readWhitelist() error { + var ( + whitelistLoaded = false + err error + executablePath string + ) + + executablePath, err = os.Executable() if err != nil { - log.Errorf("Unable to get current executable path: %s", err) - os.Exit(1) + err = fmt.Errorf("unable to get current executable path: %w", err) + + return err } whitelistJSONPath := fmt.Sprintf("%s/data/whitelist.json", filepath.Dir(executablePath)) @@ -360,21 +380,26 @@ func readWhitelist() { } if !whitelistLoaded { - log.Error("Whitelist was not loaded! Refusing to start.") - - os.Exit(1) + return errors.New("whitelist was not loaded!") //nolint:goerr113 } + + return err } // readBlacklist читает и валидирует чёрные списки пользователей. -func readBlacklist() { - blacklistLoaded := false - executablePath, err := os.Executable() +func readBlacklist() error { + var ( + blacklistLoaded = false + err error + executablePath string + ) + + executablePath, err = os.Executable() if err != nil { - log.Errorf("Unable to get current executable path: %s", err) + err = fmt.Errorf("unable to get current executable path: %w", err) - os.Exit(1) + return err } whitelistJSONPath := fmt.Sprintf("%s/data/blacklist.json", filepath.Dir(executablePath)) @@ -451,10 +476,10 @@ func readBlacklist() { } if !blacklistLoaded { - log.Error("Blacklist was not loaded! Refusing to start.") - - os.Exit(1) + return errors.New("blacklist was not loaded!") } + + return err } // establishConnection устанавливает соединение с jabber-сервером. @@ -582,7 +607,7 @@ func joinMuc(room string) { go RotateStatus(room) - // Время прoверить участников на предмет злобности + // Время проверить участников на предмет злобности namesInterface, present := roomPresences.Get(room) // Если room есть в списке presence-ов, то фигачим. room там должен быть, просто обязан. @@ -855,4 +880,32 @@ func interfaceToStringSlice(iface interface{}) []string { 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 "" +} + /* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/main.go b/main.go index a1cd56f..8c99e98 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,11 @@ func init() { TimestampFormat: "2006-01-02 15:04:05", }) - readConfig() + if err := readConfig(); err != nil { + log.Error(err) + + os.Exit(1) + } // no panic switch config.Loglevel { @@ -67,8 +71,17 @@ func main() { } // Самое время вгрузить белые и чёрные списки, если что - получим ошибку в лог. - readWhitelist() - readBlacklist() + if err := readWhitelist(); err != nil { + log.Error(err) + + os.Exit(1) + } + + if err := readBlacklist(); err != nil { + log.Error(err) + + os.Exit(1) + } // github.com/mattn/go-xmpp пишет в stdio, нам этого не надо, ловим выхлоп его в logrus с уровнем trace xmpp.DebugWriter = log.WithFields(log.Fields{"logger": "stdlib"}).WriterLevel(log.TraceLevel) diff --git a/types.go b/types.go index 1c885cc..b924c00 100644 --- a/types.go +++ b/types.go @@ -23,6 +23,7 @@ type myConfig struct { Resource string `json:"resource,omitempty"` User string `json:"user,omitempty"` Password string `json:"password,omitempty"` + BotMasters []string `json:"bot_masters"` Channels []string `json:"channels"` StartupStatus []string `json:"startup_status,omitempty"` RuntimeStatus struct { @@ -34,6 +35,7 @@ type myConfig struct { BanPhrases []string `json:"ban_phrases,omitempty"` } `json:"jabber,omitempty"` + CSign string `json:"csign,omitempty"` Loglevel string `json:"loglevel,omitempty"` Log string `json:"log,omitempty"` }