Refactor layout to go-style, update deps
This commit is contained in:
100
internal/jabber/bf.go
Normal file
100
internal/jabber/bf.go
Normal 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
|
||||
}
|
||||
204
internal/jabber/black-whilte_lists.go
Normal file
204
internal/jabber/black-whilte_lists.go
Normal 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
383
internal/jabber/buny.go
Normal 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: */
|
||||
69
internal/jabber/collection.go
Normal file
69
internal/jabber/collection.go
Normal 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
210
internal/jabber/commands.go
Normal 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
|
||||
}
|
||||
710
internal/jabber/event_parser.go
Normal file
710
internal/jabber/event_parser.go
Normal 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: */
|
||||
77
internal/jabber/main_loop.go
Normal file
77
internal/jabber/main_loop.go
Normal 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: */
|
||||
56
internal/jabber/norm_string.go
Normal file
56
internal/jabber/norm_string.go
Normal 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: */
|
||||
280
internal/jabber/read_config.go
Normal file
280
internal/jabber/read_config.go
Normal 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: */
|
||||
58
internal/jabber/sig_handler.go
Normal file
58
internal/jabber/sig_handler.go
Normal 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
78
internal/jabber/squash.go
Normal 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
198
internal/jabber/types.go
Normal 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
467
internal/jabber/util.go
Normal 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: */
|
||||
Reference in New Issue
Block a user