Refactor layout to go-style, update deps

This commit is contained in:
eleksir
2024-10-21 19:47:11 +03:00
parent 27f79905f4
commit 0264e675a4
338 changed files with 8007 additions and 12808 deletions

100
internal/jabber/bf.go Normal file
View File

@@ -0,0 +1,100 @@
package jabber
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"github.com/jbrukh/bayesian"
log "github.com/sirupsen/logrus"
)
const (
goodDictionaryFile string = "./data/good_dictionary.txt"
badDictionaryFile string = "./data/bad_dictionary.txt"
dataFile string = "./data/data.bin"
Bad bayesian.Class = "Bad"
Good bayesian.Class = "Good"
)
// Выдаёт "очки похожести" для данной фразы.
func checkPhrase(s string) error {
classifier, err := bayesian.NewClassifierFromFile(dataFile)
if err != nil {
return fmt.Errorf("unable to open %s: %w", dataFile, err)
}
s = nStringLower(s)
scores, _, _ := classifier.LogScores(strings.Split(s, " "))
_, err = fmt.Printf("Score %v\n", scores[0])
return err
}
// Выучивает слова из предопределённых словарей.
func learn() error {
classifier := bayesian.NewClassifier(Bad, Good)
if err := feed(classifier, goodDictionaryFile, Good); err != nil {
return err
}
if err := feed(classifier, badDictionaryFile, Bad); err != nil {
return err
}
if err := classifier.WriteToFile(dataFile); err != nil {
return fmt.Errorf("unable to save data to file %s: %w", dataFile, err)
}
return nil
}
// Дополняет заданный класс фразами из указанного файла.
func feed(c *bayesian.Classifier, filename string, bc bayesian.Class) error {
fh, err := os.Open(filename)
if err != nil {
return fmt.Errorf("unable to open file %s: %w", filename, err)
}
defer func(fh *os.File) {
if err := fh.Close(); err != nil {
log.Errorf("unable to close %s cleanly: %s", filename, err)
}
}(fh)
reader := bufio.NewReader(fh)
for {
line, err := reader.ReadString('\n')
line = nStringLower(line)
if err != nil {
// Правильно обрабатываем последнюю строку и конец файла.
if err == io.EOF {
if line != "" {
stuff := strings.Split(line, " ")
c.Learn(stuff, bc)
}
break
}
return fmt.Errorf("unable to read %s: %w", filename, err)
}
if line != "" {
stuff := strings.Split(line, " ")
c.Learn(stuff, bc)
}
}
return nil
}

View File

@@ -0,0 +1,204 @@
package jabber
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/hjson/hjson-go"
log "github.com/sirupsen/logrus"
)
// ReadWhitelist читает и валидирует белые списки пользователей.
func (j *Jabber) ReadWhitelist() error {
var (
whitelistLoaded = false
err error
executablePath string
)
executablePath, err = os.Executable()
if err != nil {
err = fmt.Errorf("unable to get current executable path: %w", err)
return err
}
whitelistJSONPath := fmt.Sprintf("%s/data/whitelist.json", filepath.Dir(executablePath))
locations := []string{
"~/.buny-jabber-bot-whitelist.json",
"~/buny-jabber-bot-whitelist.json",
"/etc/buny-jabber-bot-whitelist.json",
whitelistJSONPath,
}
for _, location := range locations {
fileInfo, err := os.Stat(location)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём
if err != nil {
continue
}
// Файл белого списка длинноват для белого списка, попробуем следующего кандидата
if fileInfo.Size() > 2097152 {
log.Warnf("Whitelist file %s is too long for whitelist, skipping", location)
continue
}
buf, err := os.ReadFile(location)
// Не удалось прочитать, попробуем следующего кандидата
if err != nil {
log.Warnf("Skip reading whitelist file %s: %s", location, err)
continue
}
// Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку.
// Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку
// сериализуем в json, а потом json превращаем в структурку. Не очень эффективно, но он и нечасто требуется.
var (
sampleWhitelist MyWhiteList
tmp map[string]interface{}
)
err = hjson.Unmarshal(buf, &tmp)
// Не удалось распарсить - попробуем следующего кандидата
if err != nil {
log.Warnf("Skip parsing whitelist file %s: %s", location, err)
continue
}
tmpJSON, err := json.Marshal(tmp)
// Не удалось преобразовать map-ку в json
if err != nil {
log.Warnf("Skip parsing whitelist file %s: %s", location, err)
continue
}
if err := json.Unmarshal(tmpJSON, &sampleWhitelist); err != nil {
log.Warnf("Skip parsing whitelist file %s: %s", location, err)
continue
}
j.WhiteList = sampleWhitelist
whitelistLoaded = true
log.Infof("Using %s as whiteList file", location)
break
}
if !whitelistLoaded {
return errors.New("whitelist was not loaded") //nolint:goerr113
}
return err
}
// ReadBlacklist читает и валидирует чёрные списки пользователей.
func (j *Jabber) ReadBlacklist() error {
var (
blacklistLoaded = false
err error
executablePath string
)
executablePath, err = os.Executable()
if err != nil {
err = fmt.Errorf("unable to get current executable path: %w", err)
return err
}
whitelistJSONPath := fmt.Sprintf("%s/data/blacklist.json", filepath.Dir(executablePath))
locations := []string{
"~/.bunyPresense-jabber-bot-blacklist.json",
"~/bunyPresense-jabber-bot-blacklist.json",
"/etc/bunyPresense-jabber-bot-blacklist.json",
whitelistJSONPath,
}
for _, location := range locations {
fileInfo, err := os.Stat(location)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём
if err != nil {
continue
}
// Файл чёрного списка длинноват для чёрного списка, попробуем следующего кандидата
if fileInfo.Size() > 16777216 {
log.Warnf("Blacklist file %s is too long for blacklist, skipping", location)
continue
}
buf, err := os.ReadFile(location)
// Не удалось прочитать, попробуем следующего кандидата
if err != nil {
log.Warnf("Skip reading blacklist file %s: %s", location, err)
continue
}
// Исходя из документации, hjson какбы умеет парсить "кривой" json, но парсит его в map-ку.
// Интереснее на выходе получить структурку: то есть мы вначале конфиг преобразуем в map-ку, затем эту map-ку
// сериализуем в json, а потом json превращаем в структурку. Не очень эффективно, но он и нечасто требуется.
var (
sampleBlacklist MyBlackList
tmp map[string]interface{}
)
err = hjson.Unmarshal(buf, &tmp)
// Не удалось распарсить - попробуем следующего кандидата
if err != nil {
log.Warnf("Skip parsing blacklist file %s: %s", location, err)
continue
}
tmpJSON, err := json.Marshal(tmp)
// Не удалось преобразовать map-ку в json
if err != nil {
log.Warnf("Skip parsing blacklist file %s: %s", location, err)
continue
}
if err := json.Unmarshal(tmpJSON, &sampleBlacklist); err != nil {
log.Warnf("Skip parsing whitelist file %s: %s", location, err)
continue
}
j.BlackList = sampleBlacklist
blacklistLoaded = true
log.Infof("Using %s as blacklist file", location)
break
}
if !blacklistLoaded {
return errors.New("blacklist was not loaded") //nolint:goerr113
}
return err
}

