commit 41afa7d9efeffa20b561290833f150d9a23d5d11 Author: eleksir Date: Sun Jun 11 21:03:41 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f910d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/** +.vscode/** +.vstags +data/config.json +echo-jabber-bot + diff --git a/Design b/Design new file mode 100644 index 0000000..55a54fe --- /dev/null +++ b/Design @@ -0,0 +1,51 @@ +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. 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..bf6d203 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +#!/usr/bin/env gmake -f + +GOOPTS=CGO_ENABLED=0 +BUILDOPTS=-ldflags="-s -w" -a -gcflags=all=-l -trimpath + +all: clean build + +build: + ${GOOPTS} go build ${BUILDOPTS} -o echo-jabber-bot collection.go types.go globals.go lib.go event_parser.go main.go + +clean: + go clean + +upgrade: + rm -rf vendor + 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..be61834 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Echo Jabber Bot + +Простой бот, работающий по протоколу xmpp. Его задача - демонстрация возможностей библиотеки github.com/mattn/go-xmpp. + +На его основе (будет?) построен xmpp-фронтэнд бота aleesa-bot. + +## Что он может? + +Может повторять все сказанные в чят сообщения. Умеет работать с приватными и публичными чатами. + +Имеет возможность соединяться с сервером по протоколу xmpp как с шифрованием, так и без. Шифрование работает по +классическим механикам (шифрованный канал связи, без возможности коммуникации в незашифрованном виде), без поддержки +механизма start tls. Что-то в гошке или на стороне сервера при выборе StartTLS не работает. + +## Что он не может? + +* Не может заходить в комнаты, защищённые паролем. +* Не умеет разгадывать капчу. + +## Как заставить его работать? + +Не надо этого делать. Он уже работает, потому что существует. + +## Дисклеймер + +Весь дисклеймер в файле LICENSE.txt :) diff --git a/TODO b/TODO new file mode 100644 index 0000000..392ba47 --- /dev/null +++ b/TODO @@ -0,0 +1,5 @@ +Избавиться от рекурсии - на любых ошибках возвращать контекст как можно выше по стэку. В идеале - прям в main. +Хэндлить ошибки оттуда. + +Сейчас у меня в случае ошибки копится рекусия реконнектов и потом бот хочет много раз подцепиться к комнатам и +оповестить их о своём состоянии. diff --git a/collection.go b/collection.go new file mode 100644 index 0000000..9805c97 --- /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{ + 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/data/sample_config.json b/data/sample_config.json new file mode 100644 index 0000000..9bf7024 --- /dev/null +++ b/data/sample_config.json @@ -0,0 +1,60 @@ +{ + # Фронт-энд бота + "jabber": { + # Если не задан, будет localhost + "server": "jabber.tld", + # Если не задано - 5222 для нешифрованного соединения или для starttls и 5223 для голого (старорежимного) ssl + "port": 5222, + # Включить шифрование + "ssl": true, + # Это более современный протокол и как раз на нём работет большинство jabber-серверов, порт 5222, + # jabber.ru по этому протоколу как-то странно работает, если его включить, я получаю + # tls: first record does not look like a TLS handshake + "starttls" : true, + # Не проверяем сертификаты по-умолчанию. + # Вам правда хочется всё это проверять? Там по идее могут быть всякие Lets Encrypt, которые хоть и работают, но + # создают кучу проблем + "ssl_verify": false, + # Позволять авторизацию логин-паролем по незашифрованному каналу связи + "insecureallowunencryptedauth": false, + # Таймаут установления соединения с сервером, в секундах. 10 секунд, если не задано. + "connection_timeout": 10, + # Задержка перед пере-коннекчиванием. 3 секунды, если не задано. + "reconnect_delay": 3, + # Промежуток между пингами сервера, для определения его живости. По-умолчанию 60 секунд. + # Пинги посылаются, если в течение этого времени не было активности со стороны сервера. + "server_ping_delay": 60, + # Промежуток между пингами muc-ов. По-умолчанию 900 секунд. (Согласно рекомендации в xep-0410.) + # Пинги посылаются, если в течение этого времени не было активности со стороны muc-а. + "muc_ping_delay": 900, + # Сколько ждать перед переджоиванием к комнате. По-умолчанию, 3 секунды. + "muc_rejoin_delay": 3, + # Ping-to-ping splay delay. Варьируемая задержка между пингами, чтобы не набирать высокий message rate. + # По-умолчанию, 3 секунды. Под капотом преобразуются в миллисекунды. Важна для пингов MUC-ов, особенно, + # если их МНОГО. + "ping_splay_delay": 3, + # Ник бота, с которым он будет заходить в комнаты + "nick": "echo", + # Имя пользователя для авторизации на сервере + "user": "echo@jabber.tld", + # Пароль для авторизации + "password": "secret", + # Jabber-каналы, к которым бот попробует присоединиться при старте + "channels": [ + "my_channel@conference.jabber.tld", + "another_channel@conference.jabber.tld" + ] + }, + + # Многословность логов. Если не задано, то info. Debug - ДЕЙСТВИТЕЛЬНО вербозный уровень логгирования. + # Есть ещё trace :) + "loglevel": "debug", + + # Если не задан, логи идут в STDOUT + "log": "/var/log/echo-jabber-bot/echo-jabber-bot.log", + + # Суть эхо бота в том, чтобы повторять всё что скажут + # (вообще говоря, суть в том, чтобы реализовать минимальный набор фич, сделать работающий вариант клиента) + # Если true - бот будет молчать, если false - бот будет повторять сказанное + "noecho": false +} diff --git a/event_parser.go b/event_parser.go new file mode 100644 index 0000000..cf29acb --- /dev/null +++ b/event_parser.go @@ -0,0 +1,587 @@ +package main + +import ( + "encoding/xml" + "fmt" + "strings" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" +) + +func parseEvent(e interface{}) { + 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) + + if config.NoEcho { + return + } + + // Топик чятика присылается в виде сообщения с subject, но без text + // В то же время сообщения от людей приходят с пустым subject, но с заполненным text + if v.Text != "" { + // Чятики бывают групповые и не групповые, от этого зависит Remote, куда направлять сообщение + switch v.Type { //nolint:whitespace + + // Групповой чятик + case "groupchat": + log.Debugf("Message from public chat") + 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 + } + + log.Debugf("Trying to echo message back to chat %s", dest) + + if _, err := talk.Send( + xmpp.Chat{ //nolint:exhaustruct + Remote: dest, + Text: v.Text, + Type: v.Type, + }, + ); err != nil { + log.Errorf("Unable to send message to %s: %s", v.Remote, err) + reEstablishConnection() + } + + 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.Debug("Definitely not public chat") + log.Trace(spew.Sdump(e)) + + if config.NoEcho { + return + } + + log.Debugf("Trying to echo message back to chat %s", v.Remote) + + if _, err := talk.Send( + xmpp.Chat{ //nolint:exhaustruct + Remote: v.Remote, + Text: v.Text, + Type: v.Type, + }, + ); err != nil { + log.Errorf("Unable to send message to %s: %s", v.Remote, err) + reEstablishConnection() + } + + 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 + var parseError bool + + if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { + switch iqStruct.Xmlns { //nolint:whitespace + + // Запрос номера версии приложения + case "jabber:iq:version": + log.Infof("Got IQ get request for version from %s", v.From) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + "echo-jabber-bot4.с_чем-толинупс", + ); err != nil { + log.Errorf("Unable to send verion info to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + parseError = false + + // Нам прислали попингуй + case "urn:xmpp:ping": + log.Infof("Got IQ get request for pong from %s", v.From) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + "", + ); err != nil { + log.Errorf("Unable to send pong to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + parseError = false + + // У нас запросили время последней активности. В нашем случае это либо время запуска клиента, либо время + // последней фразы в чяти. + case "jabber:iq:last": + log.Infof("Got IQ get last activity time request from %s", v.From) + + answer := fmt.Sprintf( + "", + time.Now().Unix()-lastActivity, + ) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send last activity time to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + 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 { + log.Errorf("Unable to send disco#info to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + parseError = false + + // Спрашивают список команд, чтобы поуправлять этим клиентом + case xmpp.XMPPNS_DISCO_ITEMS: + log.Infof("Got IQ get disco#items request from %s, answer service unavailable", v.From) + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf("Unable to send disco#items to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + 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) + + answer := fmt.Sprintf( + "", + time.Now().UTC().Format(time.RFC3339), + ) + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + answer, + ); err != nil { + log.Errorf("Unable to send urn:xmpp:time to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + + 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) + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf( + "Unable to send pubsub feature unimplemented to jabber server: id=%s, err=%s", + id, + err, + ) + + reEstablishConnection() + } + + parseError = false + + default: + log.Info("Got unknown IQ get pubsub something request, discarding") + log.Info(spew.Sdump(e)) + parseError = true + } + } 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.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeResult, + "", + ); err != nil { + log.Errorf("Unable to send pong to jabber server: id=%s, err=%s", id, err) + reEstablishConnection() + } + } + + 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() + + // Похоже на понг второй стадии 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 reqest for set something. Answer not implemented") + + answer := "" + answer += "" + answer += "" + answer += "" + + if id, err := talk.RawInformation( + v.To, + v.From, + v.ID, + xmpp.IQTypeError, + answer, + ); err != nil { + log.Errorf("Unable to send set feature unimplemented to jabber server: id=%s, err=%s", + id, + err, + ) + + reEstablishConnection() + } + + log.Debug(spew.Sdump(e)) + + // Нам прилетело сообщение об ошибке + case xmpp.IQTypeError: + // Если сервер не хочет пинговаться и отвечает ошибкой на пинг, то наверно он не умеет в пинги, + // хотя если мы его пингуем, значит он анонсировал такой capability. Вот, засранец! + var iqPingStruct jabberIqPing + var 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 jabberIqErrorCancelNotAccepatble + + 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 { + log.Errorf("Looks like connection to server also lost") + + reEstablishConnection() + } + } else { + log.Error( + "looks like message parsed as jabberIqErrorCancelNotAccepatble but we're not in given room", + ) + + log.Error(spew.Sdump(e)) + } + } else { + log.Error( + "Looks like message parsed as jabberIqErrorCancelNotAccepatble 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 reqest. 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" { + 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", + v.Type, v.From, v.To, v.Show, v.Status) + + if v.Show == "" && v.Status == "" { + if from := strings.Split(v.From, "/"); from[1] == config.Jabber.Nick { + roomsConnected = append(roomsConnected, from[0]) + } + } + } + + // Ответ на запрос поддерживаемых фич, который "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 + } + + 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..7fdad30 --- /dev/null +++ b/globals.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + + "github.com/eleksir/go-xmpp" +) + +// Config - это у нас глобальная штука :) +var config myConfig + +// Ставится в 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 = NewCollection() + +// Получен ли ответ на запрос disco#info к серверу +var serverCapsQueried bool + +// sync.Map-ка с капабилити сервера +var serverCapsList = NewCollection() + +// sync.Map-ка с комнатами и их capability +var mucCapsList = NewCollection() + +// Время, когда был отправлен c2s ping +var serverPingTimestampTx int64 + +// Время, когда был принят s2c pong +var serverPingTimestampRx int64 + +/* 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..b70b05f --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module echo-jabber-bot + +go 1.20 + +require ( + github.com/davecgh/go-spew v1.1.1 + github.com/eleksir/go-xmpp v1.2.1 + github.com/hjson/hjson-go v3.3.0+incompatible + github.com/sirupsen/logrus v1.9.3 + golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 +) + +require ( + golang.org/x/crypto v0.10.0 // indirect + golang.org/x/sys v0.9.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d9a2ed0 --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +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/eleksir/go-xmpp v1.2.1 h1:3I5nEvkKrPOOyE29B2Z5XwzPuiph8b7ckBzCja7f+S8= +github.com/eleksir/go-xmpp v1.2.1/go.mod h1:KnvbXvUpttEVFl8l5eDO2Q7J97ZxtTMvOsDWULD2Rn8= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= +golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib.go b/lib.go new file mode 100644 index 0000000..7cb3da2 --- /dev/null +++ b/lib.go @@ -0,0 +1,524 @@ +package main + +import ( + "encoding/json" + "fmt" + "math/rand" + "os" + "path/filepath" + "syscall" + "time" + + "github.com/eleksir/go-xmpp" + "github.com/hjson/hjson-go" + log "github.com/sirupsen/logrus" + "golang.org/x/exp/slices" +) + +// Хэндлер сигналов закрывает все бд, все сетевые соединения и сваливает из приложения +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 + } + + // Чтобы не срать в логи ошибками, проставим shutdown state приложения в true + shutdown = true + + 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) + } + } + + os.Exit(0) + } +} + +// Читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге нет +func readConfig() { + configLoaded := false + executablePath, err := os.Executable() + + if err != nil { + log.Errorf("Unable to get current executable path: %s", err) + } + + configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath)) + + locations := []string{ + "~/.echo-jabber-bot.json", + "~/echo-jabber-bot.json", + "/etc/echo-jabber-bot.json", + configJSONPath, + } + + for _, location := range locations { + 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 + var 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 + } + + // Значения для Jabber-клиента + if sampleConfig.Jabber.Server == "" { + sampleConfig.Jabber.Server = "localhost" + log.Errorf("Jabber server is not defined in config, using localhost") + } + + if sampleConfig.Jabber.Port == 0 { + sampleConfig.Jabber.Port = 5552 + + if sampleConfig.Jabber.Ssl { + if !sampleConfig.Jabber.StartTLS { + sampleConfig.Jabber.Port = 5553 + } + } + + log.Infof("Jabber port is not defined in config, using 6667") + } + + 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 + } + + if sampleConfig.Jabber.ReconnectDelay == 0 { + sampleConfig.Jabber.ReconnectDelay = 3 + } + + if sampleConfig.Jabber.ServerPingDelay == 0 { + sampleConfig.Jabber.ServerPingDelay = 60 + } + + if sampleConfig.Jabber.MucPingDelay == 0 { + sampleConfig.Jabber.MucPingDelay = 900 + } + + if sampleConfig.Jabber.MucRejoinDelay == 0 { + sampleConfig.Jabber.MucRejoinDelay = 3 + } + + if sampleConfig.Jabber.PingSplayDelay == 0 { + sampleConfig.Jabber.PingSplayDelay = 3 + } + + if sampleConfig.Jabber.Nick == "" { + log.Errorf("Jabber nick is not defined in config, quitting") + os.Exit(1) + } + + if sampleConfig.Jabber.User == "" { + sampleConfig.Jabber.User = fmt.Sprintf("%s@%s", sampleConfig.Jabber.Nick, sampleConfig.Jabber.Server) + } + + // Если sampleConfig.Jabber.Password не задан, то авторизации через Nickserv или SASL не будет + // Если sampleConfig.Jabber.Sasl не задан, то авторизация происходит через NickServ + + // Нам бот нужен в каких-то чат-румах, а не "просто так" + if len(sampleConfig.Jabber.Channels) < 1 { + log.Errorf("No jabber channels/rooms defined in config, quitting") + os.Exit(1) + } + + if sampleConfig.Loglevel == "" { + sampleConfig.Loglevel = "info" + } + + // sampleConfig.Log = "" if not set + + config = sampleConfig + configLoaded = true + log.Infof("Using %s as config file", location) + + break + } + + if !configLoaded { + log.Error("Config was not loaded! Refusing to start.") + os.Exit(1) + } +} + +func establishConnection() { + var err error + + if connecting && !isConnected { + return + } + + // проставляем глобальные переменные + connecting = true + isConnected = false + roomsConnected = make([]string, 0) + + for { + talk, err = options.NewClient() + + if err != nil { + log.Error(err) + + reEstablishConnection() + } else { + break + } + } + + // По идее keepalive должен же проходить только, если мы уже на сервере, так? + for { + if _, err := talk.SendKeepAlive(); err != nil { + log.Errorf("Try to send initial KeepAlive, got error: %s", err) + time.Sleep(time.Duration(config.Jabber.ReconnectDelay) * time.Second) + + // Понятно, что мы уже не в процессе установления соединения, а произошла ошибка + connecting = false + reEstablishConnection() + } else { + log.Info("Connected") + + break + } + } + + // Джойнимся к чятикам, но делаем это в фоне, чтобы не блочиться на ошибках, например, если бота забанили + for _, room := range config.Jabber.Channels { + go joinMuc(room) + } + + if _, err := talk.SendPresence( + xmpp.Presence{ //nolint:exhaustruct + Status: "Ready to chat", + }, + ); err != nil { + log.Infof("Unable to send presence to jabber server: %s", err) + + reEstablishConnection() + } + + 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) + + reEstablishConnection() + } +} + +func reEstablishConnection() { + // Серверные capabilities нам *уже* неизвестны + serverCapsQueried = false + serverCapsList = NewCollection() + mucCapsList = NewCollection() + lastMucActivity = NewCollection() + + // Пока соединение не установлено, мы про пинги ничего не знаем + serverPingTimestampRx = time.Now().Unix() // Считаем, что если коннект запустился, то первый пинг успешен + serverPingTimestampTx = 0 + time.Sleep(time.Duration(config.Jabber.ReconnectDelay) * time.Second) + connecting = false + establishConnection() +} + +// Джойнится к конференциям/каналам/комнатам в джаббере +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) + + reEstablishConnection() + } + + // Ждём, пока muc нам вернёт список фичей + for i := 0; i < (20 * int(config.Jabber.ConnectionTimeout)); i++ { + var myRoom interface{} + var supported bool + var 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 + } else { + 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) + + reEstablishConnection() + } else { + log.Infof("Joining to MUC: %s", room) + } + + // Ждём, когда прилетит presence из комнаты, тогда мы точно знаем, что мы вошли + for i := 0; i < (20 * int(config.Jabber.ConnectionTimeout)); i++ { + time.Sleep(50 * time.Millisecond) + + if slices.Contains(roomsConnected, room) { + break + } + } + + // Вот теперь точно можно слать статус + log.Infof("Joined to MUC: %s", room) + + if _, err := talk.SendPresence( + xmpp.Presence{ //nolint:exhaustruct + To: room, + Status: "Ready to chat", + }, + ); err != nil { + log.Infof("Unable to send presence to MUC %s: %s", room, err) + reEstablishConnection() + } +} + +// Проверяет живость соединения с сервером +func probeServerLiveness() { + for { + if shutdown { + return + } + + sleepTime := time.Duration(config.Jabber.ServerPingDelay) * 1000 * time.Millisecond + sleepTime += time.Duration(rand.Int63n(1000*config.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec + 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{} + var 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): + log.Debugf( + "Stall connection detected. No c2s pong for %d seconds", + rxTimeAgo, + ) + + reEstablishConnection() + + // По-умолчанию, мы отправляем 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 { + reEstablishConnection() + } else { + 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 { + reEstablishConnection() + } else { + serverPingTimestampTx = time.Now().Unix() + } + } + + // Сервер не анонсировал, что умеет в c2s пинги + default: + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + reEstablishConnection() + + continue + } + } + } else { // Сервер не ответил на disco#info + log.Debug("Sending keepalive whitespace ping") + + if _, err := talk.SendKeepAlive(); err != nil { + reEstablishConnection() + + continue + } + } + } +} + +// Пингует MUC-и, нужно для проверки, что клиент ещё находится в MUC-е +func probeMUCLiveness() { + for { + for _, room := range roomsConnected { + var exist bool + var 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 { + reEstablishConnection() + } + }(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 //nolint:gosec + time.Sleep(sleepTime) + + log.Debugf("Sending MUC ping from %s to %s", talk.JID(), room) + + if err := talk.PingS2S(talk.JID(), room); err != nil { + reEstablishConnection() + } + }(room) + } + } + + time.Sleep(time.Duration(config.Jabber.MucPingDelay) * time.Second) + } +} + +/* 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..ac01258 --- /dev/null +++ b/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "os" + "os/signal" + "time" + + "github.com/eleksir/go-xmpp" + log "github.com/sirupsen/logrus" +) + +func init() { + log.SetFormatter(&log.TextFormatter{ //nolint:exhaustruct + DisableQuote: true, + DisableLevelTruncation: false, + DisableColors: true, + FullTimestamp: true, + TimestampFormat: "2006-01-02 15:04:05", + }) + + readConfig() + + // 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) + } + + // Пока про capabilities мы ничего не знаем + serverCapsQueried = false + + // Мы пока что не пинговали сервер. + serverPingTimestampRx = 0 + serverPingTimestampTx = 0 +} + +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) + + 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: "echo bot", + 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: "echo bot is starting up", + DialTimeout: 10 * time.Second, + } + + go sigHandler() + signal.Notify(sigChan, os.Interrupt) + // Установим коннект + 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): + reEstablishConnection() + + continue + + // Пытаемся читать закрытый сокет + case errors.Is(err, net.ErrClosed): + // reEstablishConnection() + + continue + + // Не смогли записать в сокет + case errors.Is(err, net.ErrWriteToConnected): + reEstablishConnection() + + continue + + // Не сетевая проблема + default: + // Это уже что-то странное. + // Вероятно, ошибка парсинга xml. Собственно, баг сервера, тут мы ничего поделать не можем, разве что + // пропустить станцу... + // Cooldown + + time.Sleep(300 * time.Second) + + continue + } + } + + parseEvent(chat) + } +} + +/* 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..9d81519 --- /dev/null +++ b/types.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/xml" +) + +// Конфиг +type myConfig struct { + 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"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + Channels []string `json:"channels"` + } + Loglevel string `json:"loglevel,omitempty"` + Log string `json:"log,omitempty"` + NoEcho bool `json:"noecho,omitempty"` +} + +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" +} + +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"` +} + +type jabberTimeIqGetQuery struct { + //