package jabber import ( "buny-jabber-bot/internal/log" "encoding/json" "encoding/xml" "fmt" "slices" "sort" "strings" "time" "github.com/davecgh/go-spew/spew" "github.com/xmppo/go-xmpp" ) // ParseEvent парсит ивенты, прилетающие из модуля xmpp (изменения presence, фразы участников, ошибки). func (j *Jabber) ParseEvent(e any) { //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": // Это ошибка со стороны сервера, видимо, в ответ на наши действия и поскольку такие вещи происходят // асинхронно, связать их с конкретными нашими действиями довольно сложно. for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Error(str) } } } // 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 SimpleIqGetQuery parseError bool ) if err := xml.Unmarshal(v.Query, &iqStruct); err == nil { switch iqStruct.Xmlns { //nolint:wsl,whitespace // Запрос на список поддерживаемых фич - версия, локальное время, итд. case xmpp.XMPPNS_DISCO_INFO: log.Infof("Got IQ get disco#info request from %s", v.From) answer := "" answer += "" answer += "" answer += "" answer += "" answer += "" answer += "" answer += "" answer += "" 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") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } return } } else { parseError = true } // Попробуем распарсить входящий запрос как urn:xmpp:time. if parseError { var iqStruct TimeIqGetQuery 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") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } return } } else { parseError = true } } // Попробуем распарсить входящий запрос как http://jabber.org/protocol/pubsub. if parseError { var iqStruct PubsubIQGetQuery 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") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } parseError = true //nolint:wsl } } else { parseError = true } } // Не знаю, как парсить... залоггируем это дело и пойдём дальше. if parseError { log.Infof("Does not look like parsable via iqStruct") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } 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) == "": 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) == "": 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) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } } // Подтверждение бана. 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 SimpleIqGetQuery 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) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } } else { log.Infof("Got strange ban successful message from %s to %s", v.From, v.To) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } } else { log.Infof("Got strange ban successful message from %s to %s", v.From, v.To) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } // Ответ с результатом адресован нам. case v.To == j.Talk.JID(): var softwareVersion IqResultSoftwareVersion if err := xml.Unmarshal(v.Query, &softwareVersion); err == nil { if softwareVersion.Os == "" { log.Infof( "Recieved software version query result for %s: name=%s version=%s", v.From, softwareVersion.Name, softwareVersion.Version, ) } else { log.Infof( "Recieved software version query result for %s: name=%s version=%s os=%s", v.From, softwareVersion.Name, softwareVersion.Version, softwareVersion.Os, ) } if err = j.BunySoftwareVersion(v, softwareVersion); err != nil { log.Errorf("Unable to query client software version: %s", err) j.GTomb.Kill(err) return } return } log.Info("Got an IQ result. Dunno how deal with it, discarding") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } default: log.Info("Got an IQ result. Dunno how deal with it, discarding") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } // Этот бот не управляется со стороны сервера, поэтому все попытки порулить игнорируем. 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 } for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } // Нам прилетело сообщение об ошибке. case xmpp.IQTypeError: // Если сервер не хочет пинговаться и отвечает ошибкой на пинг, то наверно он не умеет в пинги, // хотя если мы его пингуем, значит он анонсировал такой capability. Вот, засранец! var ( iqPingStruct IqPing 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") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Error(str) } } parseError = false } // Это у нас пинг xep-0410 и мы не в комнате, предполагается, что надо бы заджойниться. if parseError { var iqErrorCancelNotAcceptable IqErrorCancelNotAcceptable 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", ) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } } else { log.Error( "Looks like message parsed as jabberIqErrorCancelNotAcceptable but addressed not to us", ) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } parseError = false } } if parseError { var IqErrorCancelServiceNotAvailable IqErrorCancelServiceNotAvailable if err := xml.Unmarshal(v.Query, &IqErrorCancelServiceNotAvailable); err == nil { if v.To == j.Talk.JID() { log.Infof("Jid %s disabled response to iq.version(?) request", v.From) } else { log.Error( "Looks like message parsed as jabberIqErrorCancelServiceNotAvailable but addressed not to us", ) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } parseError = false } } /* Адресата нет в конференции */ if parseError { var IqErrorCancelItemNotFound IqErrorCancelItemNotFound if err := xml.Unmarshal(v.Query, &IqErrorCancelItemNotFound); err == nil { if v.To == j.Talk.JID() { log.Infof("Jid %s disabled response to iq.version(?) request, hello batman46(new)", v.From) } else { log.Error( "Looks like message parsed as jabberIqErrorCancelServiceNotAvailable but addressed not to us", ) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } parseError = false } } if parseError { log.Error("Unhandled IQ Error message") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } // Нам прилетело что-то неизвестное из семейства IQ stanza. default: log.Info("Got an unknown IQ request. Dunno how deal with it, discarding") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } // Смена статуса участника. 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) { j.GTomb.Go( func() error { if err := j.JoinMuc(v.From); err != nil { return fmt.Errorf("unable to join MUC %s: %w", v.From, err) } return nil }, ) } } else { log.Errorf("Presence notification, Type: %s, From: %s, Show: %s, Status: %s", v.Type, v.From, v.Show, v.Status) for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Error(str) } } } else { log.Infof( "Presence notification, Type: %s, From: %s, To: %s Show: %s, Status: %s, Affiliation: %s, Role: %s, JID: %s, Priority: %s", v.Type, v.From, v.To, v.Show, v.Status, v.Affiliation, v.Role, v.JID, v.Priority, ) if !strings.Contains(v.From, "/") { log.Debugf("Looks like we have got Presence notification from roster: %s, skip it", v.From) return } if v.JID == "" { log.Info("Useless presence notification without JID field, skipping") return } 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 } // На всякий случай добавим комнату, из которой пришло уведомление в список тех, в которые вхож бот. if nick == j.GetBotNickFromRoomConfig(room) { j.RoomsConnected = append(j.RoomsConnected, room) // На всякий случай дедуплицируем список комнат, к которым мы заджойнились. sort.Strings(j.RoomsConnected) j.RoomsConnected = slices.Compact(j.RoomsConnected) } switch v.Role { // Участник ушёл. case "none": var ( newPresences = make([]xmpp.Presence, 0) presenceJSON []byte ok bool presences []xmpp.Presence ) // Обновлять базу имеет смысл только если есть хоть какая-то запись о комнате в ней. if presenceJSONInterface, present := j.RoomPresences.Get(room); present { presenceJSON, ok = presenceJSONInterface.([]byte) if !ok { log.Errorf("Looks like we have corrupted entry in presence db") return } if err := json.Unmarshal(presenceJSON, &presences); err != nil { log.Errorf("Looks like we have corrupted entry in presence db") return } for _, p := range presences { //nolint: gocritic // Развели тут, блять, страну советов, напиши что надо сделать кодом, сука. // Если находим, что у нас уже есть клиент с таким же From, то есть полным nick-ом (для groupchat) // пропускаем его. if p.From == v.From { if j.C.Debug.PresenceDB { log.Debugf("Skipping presence for %s in room %s", v.JID, room) } continue } if j.C.Debug.PresenceDB { log.Debugf("Appending presence for %s in room %s", v.JID, room) } newPresences = append(newPresences, p) } presenceJSON, _ = json.Marshal(newPresences) //nolint: errchkjson if j.C.Debug.PresenceDB { log.Debugf("Updating presence db for room %s", room) } j.RoomPresences.Set(room, presenceJSON) // Больше нам, похоже с этим уведомлением нечего. return } // Участник пришёл или изменил свой статус. default: var ( newPresences = make([]xmpp.Presence, 0) presenceJSON []byte ok bool presences []xmpp.Presence skip bool ) if presenceJSONInterface, present := j.RoomPresences.Get(room); present { presenceJSON, ok = presenceJSONInterface.([]byte) if !ok { log.Errorf("Looks like we have corrupted entry in presence db") return } // Если мы нашли запись о комнате, то её надо обновить. if err := json.Unmarshal(presenceJSON, &presences); err != nil { log.Errorf("Looks like we have corrupted entry in presence db") return } for _, p := range presences { //nolint: gocritic // Развели тут, блять, страну советов, напиши что надо сделать кодом, сука. // Если находим, что у нас уже есть клиент с таким же From, то есть полным nick-ом (для groupchat) // запоминаем его и добавим позднее. if p.From == v.From { // Запоминаем участника только если его роль не поменялась на outcast. (То есть если это не // бан.) if v.Role == "outcast" { if j.C.Debug.PresenceDB { log.Debugf("Let's forget about %s because it is outcast/banned", p.JID) } skip = true } else if j.C.Debug.PresenceDB { log.Debugf("Skip adding %s to presences for %s, add as last one", p.JID, room) } continue } if j.C.Debug.PresenceDB { log.Debugf("Appending presence for %s in room %s", v.JID, room) } newPresences = append(newPresences, p) } } if !skip { if j.C.Debug.PresenceDB { log.Debugf("Appending presence for %s in room %s", v.JID, room) } newPresences = append(newPresences, v) } else if j.C.Debug.PresenceDB { log.Debugf("Skip Appending outcast presence for %s in room %s", v.JID, room) } presenceJSON, _ = json.Marshal(newPresences) //nolint: errchkjson if j.C.Debug.PresenceDB { log.Debugf("Updating presence db for room %s", room) } j.RoomPresences.Set(room, presenceJSON) } if j.C.Debug.PresenceDB { log.Debugf("List of presence entries in presence db of %s:", room) if presenceJSONInterface, present := j.RoomPresences.Get(room); present { var presences []xmpp.Presence presenceJSON, ok := presenceJSONInterface.([]byte) if !ok { log.Debugf("Looks like we have corrupted entry in presence db") return } // Если мы нашли запись о комнате, то её надо обновить. if err := json.Unmarshal(presenceJSON, &presences); err != nil { log.Debugf("Looks like we have corrupted entry in presence db") return } for _, p := range presences { //nolint: gocritic // Развели тут, блять, страну советов, напиши что надо сделать кодом, сука. log.Debugf("Found presence for %s from %s", p.JID, room) } } } // Проверяем, а не злодей ли зашёл? Сделать это мы можем, только если мы находимся в комнате. // По правилам, мы можем что-то делать, только после того, как нам прилетит наш собственный 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") for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Debug(str) } } } // Это что-то неизвестное, подампим событие в лог. default: for str := range strings.SplitSeq(spew.Sdump(e), "\n") { log.Info(str) } } } /* vim: set ft=go noet ai ts=4 sw=4 sts=4: */