383
internal/jabber/buny.go Normal file
View File

@@ -0,0 +1,383 @@
package jabber
import (
"fmt"
"regexp"
"strings"
"github.com/davecgh/go-spew/spew"
"github.com/eleksir/go-xmpp"
log "github.com/sirupsen/logrus"
)
// BunyPresense производит проверку по бело-чёрным спискам. Если presence пришёл от злодея (из чёрного списка), то
// отправляет его в бан.
func (j *Jabber) BunyPresense(v xmpp.Presence) error { //nolint:gocognit,gocyclo
var err error
// Если у presence-а есть JID и presence из одной из комнат, в которой мы есть и если его домен в чёрном
// списке, заносим пидора в список outcast-ов
if v.JID != "" {
// На всякий случай: себя никогда не баним, явным образом
if v.JID == j.Talk.JID() {
return err
}
room := strings.SplitN(v.From, "/", 2)[0]
if room == "" {
log.Infof("We got empty room field in presence event, which kinda strange: %s", spew.Sdump(v))
return err
}
goodJid := strings.SplitN(v.JID, "/", 2)[0]
// Обрабатываем правила белого списка
for _, good := range j.WhiteList.Whitelist {
// Глобальный белый список
if good.RoomName == "" {
for _, person := range good.Jid {
if person == goodJid {
return err
}
}
}
// Белый список для конкретных каналов
if good.RoomName == room {
for _, person := range good.Jid {
if person == goodJid {
return err
}
}
}
}
evilJid := strings.SplitN(v.JID, "/", 2)[0]
evilNick := ""
evilNicks := strings.SplitN(v.From, "/", 2)
// Давайте-ка похэндлим нонсенс - когда у нас в строке нету разделителя
if len(evilNicks) > 1 {
evilNick = evilNicks[1]
}
// Обрабатываем правила чёрного списка
for _, cRoom := range j.RoomsConnected {
if cRoom == room {
for _, bEntry := range j.BlackList.Blacklist {
// Обработаем правила глобального чёрного списка
if bEntry.RoomName == "" {
for _, jidRegexp := range bEntry.JidRe {
if jidRegexp == "" {
continue
}
re, err := regexp.Compile(jidRegexp)
if err != nil {
log.Errorf("Incorrect regexp in global blacklist: %s, skipping", jidRegexp)
continue
}
log.Debugf("Checking jid %s vs global blacklist regex %s", v.JID, jidRegexp)
if re.MatchString(v.JID) {
log.Warnf(
"Hammer falls on %s (%s): jid matches with global blacklist entry: %s",
v.From,
evilJid,
jidRegexp,
)
id, err := j.Squash(room, evilJid, bEntry.ReasonEnable, v.Type)
if err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
for _, nickRegexp := range bEntry.NickRe {
if nickRegexp == "" {
continue
}
re, err := regexp.Compile(nickRegexp)
if err != nil {
log.Errorf("Incorrect regexp in global blacklist: %s, skipping", nickRegexp)
continue
}
log.Debugf("Checking nick %s vs global blacklist regex %s", evilNick, nickRegexp)
if re.MatchString(evilNick) {
log.Warnf(
"Hammer falls on %s (%s): nick matches with global blacklist entry: %s",
v.From,
evilJid,
nickRegexp,
)
// Баним именно jid
id, err := j.Squash(room, evilJid, bEntry.ReasonEnable, v.Type)
if err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
continue
}
// Обработаем правила конкретного канала, комнаты конференции
if bEntry.RoomName == room {
for _, jidRegexp := range bEntry.JidRe {
if jidRegexp == "" {
continue
}
re, err := regexp.Compile(jidRegexp)
if err != nil {
log.Errorf("Incorrect regexp in room %s blacklist: %s, skipping", room, jidRegexp)
continue
}
log.Debugf("Checking jid %s vs room %s blacklist regex %s", v.JID, room, jidRegexp)
if re.MatchString(v.JID) {
log.Warnf(
"Hammer falls on %s (%s): jid matches with room blacklist entry: %s",
v.From,
evilJid,
jidRegexp,
)
id, err := j.Squash(room, evilJid, bEntry.ReasonEnable, v.Type)
if err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
if bEntry.RoomName == room {
for _, nickRegexp := range bEntry.NickRe {
if nickRegexp == "" {
continue
}
re, err := regexp.Compile(nickRegexp)
if err != nil {
log.Errorf("Incorrect regexp in room %s blacklist: %s, skipping", room, nickRegexp)
continue
}
log.Debugf("Checking nick %s vs room %s blacklist regex %s", evilJid, room, nickRegexp)
if re.MatchString(evilNick) {
log.Warnf(
"Hammer falls on %s (%s): nick matches with room blacklist entry: %s",
v.From,
evilJid,
nickRegexp,
)
// Баним именно jid
id, err := j.Squash(room, evilJid, bEntry.ReasonEnable, v.Type)
if err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
}
continue
}
}
}
}
}
return err
}
// BunyChat производит проверку сообщений участников чата по списку забаненных фраз и в случае нахождения запрещённого
// шаблона банит участника чата.
func (j *Jabber) BunyChat(v xmpp.Chat) error {
var (
room = (strings.SplitN(v.Remote, "/", 2))[0]
// nick = (strings.SplitN(v.Remote, "/", 2))[1]
err error
)
// Действовать мы можем только в рамках тех комнат, где явно присуствуем.
for _, cRoom := range j.RoomsConnected {
if cRoom == room {
// Перебирём правила чёрных списков.
for _, bEntry := range j.BlackList.Blacklist {
// Обработаем правила глобального чёрного списка
if bEntry.RoomName == "" {
for _, phraseRegexp := range bEntry.PhraseRe {
if phraseRegexp == "" {
continue
}
re, err := regexp.Compile(phraseRegexp)
if err != nil {
log.Errorf("Incorrect regexp in room %s blacklist: %s, skipping", room, phraseRegexp)
continue
}
log.Debugf("Checking phrase %s vs room %s blacklist regex %s", v.Text, room, phraseRegexp)
if re.MatchString(v.Text) {
realJID := j.GetRealJIDfromNick(v.Remote)
log.Warnf(
"Hammer falls on %s (%s): phrase matches with global blacklist entry: %s vs %s",
v.Remote,
realJID,
v.Text,
phraseRegexp,
)
if id, err := j.Squash(room, realJID, bEntry.ReasonEnable, v.Type); err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
}
// Обработаем правила чёрного списка для конкретной комнаты
if bEntry.RoomName == room {
for _, phraseRegexp := range bEntry.PhraseRe {
if phraseRegexp == "" {
continue
}
re, err := regexp.Compile(phraseRegexp)
if err != nil {
log.Errorf("Incorrect regexp in room %s blacklist: %s, skipping", room, phraseRegexp)
continue
}
log.Debugf("Checking phrase %s vs room %s blacklist regex %s", v.Text, room, phraseRegexp)
if re.MatchString(v.Text) {
realJID := j.GetRealJIDfromNick(v.Remote)
log.Warnf(
"Hammer falls on %s (%s): phrase matches with room blacklist entry: %s vs %s",
v.Remote,
realJID,
v.Text,
phraseRegexp,
)
if id, err := j.Squash(room, realJID, bEntry.ReasonEnable, v.Type); err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
return err
}
}
}
}
// Если включено, проверяем фразу на КАПС.
for _, channel := range j.C.Jabber.Channels {
if channel.Name == room && channel.AllCaps.Enabled {
// Нормализуем строку и вырежем из неё пробелы
normPhrase := strings.ReplaceAll(nString(v.Text), " ", "")
// Проверяем согласно тому, что длина фразы более чем сколько-то символов
if len(normPhrase) >= channel.AllCaps.MinLength {
normPhraseUpper := strings.ReplaceAll(nStringUpper(v.Text), " ", "")
if normPhrase == normPhraseUpper {
realJID := j.GetRealJIDfromNick(v.Remote)
id, err := j.Squash(room, realJID, false, v.Type)
if err != nil {
err := fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
}
}
}
}
}
break
}
}
return err
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,69 @@
package jabber
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: */

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

@@ -0,0 +1,210 @@
package jabber
import (
"fmt"
"slices"
"strings"
"github.com/davecgh/go-spew/spew"
"github.com/eleksir/go-xmpp"
log "github.com/sirupsen/logrus"
)
// Cmd парсит команды из чятика.
func (j *Jabber) Cmd(v xmpp.Chat) error {
var err error
switch {
case v.Text == fmt.Sprintf("%shelp", j.C.CSign) || v.Text == fmt.Sprintf("%sпомощь", j.C.CSign):
var (
answer string
chosenOneTalks = false
)
realJID := j.GetRealJIDfromNick(v.Remote)
for _, master := range j.C.Jabber.BotMasters {
if (strings.SplitN(realJID, "/", 2))[0] == master {
chosenOneTalks = true
}
}
if chosenOneTalks {
answer = fmt.Sprintf("%sпомощь - этот список команд\n", j.C.CSign)
answer += fmt.Sprintf("%shelp - this commands list\n", j.C.CSign)
answer += fmt.Sprintf("%srehash - reload white and black lists", j.C.CSign)
} else {
answer = "Ничем помочь не могу. Луна не светит на тебя."
}
dest := v.Remote
if v.Type == "groupchat" {
dest = (strings.SplitN(v.Remote, "/", 2))[0]
}
if _, err := j.Talk.Send(
xmpp.Chat{ //nolint:exhaustruct
Remote: dest,
Text: strings.TrimSpace(answer),
Type: v.Type,
},
); err != nil {
err = fmt.Errorf("unable to send message to %s: %w", v.Remote, err)
return err
}
case v.Text == fmt.Sprintf("%srehash", j.C.CSign): //nolint:wsl
/* Эта команда может прилетать из приватной беседы с realjid-ом, из приватной беседы с chat-nick-ом, а также из
* чятика.
* Groupchat мы можем определить по v.Type (groupchat) и тогда понятно что делать.
* А вот, например, приватные притязания определить несколько сложно-вато. Но можно попробовать, например,
* пытаться по списку presence-ов находить персонажа и его real jid и уже из этого определять master он или нет.
*/
// Попробуем определить каков алгоритм наших действий, в зависимости от типа сообщения
switch v.Type {
case "groupchat":
// Проверим, является ли "командир" избранным или это самозванец?
var (
chosenOneTalks = false
listsLoaded = true
)
realJID := j.GetRealJIDfromNick(v.Remote)
for _, master := range j.C.Jabber.BotMasters {
if (strings.SplitN(realJID, "/", 2))[0] == master {
chosenOneTalks = true
}
}
if !chosenOneTalks {
log.Infof(
"Command %srehash given by non-bot_master user %s(%s), ignoring",
j.C.CSign,
realJID,
v.Remote,
)
return err
}
if err := j.ReadWhitelist(); err != nil {
listsLoaded = false
var msg xmpp.Chat
msg.Remote = v.Remote
msg.Type = "chat" // private message
msg.Text = fmt.Sprint(err)
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
if err := j.ReadBlacklist(); err != nil {
listsLoaded = false
var msg xmpp.Chat
msg.Remote = v.Remote
msg.Type = "chat" // private message
msg.Text = fmt.Sprint(err)
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
if listsLoaded {
room := (strings.SplitN(v.Remote, "/", 2))[0]
var msg xmpp.Chat
msg.Remote = room
msg.Type = v.Type
msg.Text = "Сделано"
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
case "chat":
room := (strings.SplitN(v.Remote, "/", 2))[0]
// Реагировать на приватную команду "из комнаты", только если бот в комнате
if slices.Contains(j.RoomsConnected, room) {
var (
chosenOneTalks = false
listsLoaded = true
)
// Реагировать на rehash только если собеседник является bot master-ом
realJID := j.GetRealJIDfromNick(v.Remote)
for _, master := range j.C.Jabber.BotMasters {
if (strings.SplitN(realJID, "/", 2))[0] == master {
chosenOneTalks = true
}
}
if !chosenOneTalks {
log.Infof(
"Command %srehash given by non-bot_master user %s(%s), ignoring",
j.C.CSign,
realJID,
v.Remote,
)
return err
}
if err := j.ReadWhitelist(); err != nil {
listsLoaded = false
var msg xmpp.Chat
msg.Remote = v.Remote
msg.Type = v.Type
msg.Text = fmt.Sprint(err)
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
if err := j.ReadBlacklist(); err != nil {
listsLoaded = false
var msg xmpp.Chat
msg.Remote = v.Remote
msg.Type = v.Type
msg.Text = fmt.Sprint(err)
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
if listsLoaded {
var msg xmpp.Chat
msg.Remote = v.Remote
msg.Type = v.Type
msg.Text = "Сделано"
if _, err := j.Talk.Send(msg); err != nil {
return err
}
}
}
default:
log.Infof("Rehash command from outer space: %s", spew.Sdump(v))
}
return err
default:
return err
}
return err
}

View File

@@ -0,0 +1,710 @@
package jabber
import (
"encoding/json"
"encoding/xml"
"fmt"
"runtime"
"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 (j *Jabber) ParseEvent(e interface{}) { //nolint:maintidx,gocognit,gocyclo
j.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":
var (
room = strings.SplitN(v.Remote, "/", 2)[0]
nick = strings.SplitN(v.Remote, "/", 2)[1]
)
log.Debugf("Message from public chat: %s", v.Text)
if nick == j.GetBotNickFromRoomConfig(room) {
log.Debug("Skipping message from myself")
return
}
if err := j.Cmd(v); err != nil {
j.GTomb.Kill(err)
return
}
if err := j.BunyChat(v); err != nil {
j.GTomb.Kill(err)
return
}
j.LastActivity = j.LastServerActivity
if muc, _ := strings.CutSuffix(v.Remote, "/"); muc != "" {
j.LastMucActivity.Set(muc, j.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 := j.Cmd(v); err != nil {
j.GTomb.Kill(err)
return
}
j.LastActivity = j.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(j.RoomsConnected, muc) {
j.LastMucActivity.Set(muc, j.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 := j.Talk.IqVersionResponse(
v,
"buny-jabber-bot",
"1.dev",
(strings.SplitN(runtime.GOOS, "/", 2))[0],
); err != nil {
err := fmt.Errorf(
"unable to send version info to jabber server: id=%s, err=%w",
id,
err,
)
j.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 := j.Talk.PingResponse(v); err != nil {
err := fmt.Errorf(
"unable to send pong to jabber server: id=%s, err=%w",
id,
err,
)
j.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 := j.Talk.JabberIqLastResponse(v, j.LastActivity); err != nil {
err := fmt.Errorf(
"unable to send last activity time to jabber server: id=%s, err=%w",
id,
err,
)
j.GTomb.Kill(err)
return
}
parseError = false
// Запрос на список поддерживаемых фич - версия, локальное время, итд
case xmpp.XMPPNS_DISCO_INFO:
log.Infof("Got IQ get disco#info request from %s", v.From)
answer := "<query xmlns=\"http://jabber.org/protocol/disco#info\">"
answer += "<feature var=\"jabber:iq:version\" />"
answer += "<feature var=\"urn:xmpp:time\" />"
answer += "<feature var=\"urn:xmpp:ping\" />"
answer += "<feature var=\"jabber:iq:last\" />"
answer += "<feature var=\"http://jabber.org/protocol/disco#info\" />"
answer += "</query>"
if id, err := j.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,
)
j.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 := j.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,
)
j.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 := j.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,
)
j.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 := j.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,
)
j.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 := j.Talk.PingResponse(v); err != nil {
err := fmt.Errorf(
"unable to send pong to jabber server: id=%s, err=%w",
id,
err,
)
j.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(j.RoomsConnected, muc) {
j.LastMucActivity.Set(muc, j.LastServerActivity)
}
}
switch {
// Похоже на pong от сервера (по стандарту в ответе нету query, но go-xmpp нам подсовывает это)
case v.From == j.C.Jabber.Server && v.To == j.Talk.JID() && string(v.Query) == "<XMLElement></XMLElement>":
log.Debugf("Got S2C pong answer from %s to %s", v.From, v.To)
j.ServerPingTimestampRx = time.Now().Unix() //nolint:wsl
// Похоже на понг второй стадии xep-0410 MUC-Ping-а, который у нас не реализован
case v.To == j.Talk.JID() && string(v.Query) == "<XMLElement></XMLElement>":
mucNameMatch := slices.Contains(j.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 j.RoomsConnected {
mucNick := fmt.Sprintf("%s/%s", room, j.GetBotNickFromRoomConfig(room))
if v.From == mucNick {
mucNickMatch = true
break
}
}
if mucNickMatch {
log.Debugf("Got 2-nd stage MUC pong answer (xep-0410) from %s to %s", v.From, v.To)
// Поскольку никакой логики у нас на этот счёт не предусмотрено, то просто пропускаем ответ
} else {
log.Infof("Got unknown pong from %s to %s", v.From, v.To)
log.Debug(spew.Sdump(e))
}
}
// Подтверждение бана
case v.To == j.Talk.JID() && v.ID == "ban1":
mucNickMatch := false
// Если ник, которому предназначается сообщение, совпадает с ником, из конфига бота (глобального или для
// комнаты), то считаем, что мы есть в этой комнате. Потому что в противном случае в roomsConnected не
// будет искомой комнаты и мы не сможем составить ник бота, чтобы сравнить его с тем, кому адресовано
// уведомление.
for _, room := range j.RoomsConnected {
mucNick := fmt.Sprintf("%s/%s", room, j.GetBotNickFromRoomConfig(room))
if v.To == mucNick {
mucNickMatch = true
break
}
}
// Формально, ответ должен парситься как пустой result и совпадать с типом jabberSimpleIqGetQuery
// Ответ приходит с 2 xmlns, похоже, он парсится неправильно в go-xmpp.
// TODO: после исправления go-xmpp, надо исправить это тут
var iqStruct JabberSimpleIqGetQuery
if err := xml.Unmarshal(v.Query, &iqStruct); err == nil {
if iqStruct.Xmlns == "http://jabber.org/protocol/muc#admin" && iqStruct.Text == "" {
if mucNickMatch {
// Поскольку никакой логики у нас на этот счёт не предусмотрено, то просто пропускаем ответ
log.Infof("Got ban successful from %s to %s", v.From, v.To)
} else {
log.Infof("Got ban successful from %s to %s, from room we are not in", v.From, v.To)
log.Debug(spew.Sdump(e))
}
} else {
log.Infof("Got strange ban successful message from %s to %s", v.From, v.To)
log.Debug(spew.Sdump(e))
}
} else {
log.Infof("Got strange ban successful message 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(j.RoomsConnected, muc) {
j.LastMucActivity.Set(muc, j.LastServerActivity)
}
}
log.Info("Got an IQ request for set something. Answer not implemented")
if id, err := j.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,
)
j.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 == j.C.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)
j.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 == j.Talk.JID() {
nick := strings.SplitN(v.From, "/", 2)[1]
room := strings.SplitN(v.From, "/", 2)[0]
if slices.Contains(j.RoomsConnected, iqErrorCancelNotAcceptable.By) &&
nick == j.GetBotNickFromRoomConfig(room) {
log.Errorf(
"Got Iq error message from: %s to: %s. Looks like i'm not in MUC anymore",
v.From, v.To,
)
time.Sleep(time.Duration(j.C.Jabber.MucRejoinDelay) * time.Second)
if _, err := j.Talk.JoinMUCNoHistory(iqErrorCancelNotAcceptable.By, nick); err != nil {
err := fmt.Errorf(
"looks like connection to server also lost err=%w",
err,
)
j.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(j.RoomsConnected, muc) {
j.LastMucActivity.Set(muc, j.LastServerActivity)
}
}
if v.Type == xmpp.IQTypeError {
// Это событие происходит, когда из чятика выходит другой инстанс клиента.
// Такое бывает, когда 2 инстанса с одинаковым ресурсом, например, начинают "драться" за возможность
// остаться на сервере. Ситуация, которую допускать нельзя, на самом деле, потому что рано или поздно такого
// клиента забанят из-за спама "вошёл-вышел".
// Однако, это не значит, что такую ситуацию мы не должны корректным образом обрабатывать.
if v.Type == "unavailable" {
// Считаем, что мы больше не в комнате, поэтому не знаем, кто там есть
j.RoomPresences.Delete(v.From)
log.Error("Presence notification - looks like another instance of client leaves room")
if slices.Contains(j.RoomsConnected, v.From) {
go j.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 == j.GetBotNickFromRoomConfig(room) {
j.RoomsConnected = append(j.RoomsConnected, room)
// На всякий случай дедуплицируем список комнат, к которым мы заджойнились.
sort.Strings(j.RoomsConnected)
slices.Compact(j.RoomsConnected)
}
}
switch v.Role {
// Участник ушёл
case "none":
if presenceJSONInterface, present := j.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)
}
j.RoomPresences.Set(room, newPresenceJSONStrings)
}
// Участник пришёл
default:
var (
presenceJSONStrings []string
newPresenceJSONStrings []string
presenceJSONBytes []byte
)
if presenceJSONInterface, present := j.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))
j.RoomPresences.Set(room, newPresenceJSONStrings)
}
// Проверяем, а не злодей ли зашёл? Сделать это мы можем, только если мы находимся в комнате.
// По правилам, мы можем что-то делать, только после того, как нам прилетит наш собственный presence, это
// значит, что мы вошли в комнату.
if slices.Contains(j.RoomsConnected, room) {
if v.Affiliation != "outcast" {
if err := j.BunyPresense(v); err != nil {
j.GTomb.Kill(err)
return
}
}
}
}
// Ответ на запрос поддерживаемых фич, который "http://jabber.org/protocol/disco#info"
case xmpp.DiscoResult:
if muc, _ := strings.CutSuffix(v.To, "/"); muc != "" {
if slices.Contains(j.RoomsConnected, muc) {
j.LastMucActivity.Set(muc, j.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)
j.ServerCapsList.Set(feature, true)
}
j.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
}
j.MucCapsList.Set(v.From, mucCaps)
case "pubsub":
log.Debugf("PubSub component %s reply to disco#info, skipping", v.From)
default:
log.Debug("Got unknown reply to disco#info")
log.Debug(spew.Sdump(e))
}
}
// Это что-то неизвестное, подампим событие в лог
default:
log.Info(spew.Sdump(e))
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,77 @@
package jabber
import (
"time"
log "github.com/sirupsen/logrus"
)
// MyLoop - основной цикл программы.
func (j *Jabber) MyLoop() {
for {
select {
case <-j.GTomb.Dying():
j.GTomb.Done()
return //nolint:nlreturn
default:
// Зададим начальное значение глобальным переменным
j.ServerPingTimestampRx = 0
j.ServerPingTimestampTx = 0
j.RoomsConnected = make([]string, 1)
j.LastActivity = 0
j.LastServerActivity = 0
j.LastMucActivity = NewCollection()
j.ServerCapsQueried = false
j.ServerCapsList = NewCollection()
j.MucCapsList = NewCollection()
j.ServerPingTimestampTx = 0
j.ServerPingTimestampRx = 0
j.RoomPresences = NewCollection()
// Установим коннект
j.EstablishConnection()
j.ServerPingTimestampRx = time.Now().Unix() // Считаем, что если коннект запустился, то первый пинг успешен.
// Тыкаем сервер палочкой, проверяем, что коннект жив и вываливаемся из mainLoop, если он не жив.
go j.ProbeServerLiveness()
// Тыкаем muc-и палочкой, проверяем, что они живы и вываливаемся из mainLoop, если пинги пропали.
// Если пинги до комнаты пропали, то это фактически значит, что либо сервер потерял связь с MUC-компонентом,
// либо у нас какой-то wire error.
go j.ProbeMUCLiveness()
// Гребём ивенты...
for {
// Стриггерилось завершение работы приложения, или соединение не установлено (порвалось, например)
// грести не надо
if j.Shutdown {
break
}
if !j.IsConnected {
// Tight loop - это наверно не очень хорошо, думаю, ничего страшного не будет, если мы поспим 100мс.
time.Sleep(100 * time.Millisecond)
continue
}
// Вынимаем ивент из "провода"
chat, err := j.Talk.Recv()
if err != nil {
log.Errorf("Unable to get events from server: %s", err)
j.GTomb.Kill(err)
// Не забываем выходить на ошибке :(
return
}
j.ParseEvent(chat)
}
}
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,56 @@
package jabber
import (
"regexp"
"strings"
)
// Переменные для простенького нормализатора текста.
var (
// Знаки препинания раз.
pMarks = []string{".", ",", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "{", "}", "<", ">", "[", "]", "\\"}
// Знаки препинания два-с.
pMarks2 = []string{"-", "_", "+", "=", ":", ";", "'", "`", "~", "\""}
// Символы новой строки.
newLines = []string{"\n", "\r", "\n\r", "\r\n"}
)
// Нормализует текстовый буфер. Убирает начальные и конечные пробельные символы, заменяет сносы строк пробелами, удаляет
// знаки препинания, схлопывает повторяющиеся пробелы в один символ.
func nString(buf string) string {
// Убираем пробелы вначале текстового буфера и в конце.
buf = strings.Trim(buf, "\n\r\t ")
// Убираем знаки препинания.
for _, pMark := range pMarks {
buf = strings.ReplaceAll(buf, pMark, "")
}
for _, pMark := range pMarks2 {
buf = strings.ReplaceAll(buf, pMark, "")
}
// Замещаем сносы строки пробелами.
for _, newline := range newLines {
buf = strings.ReplaceAll(buf, newline, " ")
}
// Схлопываем повторяющиеся пробелы.
buf = regexp.MustCompile(`\s+`).ReplaceAllString(buf, " ")
return buf
}
// Обёртка для nString, возвращает нормализованную строку в нижнем регистре.
func nStringLower(buf string) string {
return strings.ToLower(nString(buf))
}
// Обёртка для nString, возвращает нормализованную строку в верхнем регистре.
func nStringUpper(buf string) string {
return strings.ToUpper(nString(buf))
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,280 @@
package jabber
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/hjson/hjson-go"
log "github.com/sirupsen/logrus"
)
// ReadConfig читает и валидирует конфиг, а также выставляет некоторые default-ы, если значений для параметров в конфиге
// нет.
func (j *Jabber) ReadConfig() error { //nolint:gocognit,gocyclo
var (
err error
executablePath string
configLoaded = false
)
executablePath, err = os.Executable()
if err != nil {
err := fmt.Errorf("unable to get current executable path: %w", err)
return err
}
configJSONPath := fmt.Sprintf("%s/data/config.json", filepath.Dir(executablePath))
locations := []string{
"~/.buny-jabber-bot.json",
"~/buny-jabber-bot.json",
"/etc/buny-jabber-bot.json",
configJSONPath,
}
for _, location := range locations {
fileInfo, err := os.Stat(location)
// Предполагаем, что файла либо нет, либо мы не можем его прочитать, второе надо бы логгировать, но пока забьём.
if err != nil {
continue
}
// Конфиг-файл длинноват для конфига, попробуем следующего кандидата.
if fileInfo.Size() > 2777216 {
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
}
// Значения для 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 = "bunyPresense bot"
log.Info("Jabber resource not defined in config, using bunyPresense bot")
}
if sampleConfig.Jabber.User == "" {
sampleConfig.Jabber.User = fmt.Sprintf("%s@%s", sampleConfig.Jabber.Nick, sampleConfig.Jabber.Server)
log.Infof("Jabber user not defined in config, guessing, it can be %s", sampleConfig.Jabber.User)
}
// Если sampleConfig.Jabber.Password не задан, то авторизации не будет
// Если не задано ни одного мастера, то бот сам себе мастер
if len(sampleConfig.Jabber.BotMasters) == 0 {
sampleConfig.Jabber.BotMasters[0] = sampleConfig.Jabber.User
}
// Нам бот нужен в каких-то чат-румах, а не "просто так"
if len(sampleConfig.Jabber.Channels) < 1 {
return errors.New("no jabber channels/rooms defined in config, quitting") //nolint:goerr113
}
for n, channel := range sampleConfig.Jabber.Channels {
if channel.Name == "" {
return errors.New("no \"name\" entry in jabber channel config") //nolint:goerr113
}
if channel.Nick == "" {
sampleConfig.Jabber.Channels[n].Nick = sampleConfig.Jabber.Nick
}
// channel.Password может быть пустым, тогда пароля нет
// channel.Bayes.Enabled будет false, если не проставлен
if channel.Bayes.MinLength < 40 {
channel.Bayes.MinLength = 40
}
if channel.Bayes.MinWords < 8 {
channel.Bayes.MinWords = 8
}
switch channel.Bayes.DefaultAction {
case "kick":
case "ban":
case "devoice":
default:
channel.Bayes.DefaultAction = "log"
}
// channel.AllCaps.Enabled будет false, если не указан
if channel.AllCaps.MinLength < 10 {
channel.AllCaps.MinLength = 10
}
switch channel.AllCaps.DefaultAction {
case "kick":
case "ban":
case "devoice":
default:
channel.AllCaps.DefaultAction = "log"
}
}
// Если список фраз с которыми стартует бот пустой, вносим в него 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
// Если sampleConfig.Jabber.BanDelay не задан, то он равен 0
// Если sampleConfig.Jabber.BanPhrasesEnable не задан, то он false
// Если список фраз, с которыми банят пустой, то вносим в него одну позицию с пустой строкой
if len(sampleConfig.Jabber.BanPhrases) == 0 {
sampleConfig.Jabber.BanPhrases[0] = ""
}
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
j.C = sampleConfig
configLoaded = true
log.Infof("Using %s as config file", location)
break
}
if !configLoaded {
return errors.New("config was not loaded") //nolint:goerr113
}
return err //nolint:wrapcheck
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

View File

@@ -0,0 +1,58 @@
package jabber
import (
"os"
"syscall"
"github.com/eleksir/go-xmpp"
log "github.com/sirupsen/logrus"
)
func (j *Jabber) SigHandler() {
log.Debug("Installing signal handler")
for s := range j.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.
j.Shutdown = true
if j.IsConnected && !j.Shutdown {
log.Debug("Try to set our presence to Unavailable and status to Offline")
// Вот тут понадобится коллекция известных пользователей, чтобы им разослать presence, что бот свалил в
// offline. Пока за неимением лучшего сообщим об этом самим себе.
for _, room := range j.RoomsConnected {
if _, err := j.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.Infoln("Closing connection to jabber server")
if err := j.Talk.Close(); err != nil {
log.Infof("Unable to close connection to jabber server: %s", err)
}
}
os.Exit(0)
}
}

78
internal/jabber/squash.go Normal file
View File

@@ -0,0 +1,78 @@
package jabber
import (
"fmt"
"time"
"github.com/eleksir/go-xmpp"
)
// Squash банит указанный jid в указанной комнате.
// reasonEnable указывает, надо ли писать дату автобана в банлисте в поле reason (это единственная причина, в которую
// умеет бот).
func (j *Jabber) Squash(room, jid string, reasonEnable bool, vType string) (string, error) {
var (
id string
err error
)
if j.C.Jabber.BanPhrasesEnable {
phrase := RandomPhrase(j.C.Jabber.BanPhrases)
if _, err = j.Talk.Send(
xmpp.Chat{ //nolint:exhaustruct
Remote: room,
Text: phrase,
Type: vType,
},
); err != nil {
err = fmt.Errorf("unable to send phrase to room %s: %w", room, err)
// Здесь возвращаем nil, т.к. за нас ошибку залоггирует код выше.
return id, err
}
}
// https://xmpp.org/extensions/xep-0045.html#ban баним вот таким сообщением
ban := "<item affiliation='outcast' jid='" + jid + "'>"
if reasonEnable {
var t = time.Now()
ban += fmt.Sprintf(
"<reason>autoban at %04d.%02d.%02d %02d:%02d:%02d</reason>",
t.Year(),
t.Month(),
t.Day(),
t.Hour(),
t.Minute(),
t.Second(),
)
} else {
ban += "<reason />"
}
ban += "</item>"
// Выжидаем некоторое время перед баном. А то можно настолько рано забанить, что сервер не внесёт злодея в банлист
// комнаты и пришлёт affiliation: none вместо affiliation: outcast.
if j.C.Jabber.BanDelay > 0 {
time.Sleep(time.Duration(j.C.Jabber.BanDelay) * time.Millisecond)
}
if id, err = j.Talk.RawInformationQuery(
j.Talk.JID(),
room,
"ban1",
xmpp.IQTypeSet,
"http://jabber.org/protocol/muc#admin",
ban,
); err != nil {
err = fmt.Errorf(
"unable to ban user: id=%s, err=%w",
id,
err,
)
}
return id, err
}

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

@@ -0,0 +1,198 @@
package jabber
import (
"encoding/xml"
"os"
"github.com/eleksir/go-xmpp"
"gopkg.in/tomb.v1"
)
// MyConfig прототип структурки с конфигом.
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"`
Resource string `json:"resource,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
BotMasters []string `json:"bot_masters,omitempty"`
Channels []struct {
Name string `json:"name,omitempty"`
Nick string `json:"nick,omitempty"`
Password string `json:"password,omitempty"`
Bayes struct {
Enabled bool `json:"enabled,omitempty"`
MinWords int64 `json:"min_words,omitempty"`
MinLength int `json:"min_length,omitempty"`
DefaultAction string `json:"default_action,omitempty"`
} `json:"bayes,omitempty"`
AllCaps struct {
Enabled bool `json:"enabled,omitempty"`
MinLength int `json:"min_length,omitempty"`
DefaultAction string `json:"default_action,omitempty"`
} `json:"all_caps,omitempty"`
} `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"`
BanDelay int64 `json:"ban_delay,omitempty"`
BanPhrasesEnable bool `json:"ban_phrases_enable,omitempty"`
BanPhrases []string `json:"ban_phrases,omitempty"`
} `json:"jabber,omitempty"`
CSign string `json:"csign,omitempty"`
Loglevel string `json:"loglevel,omitempty"`
Log string `json:"log,omitempty"`
}
// MyWhiteList прототип структурки с белым списком jid-ов.
type MyWhiteList struct {
Whitelist []struct {
RoomName string `json:"room_name,omitempty"`
Jid []string `json:"jid,omitempty"`
WipeBans bool `json:"wipe_bans,omitempty"`
} `json:"whitelist,omitempty"`
}
// MyBlackList прототип структурки с чёрным списком jid-ов.
type MyBlackList struct {
Blacklist []struct {
RoomName string `json:"room_name,omitempty"`
ReasonEnable bool `json:"reason_enable,omitempty"`
JidRe []string `json:"jid_re,omitempty"`
NickRe []string `json:"nick_re,omitempty"`
PhraseRe []string `json:"phrase_re,omitempty"`
} `json:"blacklist,omitempty"`
}
// Jabber основная структура-объект, содержащая стейты и проч.
type Jabber struct {
// C - конфиг, как он распарсился из конфиг-файла.
C MyConfig
// WhiteList - структурка с безусловно разрешёнными jid-ами
WhiteList MyWhiteList
// BlackList - структурка с запрещёнными по регуляркам фразами, никами, jid-ами.
BlackList MyBlackList
// Опции подключения к xmpp-серверу.
Options *xmpp.Options
// gTomb пул активных горутин.
GTomb tomb.Tomb
// Talk основная структурка xmpp-клиента.
Talk *xmpp.Client
// sync.Map-ка с капабилити сервера.
ServerCapsList *Collection
// ServerCapsQueried показывает, были ли запрошены capabilities сервера.
ServerCapsQueried bool
// Время последней активности, нужно для c2s пингов - посылаем пинги, только если давненько ничего не приходило с
// сервера.
LastServerActivity int64
// Время, когда был отправлен c2s ping.
ServerPingTimestampTx int64
// Время, когда был принят s2c pong.
ServerPingTimestampRx int64
// Время последней активности, нужно для jabber:iq:last.
LastActivity int64
// sync.Map-ка с комнатами и их capability.
MucCapsList *Collection
// Время последней активности MUC-ов, нужно для пингов - посылаем пинги, только если давненько ничего не приходило из
// muc-ов.
LastMucActivity *Collection
// Список комнат, в которых находится бот.
RoomsConnected []string
// sync.Map-ка со списком участников конференций (в json-формате, согласно структуре xmpp.Presence, "room".[]json).
RoomPresences *Collection
// Канал, по котором приходят сообщения о том, что ОС отправила некие сигналы процессу.
SigChan chan os.Signal
// Индиктор того, что процесс завершается.
Shutdown bool
// Индиктор того, что соединение установлено.
IsConnected bool
// Индикатор того, что соединение в процессе достукивания до сервера.
Connecting bool
}
// JabberSimpleIqGetQuery прототип структурки для разбора запросов xmpp discovery query, например,
// https://xmpp.org/extensions/xep-0030.html#example-18 .
type JabberSimpleIqGetQuery struct {
XMLName xml.Name `xml:"query"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Node string `xml:"node,attr,omitempty"` // для xmlns="http://jabber.org/protocol/disco#items"
}
// JabberPubsubIQGetQuery прототип структурки для разбора запросов xmpp pubsub.
type JabberPubsubIQGetQuery struct {
XMLName xml.Name `xml:"pubsub"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
Items struct {
Text string `xml:",chardata"`
Node string `xml:"node,attr"`
MaxItems string `xml:"max_items,attr"`
} `xml:"items"`
}
// JabberTimeIqGetQuery прототип структурки для разбора IQ запросов на локальное время клиента,
// https://xmpp.org/extensions/xep-0202.html
type JabberTimeIqGetQuery struct {
// <time xmlns="urn:xmpp:time"/>
XMLName xml.Name `xml:"time"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
}
// JabberIqPing прототип структурки для разбора IQ запросов на пинг клиента, https://xmpp.org/extensions/xep-0199.html
type JabberIqPing struct {
XMLName xml.Name `xml:"ping"`
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
}
// JabberIqErrorCancelNotAcceptable прототип структурки для разбора IQ ответов, когда сервис (сервер, клиент, etc) не
// может или не хочет принимать наш iq-запрос https://xmpp.org/extensions/xep-0099.html
type JabberIqErrorCancelNotAcceptable struct {
XMLName xml.Name `xml:"error"`
Text string `xml:",chardata"`
Type string `xml:"type,attr"`
By string `xml:"by,attr"`
NotAcceptable struct {
Text string `xml:",chardata"`
Xmlns string `xml:"xmlns,attr"`
} `xml:"not-acceptable"`
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

467
internal/jabber/util.go Normal file
View File

@@ -0,0 +1,467 @@
package jabber
import (
"encoding/json"
"fmt"
"math/rand"
"reflect"
"slices"
"strings"
"time"
"github.com/eleksir/go-xmpp"
log "github.com/sirupsen/logrus"
)
// 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 (j *Jabber) GetRealJIDfromNick(fullNick string) string {
var p xmpp.Presence
room := (strings.SplitN(fullNick, "/", 2))[0]
// Достанем presence участника
presenceJSONInterface, present := j.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 ""
}
// GetBotNickFromRoomConfig достаёт из настроек комнаты короткий ник бота, либо берёт значение из конфига, если ник в
// настройках комнаты не задан. Короткий ник не содержит название комнаты.
func (j *Jabber) GetBotNickFromRoomConfig(room string) string {
for _, roomStruct := range j.C.Jabber.Channels {
if roomStruct.Name == room {
return roomStruct.Nick
}
}
return j.C.Jabber.Nick
}
// RotateStatus периодически изменяет статус бота в MUC-е согласно настройкам из кофига.
func (j *Jabber) RotateStatus(room string) {
for {
select {
case <-j.GTomb.Dying():
j.GTomb.Done()
return
default:
// TODO: Переделать на ticker-ы
totalSleepTime := time.Duration(j.C.Jabber.RuntimeStatus.RotationTime) * time.Second
totalSleepTime += time.Duration(j.C.Jabber.RuntimeStatus.RotationSplayTime) * time.Second
for {
status := RandomPhrase(j.C.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 := j.Talk.SendPresence(p); err != nil {
log.Infof("Unable to send presence to MUC %s: %s", room, err)
j.GTomb.Kill(err)
return
}
// Если мы не хотим ротировать, то цикл нам тут не нужен, просто выходим.
if j.C.Jabber.RuntimeStatus.RotationTime == 0 {
j.GTomb.Done()
return
}
time.Sleep(totalSleepTime)
}
}
}
}
// JoinMuc джойнится к конференциям/каналам/комнатам в джаббере.
func (j *Jabber) JoinMuc(room string) {
log.Debugf("Sending disco#info from %s to %s", j.Talk.JID(), room)
if _, err := j.Talk.DiscoverInfo(j.Talk.JID(), room); err != nil {
log.Infof("Unable to send disco#info to MUC %s: %s", room, err)
j.GTomb.Kill(err)
return
}
// Ждём, пока muc нам вернёт список фичей.
for i := 0; i < (20 * int(j.C.Jabber.ConnectionTimeout)); i++ {
var (
myRoom interface{}
supported bool
exist bool
)
time.Sleep(50 * time.Millisecond)
if myRoom, exist = j.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 := j.Talk.JoinMUCNoHistory(room, j.GetBotNickFromRoomConfig(room)); err != nil {
log.Errorf("Unable to join to MUC: %s", room)
j.GTomb.Kill(err)
return
}
log.Infof("Joining to MUC: %s", room)
// Ждём, когда прилетит presence из комнаты, тогда мы точно знаем, что мы вошли.
entered := false
for i := 0; i < (20 * int(j.C.Jabber.ConnectionTimeout)); i++ {
time.Sleep(50 * time.Millisecond)
if slices.Contains(j.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(j.C.Jabber.ConnectionTimeout)+1,
)
return
}
// Вот теперь точно можно слать статус.
log.Infof("Joined to MUC: %s", room)
go j.RotateStatus(room)
// Время проверить участников на предмет злобности
namesInterface, present := j.RoomPresences.Get(room)
// Если room есть в списке presence-ов, то фигачим. room там должен быть, просто обязан.
if present {
for _, name := range InterfaceToStringSlice(namesInterface) {
var v xmpp.Presence
_ = json.Unmarshal([]byte(name), &v)
log.Infof("Fake presence forged for %s just for on-enter check", name)
// Оно там внутри всё само обработает, если вдруг возникнет wire error, то зарекконетится.
_ = j.BunyPresense(v)
}
}
}
// EstablishConnection устанавливает соединение с jabber-сервером.
func (j *Jabber) EstablishConnection() {
var err error
if j.Connecting && !j.IsConnected {
return
}
// Проставляем глобальные переменные.
j.Connecting = true
j.IsConnected = false
j.RoomsConnected = make([]string, 0)
log.Debugf("Establishing connection to %s", j.Options.Host)
j.Talk, err = j.Options.NewClient()
if err != nil {
j.GTomb.Kill(err)
return
}
// По идее keepalive должен же проходить только, если мы уже на сервере, так?
if _, err := j.Talk.SendKeepAlive(); err != nil {
log.Errorf("Try to send initial KeepAlive, got error: %s", err)
j.GTomb.Kill(err)
return
}
log.Info("Connected")
// Джойнимся к чятикам, но делаем это в фоне, чтобы не блочиться на ошибках, например, если бота забанили
for _, roomStruct := range j.C.Jabber.Channels {
room := roomStruct.Name
go j.JoinMuc(room)
}
go j.RotateStatus("")
j.LastActivity = time.Now().Unix()
j.Connecting = false
j.IsConnected = true
log.Debugf("Sending disco#info to %s", j.C.Jabber.Server)
_, err = j.Talk.DiscoverInfo(j.Talk.JID(), j.C.Jabber.Server)
if err != nil {
log.Infof("Unable to send disco#info to jabber server: %s", err)
j.GTomb.Kill(err)
return
}
}
// ProbeServerLiveness проверяет живость соединения с сервером. Для многих серверов обязательная штука, без которой
// они выкидывают (дисконнектят) клиента через некоторое время неактивности.
func (j *Jabber) ProbeServerLiveness() { //nolint:gocognit
for {
select {
case <-j.GTomb.Dying():
j.GTomb.Done()
return
default:
for {
if j.Shutdown {
return
}
sleepTime := time.Duration(j.C.Jabber.ServerPingDelay) * 1000 * time.Millisecond
sleepTime += time.Duration(rand.Int63n(1000*j.C.Jabber.PingSplayDelay)) * time.Millisecond
time.Sleep(sleepTime)
if !j.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 j.ServerCapsQueried { // Сервер ответил на disco#info
var (
value interface{}
exist bool
)
value, exist = j.ServerCapsList.Get("urn:xmpp:ping")
switch {
// Сервер анонсировал, что умеет в c2s пинги
case exist && value.(bool):
// Таймаут c2s пинга. Возьмём сумму задержки между пингами, добавим таймаут коннекта и добавим
// максимальную корректировку разброса.
txTimeout := j.C.Jabber.ServerPingDelay + j.C.Jabber.ConnectionTimeout
txTimeout += j.C.Jabber.PingSplayDelay
rxTimeout := txTimeout
rxTimeAgo := time.Now().Unix() - j.ServerPingTimestampRx
if j.ServerPingTimestampTx > 0 { // Первая пуля от нас ушла...
switch {
// Давненько мы не получали понгов от сервера, вероятно, соединение с сервером утеряно?
case rxTimeAgo > (rxTimeout * 2):
err := fmt.Errorf( //nolint:goerr113
"stall connection detected. No c2s pong for %d seconds",
rxTimeAgo,
)
j.GTomb.Kill(err)
return
// По-умолчанию, мы отправляем c2s пинг
default:
log.Debugf("Sending c2s ping from %s to %s", j.Talk.JID(), j.C.Jabber.Server)
if err := j.Talk.PingC2S(j.Talk.JID(), j.C.Jabber.Server); err != nil {
j.GTomb.Kill(err)
return
}
j.ServerPingTimestampTx = time.Now().Unix()
}
} else { // Первая пуля пока не вылетела, отправляем
log.Debugf("Sending first c2s ping from %s to %s", j.Talk.JID(), j.C.Jabber.Server)
if err := j.Talk.PingC2S(j.Talk.JID(), j.C.Jabber.Server); err != nil {
j.GTomb.Kill(err)
return
}
j.ServerPingTimestampTx = time.Now().Unix()
}
// Сервер не анонсировал, что умеет в c2s пинги
default:
log.Debug("Sending keepalive whitespace ping")
if _, err := j.Talk.SendKeepAlive(); err != nil {
j.GTomb.Kill(err)
return
}
}
} else { // Сервер не ответил на disco#info
log.Debug("Sending keepalive whitespace ping")
if _, err := j.Talk.SendKeepAlive(); err != nil {
j.GTomb.Kill(err)
return
}
}
}
}
}
}
// ProbeMUCLiveness Пингует MUC-и, нужно для проверки, что клиент ещё находится в MUC-е.
func (j *Jabber) ProbeMUCLiveness() { //nolint:gocognit
for {
select {
case <-j.GTomb.Dying():
j.GTomb.Done()
return
default:
for {
for _, room := range j.RoomsConnected {
var (
exist bool
lastActivityTS interface{}
)
// Если записи про комнату нету, то пинговать её бессмысленно.
if lastActivityTS, exist = j.LastMucActivity.Get(room); !exist {
continue
}
// Если время последней активности в чятике не превысило
// j.C.Jabber.ServerPingDelay + j.C.Jabber.PingSplayDelay, ничего не пингуем.
if (time.Now().Unix() - lastActivityTS.(int64)) < (j.C.Jabber.ServerPingDelay + j.C.Jabber.PingSplayDelay) {
continue
}
/* Пинг MUC-а по сценарию без серверной оптимизации мы реализовывать не будем. Это как-то не надёжно.
go func(room string) {
// Небольшая рандомная задержка перед пингом комнаты
sleepTime := time.Duration(rand.Int63n(1000*j.C.Jabber.PingSplayDelay)) * time.Millisecond //nolint:gosec
time.Sleep(sleepTime)
if err := j.Talk.PingS2S(talk.JID(), room+"/"+j.GetBotNickFromRoomConfig(room)); err != nil {
j.GTomb.Kill(err)
return
}
}(room)
*/
var roomMap interface{}
roomMap, exist = j.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*j.C.Jabber.PingSplayDelay)) * time.Millisecond
time.Sleep(sleepTime)
log.Debugf("Sending MUC ping from %s to %s", j.Talk.JID(), room)
if err := j.Talk.PingS2S(j.Talk.JID(), room); err != nil {
err := fmt.Errorf("unable to ping MUC %s: %w", room, err)
j.GTomb.Kill(err)
return
}
}(room)
}
}
time.Sleep(time.Duration(j.C.Jabber.MucPingDelay) * time.Second)
}
}
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */