commit 843dd418b02783dd8040e3e80412c0e4e777bfb3 Author: eleksir Date: Sun Sep 17 12:18:09 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..050a962 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.idea/** +.vscode/** +.vstags +*.bak +*.tmp +**/*.bak +**/*.tmp +data/config.json +data/settings_db/** +buny-jabber-bot diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c326df9 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,69 @@ +run: + tests: false + skip-dirs: + - data + - vendor + modules-download-mode: readonly + allow-parallel-runners: true + go: '1.21' +output: + sort-results: true +linters-settings: + gosec: + excludes: + # Оно не используется для повышения безопасности или каких-то таких секъюрых вещей, оно используется для выборки + # произвольного элемента из массива, так что ИДИ НАХУЙ с этой проверкой и не еби мозги липовой безопасностью. + - G404 # Insecure random number source (rand) +linters: + disable-all: true + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - asasalint + - asciicheck + - bodyclose + - containedctx + - contextcheck + - decorder + - durationcheck + - errchkjson + - errorlint + - exhaustive + - exhaustruct + - exportloopref + - gci + - ginkgolinter + - gocheckcompilerdirectives + - gocritic + - godot + - goerr113 + - gofmt + - goimports + - gosec + - gosmopolitan + - grouper + - importas + - ireturn + - makezero + - mirror + - nakedret + - nilerr + - nilnil + - nlreturn + - noctx + - nolintlint + - nonamedreturns + - predeclared + - promlinter + - reassign + - revive + - rowserrcheck + - stylecheck + - unconvert + - wastedassign + - whitespace + - wsl diff --git a/Design.txt b/Design.txt new file mode 100644 index 0000000..4bbed4f --- /dev/null +++ b/Design.txt @@ -0,0 +1,63 @@ +Oсобенности реализации клиентской части. + +Общие положения +------------------------------------------------------------------------------------------------------------------------ + +Nick для room-ов един и не может задаваться на per-conference основе. На этом завязаны отсылка ping-ов и определение +pong-ов, как минимум. Хотя формально джойниться можно с любым ником :) но что-то не хочется потом распутывать этот +клубок. + + +XEP-0410 MUC Self-Ping (Schrödinger's Chat) +------------------------------------------------------------------------------------------------------------------------ + +xep-0410 реализован не польностью. Согласно https://xmpp.org/extensions/xep-0410.html, существует минимум 2 варианта +реализации обнаружения, что киент оотвалился от чатика. + +* С двухступенчатым пингом. Когда с jid-а локального пнгуется jid в конференции. Ответ доставляется на один из клиентов. +Который в свою очередь должен ответить на этот ответ (отвечайте на мои ответы!), результат этого ответа передаётся +оригинальному запросившему клиенту. + +* С серверной орптимизацией. (оптимизация анонсируется в ответе на запрос disco#info MUC-а) +В этом случае пинг будет одноступенчатым и нам сразу же должен по идее вернуться result (то бишь pong) + +Двухступенчатая реализация вызывает вопросы. Во-первых, своей двухступенчатастью, а во-вторых, ответ на начальный пинг +доставляется на один из клиентов (не на все!), а если этот клиент испытывает сетевые проблемы, то получается, страдают +все клиенты. В нашем случае предполагается, что если клиентов больше одного, то он не должен своим состоянием аффектить +других клиентов. + +И... тут есть другой момент - двухступенчатый пинг гораздо сложнее в реализации на клиентской стороне, т.к. надо +обрабатывать значительно бОльшее количество кейсов сочетаний ошибок, которые могут возникнуть в ходе пинга. + +Вобщем, поддерживаем только второй вариант пинга, который с серверной оптимизацией. + +N.B. Но на IQ get ping с xmlns="urn:xmpp:ping" из первого шага мы всё-таки будем отвечать обычным result-ом (понгом, то + бишь) + +N.B. Каждый пинг комнаты происходит в отдельной goroutne-ке, так что заходить в огромное количеств комнат (>1k) + не рекомендуется + + +xep-0199 XMPP Ping +------------------------------------------------------------------------------------------------------------------------ + +Cуществует несколько видов пинга: +* Server-To-Client Pings +* Client-To-Server Pings +* Server-To-Server Pings +* Client-to-Client Pings +* Component-to-Client Pings + +Явным образом я реализовываю с2s. Плюс частично Component-to-Client в рамках N.B. по xep-0410. + + +Ротация статусов +------------------------------------------------------------------------------------------------------------------------ +Ротирование статуса реализовано через заддержки time.sleep, На каждый чат таким образом порождается по горутине. Если +чатов много, это может вызвать проблемы. + + +Причина бана +------------------------------------------------------------------------------------------------------------------------ +Причину бана можно только включить или выключить в конфиге на per-chat основе. Фразу настроить невозможно. (не хотелось +реализовывать парсинг темплейтов, как минимум даты-времени) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..13255ce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +Copyright (c) 2022 Sergei Fedosoff + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8238dfd --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +#!/usr/bin/env gmake -f + +BUILDOPTS=-ldflags="-s -w" -a -gcflags=all=-l -trimpath +FILELIST=collection.go types.go globals.go lib.go event_parser.go commands.go redis.go settings-db-util.go main.go +BINARY=aleesa-jabber-go + +all: clean build + +build: +ifeq ($(OS),Windows_NT) +# powershell +ifeq ($(SHELL),sh.exe) + SET CGO_ENABLED=0 + go build ${BUILDOPTS} -o ${BINARY} ${FILELIST} +else +# jetbrains golang + CGO_ENABLED=0 + go build ${BUILDOPTS} -o ${BINARY} ${FILELIST} +endif +# bash/git bash +else + CGO_ENABLED=0 go build ${BUILDOPTS} -o ${BINARY} ${FILELIST} +endif + +clean: + go clean + +upgrade: +ifeq ($(OS),Windows_NT) +# jetbrains golang, powershell +ifeq ($(SHELL),sh.exe) + if exist vendor del /F /S /Q vendor >nul +# git bash case +else + $(RM) -r vendor +endif +else + $(RM) -r vendor +endif + go get -d -u -t ./... + go mod tidy + go mod vendor + +# vim: set ft=make noet ai ts=4 sw=4 sts=4: diff --git a/README.md b/README.md new file mode 100644 index 0000000..e14c7fb --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Aleesa-jabber-go + +## Что это? + +Сервис для aleesa-bot, работающий с протоколом XMPP/Jabber. С основными сервисами бота работает по протоколу Redis +pubsub. + + +## Как заставить его работать? + +Технически, бот собирается гошкой 1.21. Приложение тестировалось на alpine linux x86_64 3.16, но должно работать на +любом (более свежем) linux-е. Работоспособность на других платформах не тестировалась. + +Чтобы получить готовые бинарники, достаточно выполнить команду: + +```bash +make +``` + +в итоге получится бинарник **aleesa-jabber-go**. После создания data/config.json из data/config_sample.json, можно +запускать приложение. + +Предполагается, что конфиг написан в формате [human json](https://hjson.github.io), который допускает коментарии и +некоторые другие вольности в форматировании json-а. Конфиг ищется последовательно по следующим путям по принципу "кто +первый того и тапки": + +```text +~/.aleesa-jabber-go.json +~/aleesa-jabber-go.json +/etc/aleesa-jabber-go.json +data/config.json +``` + +В каталоге **contrib** находится скрипт для _alpine linux_, системы инициализации _openrc_. Этот скрипт достаточно +положить в **/etc/init.d/aleesa-jabber-go**. После чего скопировать бинарник в каталог **/var/lib/aleesa-jabber-go**, +положить конфиг в одну из дефолтных локаций (тестировалась /var/lib/aleesa-jabber-go/data/config.json) и после этого +сервис запускается командой: + +```bash +service aleesa-jabber-go start +``` + +В автозагрузку его можно прописать через команду: + +```bash +rc-update add aleesa-jabber-go default +``` + +### Замечание + +Для бота важно, чтобы комната была _неанонимной_, или была _полуанонимной_, то тогда бот должен быть админом, чтобы +_видеть real jid-ы_ участников. По механике работы, ему надо знать real jid-ы участников. + +## Дисклеймер + +Весь дисклеймер в файле LICENSE.txt :) + +## Благодарности + +Во-первых, `сообществу`, без него не было бы чятиков. + +Во-вторых, `batman46(new)` из конференций на `jabber.ru`, за наблюдения и за некоторые идеи, воплощённые в прототипе +бота, с которого был списан этот фронт-энд. + +В-третьих, `U2` из конференции `radio@conference.jabber.ru` за мысли, идеи для прототипа бота, с которого опять же +был списан этот фронт-энд, и за позитивный настрой. diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..9ec783a --- /dev/null +++ b/collection.go @@ -0,0 +1,69 @@ +package main + +import ( + "sync" +) + +// Collection это sync.Map-ка, хранящая в себе произвольные итемы. +type Collection struct { + items sync.Map + close chan struct{} +} + +// Item представляет собой интерфейс для хранения произвольных данных. +type item struct { + data interface{} +} + +// NewCollection создаёт инстанс коллекции. +func NewCollection() *Collection { + c := &Collection{ //nolint:exhaustruct + close: make(chan struct{}), + } + + return c +} + +// Get достаёт данные по заданному ключу из коллекции. +func (collection *Collection) Get(key interface{}) (interface{}, 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 interface{}, value interface{}) { + collection.items.Store(key, item{ + data: value, + }) +} + +// Range применяет функцию f ко всем ключам в коллекции. +func (collection *Collection) Range(f func(key, value interface{}) bool) { + fn := func(key, value interface{}) bool { + item := value.(item) + + return f(key, item.data) + } + + collection.items.Range(fn) +} + +// Delete удаляет ключ и значение из коллекции данных. +func (collection *Collection) Delete(key interface{}) { + 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: */ diff --git a/commands.go b/commands.go new file mode 100644 index 0000000..52543ee --- /dev/null +++ b/commands.go @@ -0,0 +1,374 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "slices" + "strings" + + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" +) + +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", "карма"} + + // Список отключаемых команд проверяется в 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 = config.Jabber.Nick + 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 + } + } + } + + // Предполагается что в канале бот должен отвечать, только если к нему обратились, либо это была команда + if regexp.MustCompile(config.Jabber.Nick).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: */ diff --git a/contrib/aleesa-jabber-go.openrc b/contrib/aleesa-jabber-go.openrc new file mode 100644 index 0000000..0504dac --- /dev/null +++ b/contrib/aleesa-jabber-go.openrc @@ -0,0 +1,20 @@ +#!/sbin/openrc-run + +name="$SVCNAME" +command="/var/lib/$SVCNAME/$SVCNAME" +supervisor="supervise-daemon" +pidfile="/run/$SVCNAME/$SVCNAME.pid" +command_user=nobody:nogroup + +depend() { + need net redis + use dns + after redis +} + +start_pre() { + mkdir -p /var/log/$SVCNAME + chown -R nobody:nogroup /var/log/$SVCNAME + mkdir -p /run/$SVCNAME + chown -R nobody:nogroup /run/$SVCNAME +} diff --git a/data/sample_config.json b/data/sample_config.json new file mode 100644 index 0000000..3f2188e --- /dev/null +++ b/data/sample_config.json @@ -0,0 +1,125 @@ +{ + # Фронт-энд бота + "redis": { + # Если не задан, будет localhost + "server": "redis.tld", + + # 6379 if not set + "port": 6379, + + # Канал, на котором работает msg router + "channel" : "misc" + + # Канал, в который пишут другие модули бота сообщения для jabber-модуля + "my_channel" : "jabber" + }, + "jabber": { + # Если не задан, будет localhost + "server": "jabber.tld", + + # Если не задано - 5222 для нешифрованного соединения или для starttls и 5223 для голого (старорежимного) ssl + "port": 5223, + + # Включить шифрование + "ssl": true, + + # Это более современный протокол и как раз на нём работет большинство jabber-серверов, порт 5222, + # jabber.ru по этому протоколу как-то странно работает, если его включить, я получаю + # tls: first record does not look like a TLS handshake + "starttls" : false, + + # Не проверяем сертификаты по-умолчанию. + # Вам правда хочется всё это проверять? Там по идее могут быть всякие 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": "aleesa", + + # Уникальная часть jid-а, специфичная для клиента + "resource" : "bot", + + # Имя пользователя для авторизации на сервере + "user": "aleesa@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": [ + "aleesa bot is starting" + ], + + # Статус, который демонстрируется во время работы бота, как xa + # (по задумке - extended away, на практике, к away обычно отношения не имеет) + "runtime_status": { + # Сам текст статуса, может отсустствовать, тогда статус - это пустая строка + "text": [ + "I’ll just warn you now, I don’t know how to use a computer." + ], + + # Период смены статуса, сек. Если параметра нет или он выставлен в 0, ротации не будет + # 3 дня в секундах + "rotation_time": 259200, + + # Рандомная составляющая, добавляемая к периоду смены статуса, сек. + # 4 дня в секундах + "rotation_splay_time": 345600 + }, + + # Каталог, в котором размещается бд с настройками бота, которые можно менять на лету через команду !admin + "data_dir": "data", + + # Будет 5, если не задан. Скорее всего вам не надо это менять. + "forwards_max": 5, + + # command sign символ-суффикс команды, например !rehash или !help. По-умолчанию "!". + "csign" : "!", + + # Многословность логов. Если не задано, то info. Debug - ДЕЙСТВИТЕЛЬНО вербозный уровень логгирования. + # Есть ещё trace :) + "loglevel": "debug", + + # Если не задан, логи идут в STDOUT + "log": "/var/log/aleesa-jabber-go/error.log" +} diff --git a/event_parser.go b/event_parser.go new file mode 100644 index 0000000..6e5b31e --- /dev/null +++ b/event_parser.go @@ -0,0 +1,638 @@ +package main + +import ( + "encoding/json" + "encoding/xml" + "fmt" + "sort" + "strings" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" + "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) + // dest := strings.SplitN(v.Remote, "/", 2)[0] + nick := strings.SplitN(v.Remote, "/", 2)[1] + + if nick == config.Jabber.Nick { + 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 := "" + answer += "" + answer += "" + answer += "" + answer += "" + answer += "" + answer += "" + + 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) == "": + 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) == "": + 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, config.Jabber.Nick) + + 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() { + nick := strings.SplitN(v.From, "/", 2)[1] + + if slices.Contains(roomsConnected, iqErrorCancelNotAcceptable.By) && nick == config.Jabber.Nick { + 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) + } + } else { + log.Errorf("Presence notification, Type: %s, From: %s, Show: %s, Status: %s", + v.Type, v.From, v.Show, v.Status) + log.Errorf(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 + var newPresenceJSONStrings []string + var 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: */ diff --git a/globals.go b/globals.go new file mode 100644 index 0000000..8bc16df --- /dev/null +++ b/globals.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "os" + + "github.com/cockroachdb/pebble" + "github.com/eleksir/go-xmpp" + "github.com/go-redis/redis/v8" + "gopkg.in/tomb.v1" +) + +// 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 = make(chan os.Signal, 1) + +// Основной инстанс 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 *Collection + +// Получен ли ответ на запрос disco#info к серверу. +var serverCapsQueried bool + +// sync.Map-ка с капабилити сервера. +var serverCapsList *Collection + +// sync.Map-ка с комнатами и их capability. +var mucCapsList *Collection + +// Время, когда был отправлен c2s ping. +var serverPingTimestampTx int64 + +// Время, когда был принят s2c pong. +var serverPingTimestampRx int64 + +// Объектик для хранения стейта утилизатора горутинок. +var gTomb tomb.Tomb + +// sync.Map-ка со списком участников конференций (в json-формате, согласно структуре xmpp.Presence, "room".[]json). +var roomPresences *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: */ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26e3f6b --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module buny-jabber-bot + +go 1.21 + +require ( + github.com/cockroachdb/errors v1.11.1 + github.com/cockroachdb/pebble v0.0.0-20230905214401-0cbd3b1bccef + github.com/davecgh/go-spew v1.1.1 + github.com/eleksir/go-xmpp v1.5.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/hjson/hjson-go v3.3.0+incompatible + github.com/sirupsen/logrus v1.9.3 + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 +) + +require ( + github.com/DataDog/zstd v1.5.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/getsentry/sentry-go v0.24.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.16.0 // indirect + github.com/prometheus/client_model v0.4.0 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + golang.org/x/crypto v0.13.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..381622a --- /dev/null +++ b/go.sum @@ -0,0 +1,140 @@ +github.com/DataDog/zstd v1.5.5 h1:oWf5W7GtOLgp6bciQYDmhHHjdhYkALu6S/5Ni9ZgSvQ= +github.com/DataDog/zstd v1.5.5/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= +github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.11.1 h1:xSEW75zKaKCWzR3OfxXUxgrk/NtT4G1MiOv5lWZazG8= +github.com/cockroachdb/errors v1.11.1/go.mod h1:8MUxA3Gi6b25tYlFEBGLf+D8aISL+M4MIpiWMSNRfxw= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v0.0.0-20230905214401-0cbd3b1bccef h1:woeEFsEoclqyaKA0uNlotcy+pMDrULq5FZptvmb9Dik= +github.com/cockroachdb/pebble v0.0.0-20230905214401-0cbd3b1bccef/go.mod h1:nindLFinxeDPjP4qI9LtVHAwDef57/0s5KMfWgdktQc= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/eleksir/go-xmpp v1.5.0 h1:kAjpCxuqWiu9LdlCTkQzvczotHONpojwp/mpI4aBVmQ= +github.com/eleksir/go-xmpp v1.5.0/go.mod h1:KnvbXvUpttEVFl8l5eDO2Q7J97ZxtTMvOsDWULD2Rn8= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getsentry/sentry-go v0.24.0 h1:02b7qEmJ56EHGe9KFgjArjU/vG/aywm7Efgu+iPc01Y= +github.com/getsentry/sentry-go v0.24.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI= +github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= +github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= +github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..51ec4aa --- /dev/null +++ b/lib.go @@ -0,0 +1,783 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "math/rand" + "os" + "reflect" + "strings" + "syscall" + "time" + + "github.com/eleksir/go-xmpp" + "github.com/hjson/hjson-go" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" +) + +// sigHandler Хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения. +func sigHandler() { + log.Debug("Installing signal handler") + + for s := range sigChan { + switch s { + case syscall.SIGINT: + log.Infoln("Got SIGINT, quitting") + case syscall.SIGTERM: + log.Infoln("Got SIGTERM, quitting") + case syscall.SIGQUIT: + log.Infoln("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.Debugf("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) + } +} + +// 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 + } + + // Если список фраз с которыми стартует бот пустой, вносим в него 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) //nolint:wsl + + break + } + + if !configLoaded { + return errors.New("config was not loaded!") //nolint:goerr113,revive,stylecheck + } + + return err //nolint:wrapcheck +} + +// establishConnection устанавливает соединение с jabber-сервером. +func establishConnection() { + var err error + + if connecting && !isConnected { + return + } + + // Проставляем глобальные переменные. + connecting = true + isConnected = false + roomsConnected = make([]string, 0) + + talk, err = options.NewClient() + + if err != nil { + gTomb.Kill(err) + + return + } + + // По идее keepalive должен же проходить только, если мы уже на сервере, так? + if _, err := talk.SendKeepAlive(); err != nil { + log.Errorf("Try to send initial KeepAlive, got error: %s", err) + + gTomb.Kill(err) + } + + log.Info("Connected") + + // Джойнимся к чятикам, но делаем это в фоне, чтобы не блочиться на ошибках, например, если бота забанили + for _, room := range config.Jabber.Channels { + go joinMuc(room) + } + + go 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 { + log.Infof("Unable to send disco#info to jabber server: %s", err) + gTomb.Kill(err) + + return + } +} + +// joinMu джойнится к конференциям/каналам/комнатам в джаббере. +func joinMuc(room string) { + log.Debugf("Sending disco#info from %s to %s", talk.JID(), room) + + if _, err := talk.DiscoverInfo(talk.JID(), room); err != nil { + log.Infof("Unable to send disco#info to MUC %s: %s", room, err) + + gTomb.Kill(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 + } + } + + if _, err := talk.JoinMUCNoHistory(room, config.Jabber.Nick); err != nil { + log.Errorf("Unable to join to MUC: %s", room) + + gTomb.Kill(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 + } + + // Вот теперь точно можно слать статус. + log.Infof("Joined to MUC: %s", room) + + go RotateStatus(room) +} + +// probeServerLiveness проверяет живость соединения с сервером. Для многих серверов обязательная штука, без которой +// они выкидывают клиента через некоторое время неактивности. +func probeServerLiveness() { //nolint:gocognit + defer gTomb.Done() + + for { + select { + case <-gTomb.Dying(): + return + + default: + for { + if shutdown { + return + } + + 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, + ) + + gTomb.Kill(err) + + continue + + // По-умолчанию, мы отправляем 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 { + gTomb.Kill(err) + + continue + } + + 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 { + gTomb.Kill(err) + + continue + } + + serverPingTimestampTx = time.Now().Unix() + } + + // Сервер не анонсировал, что умеет в c2s пинги + default: + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + gTomb.Kill(err) + + continue + } + } + } else { // Сервер не ответил на disco#info + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + gTomb.Kill(err) + + continue + } + } + } + } + } +} + +// probeMUCLiveness Пингует MUC-и, нужно для проверки, что клиент ещё находится в MUC-е. +func probeMUCLiveness() { //nolint:gocognit + for { + select { + case <-gTomb.Dying(): + return + + default: + 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+"/"+config.Jabber.Nick); 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"] { + go func(room string) { + // Небольшая рандомная задержка перед пингом комнаты. + 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 { + err := fmt.Errorf("unable to ping MUC %s: %w", room, err) + gTomb.Kill(err) + } + }(room) + } + } + + time.Sleep(time.Duration(config.Jabber.MucPingDelay) * time.Second) + } + } + } +} + +// RotateStatus периодически изменяет статус бота в MUC-е согласно настройкам из кофига. +func RotateStatus(room string) { + defer gTomb.Done() + + for { + select { + case <-gTomb.Dying(): + return + + default: + // 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 { + log.Infof("Unable to send presence to MUC %s: %s", room, err) + gTomb.Kill(err) + + return + } + + // Если мы не хотим ротировать, то цикл нам тут не нужен, просто выходим. + if config.Jabber.RuntimeStatus.RotationTime == 0 { + gTomb.Done() + + return + } + + 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 +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/main.go b/main.go new file mode 100644 index 0000000..9f73c9f --- /dev/null +++ b/main.go @@ -0,0 +1,224 @@ +package main + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "path/filepath" + "time" + + "github.com/eleksir/go-xmpp" + "github.com/go-redis/redis/v8" + log "github.com/sirupsen/logrus" + "gopkg.in/tomb.v1" +) + +func init() { + log.SetFormatter(&log.TextFormatter{ //nolint:exhaustruct + DisableQuote: true, + DisableLevelTruncation: false, + DisableColors: true, + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + + // Добавим динамически формируемый путь до конфига в список возможных положений конфига в системе + executablePath, err := os.Executable() + + if err != nil { + log.Errorf("unable to get current executable path: %s", err) + + os.Exit(1) + } + + configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath)) + configLocations = append(configLocations, configJSONPath) + + if err := readConfig(); err != nil { + log.Error(err) + + os.Exit(1) + } + + // no panic + switch config.Loglevel { + case "fatal": + log.SetLevel(log.FatalLevel) + case "error": + log.SetLevel(log.ErrorLevel) + case "warn": + log.SetLevel(log.WarnLevel) + case "info": + log.SetLevel(log.InfoLevel) + case "debug": + log.SetLevel(log.DebugLevel) + case "trace": + log.SetLevel(log.TraceLevel) + default: + log.SetLevel(log.InfoLevel) + } +} + +func main() { + // Откроем лог и скормим его логгеру + if config.Log != "" { + logfile, err := os.OpenFile(config.Log, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + + if err != nil { + log.Fatalf("Unable to open log file %s: %s", config.Log, err) + } + + log.SetOutput(logfile) + } + + myLogLevel := log.GetLevel() + log.Warnf("Loglevel set to %v", myLogLevel) + + verboseClient := false + + if myLogLevel == log.TraceLevel { + verboseClient = true + } + + // github.com/mattn/go-xmpp пишет в stdio, нам этого не надо, ловим выхлоп его в logrus с уровнем trace + xmpp.DebugWriter = log.WithFields(log.Fields{"logger": "stdlib"}).WriterLevel(log.TraceLevel) + + // Хэндлер сигналов не надо трогать, он нужен для завершения программы целиком + go sigHandler() + signal.Notify(sigChan, os.Interrupt) + + // Иницализируем redis-клиента + redisClient = redis.NewClient(&redis.Options{ //nolint:exhaustruct + Addr: fmt.Sprintf("%s:%d", config.Redis.Server, config.Redis.Port), + }) + + log.Debugf("Lazy connect() to redis at %s:%d", config.Redis.Server, config.Redis.Port) + subscriber = redisClient.Subscribe(ctx, config.Redis.MyChannel) + + go redisLoop(subscriber.Channel()) + + for { + options = &xmpp.Options{ //nolint:exhaustruct + Host: fmt.Sprintf("%s:%d", config.Jabber.Server, config.Jabber.Port), + User: config.Jabber.User, + Password: config.Jabber.Password, + Resource: config.Jabber.Resource, + NoTLS: !config.Jabber.Ssl, + StartTLS: config.Jabber.StartTLS, + TLSConfig: &tls.Config{ //nolint:exhaustruct + ServerName: config.Jabber.Server, + InsecureSkipVerify: !config.Jabber.SslVerify, //nolint:gosec + }, + InsecureAllowUnencryptedAuth: config.Jabber.InsecureAllowUnencryptedAuth, + Debug: verboseClient, + Session: false, + Status: "xa", + StatusMessage: randomPhrase(config.Jabber.StartupStatus), + DialTimeout: time.Duration(config.Jabber.ConnectionTimeout) * time.Second, + } + + // Через tomb попробуем сделать выход горутинок управляемым + gTomb = tomb.Tomb{} + + // Устанавливаем соединение и гребём события, посылаемые сервером + myLoop() + + log.Error(gTomb.Wait()) + time.Sleep(time.Duration(config.Jabber.ReconnectDelay) * time.Second) + } +} + +func myLoop() { + defer gTomb.Done() + + for { + select { + case <-gTomb.Dying(): + return + default: + // Зададим начальное значение глобальным переменным + serverPingTimestampRx = 0 + serverPingTimestampTx = 0 + roomsConnected = make([]string, 1) + lastActivity = 0 + lastServerActivity = 0 + lastMucActivity = NewCollection() + serverCapsQueried = false + serverCapsList = NewCollection() + mucCapsList = NewCollection() + serverPingTimestampTx = 0 + serverPingTimestampRx = 0 + roomPresences = NewCollection() + + // Установим коннект + establishConnection() + + serverPingTimestampRx = time.Now().Unix() // Считаем, что если коннект запустился, то первый пинг успешен + + // Тыкаем сервер палочкой, проверяем, что коннект жив и переустанавливаем его, если он не жив + go probeServerLiveness() + + // Тыкаем muc-и палочкой, проверяем, что они живы и пере-заходим в них, если пинги пропали + go probeMUCLiveness() + + // Гребём сообщения... + for { + // Стриггерилось завершение работы приложения, или соединение не установлено (порвалось, например) + // грести не надо + if shutdown { + break + } + + if !isConnected { + continue + } + + chat, err := talk.Recv() + + if err != nil { + log.Errorf("Unable to get events from server: %s", err) + + switch { + // Стрим не читается, он закрылся с той стороны во время чтения + case errors.Is(err, io.EOF): + err := fmt.Errorf("tcp stream closed while reading, err=%w", err) + gTomb.Kill(err) + + continue + + // Пытаемся читать закрытый сокет + case errors.Is(err, net.ErrClosed): + err := fmt.Errorf("unable to read closed socket, err=%w", err) + gTomb.Kill(err) + + continue + + // Не смогли записать в сокет + case errors.Is(err, net.ErrWriteToConnected): + err := fmt.Errorf("unable to write to socket, err=%w", err) + gTomb.Kill(err) + + continue + + // Не сетевая проблема + default: + // Это уже что-то странное. + // Вероятно, ошибка парсинга xml. Собственно, баг сервера, тут мы ничего поделать не можем + err := fmt.Errorf("error during parsing received message, err=%w", err) + gTomb.Kill(err) + + continue + } + } + + parseEvent(chat) + } + } + } +} + +/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */ diff --git a/redis.go b/redis.go new file mode 100644 index 0000000..d81c1e4 --- /dev/null +++ b/redis.go @@ -0,0 +1,141 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/eleksir/go-xmpp" + "github.com/go-redis/redis/v8" + log "github.com/sirupsen/logrus" +) + +func redisLoop(redisMsgChan <-chan *redis.Message) { + // Обработчик событий от редиски + for msg := range redisMsgChan { + if shutdown { + return + } + + if err := redisMsgParser(msg.Payload); err != nil { + log.Warn(err) + } + } +} + +// 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: */ diff --git a/settings-db-util.go b/settings-db-util.go new file mode 100644 index 0000000..b43b6e8 --- /dev/null +++ b/settings-db-util.go @@ -0,0 +1,126 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "io/fs" + + "github.com/cockroachdb/errors/oserror" + "github.com/cockroachdb/pebble" + log "github.com/sirupsen/logrus" +) + +// StoreKV сохраняет в указанной бд ключ и значение. +func StoreKV(db *pebble.DB, key string, value string) error { + var ( + kArray = []byte(key) //nolint:revive + 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) //nolint:revive + 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: */ diff --git a/types.go b/types.go new file mode 100644 index 0000000..0186a37 --- /dev/null +++ b/types.go @@ -0,0 +1,139 @@ +package main + +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 []string `json:"channels"` + 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 { + //