diff --git a/.gitignore b/.gitignore index 58426dd..cf4df11 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ telegabber sessions/ session.dat session.dat.new +release/ +tdlib/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c3858e9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM golang:1.19-bookworm AS base + +RUN apt-get update +run apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git + +FROM base AS tdlib + +ARG TD_COMMIT +ARG MAKEOPTS +RUN git clone https://github.com/tdlib/td /src/ +RUN git -C /src/ checkout "${TD_COMMIT}" +RUN mkdir build +WORKDIR /build/ +RUN cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/compiled/ /src/ +RUN cmake --build . ${MAKEOPTS} +RUN make install + +FROM base AS cache +ARG VERSION +COPY --from=tdlib /compiled/ /usr/local/ +COPY ./ /src +RUN git -C /src checkout "${VERSION}" +WORKDIR /src +RUN go get + +FROM cache AS build +ARG MAKEOPTS +WORKDIR /src +RUN make ${MAKEOPTS} + +FROM scratch AS telegabber +COPY --from=build /src/release/telegabber /usr/local/bin/ +ENTRYPOINT ["/usr/local/bin/telegabber"] + +FROM scratch AS binaries +COPY --from=telegabber /usr/local/bin/telegabber / diff --git a/Makefile b/Makefile index 48c5d7e..b1facea 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,25 @@ .PHONY: all test COMMIT := $(shell git rev-parse --short HEAD) +TD_COMMIT := "5bbfc1cf5dab94f82e02f3430ded7241d4653551" +VERSION := "v2.0.0-dev" +MAKEOPTS := "-j4" all: - go build -ldflags "-X main.commit=${COMMIT}" -o telegabber + mkdir -p release + go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber test: - go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter + go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger lint: $(GOPATH)/bin/golint ./... + +build_indocker: + docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries . + +build_indocker_staging: + DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --network host --output=release --target binaries -f staging.Dockerfile . + +build_tdlib: + DOCKER_BUILDKIT=1 docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=tdlib --target binaries -f tdlib.Dockerfile . diff --git a/README.md b/README.md index 36aa7ca..ae80f4a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ It is good idea to obtain Telegram API ID from [**https://my.telegram.org**](htt * `--profiling-port=xxxx`: start the pprof server on port `xxxx`. Access is limited to localhost. * `--config=/bla/bla/config.yml`: set the config file path (default: `config.yml`). * `--schema=/bla/bla/schema.json`: set the schema file path (default: `./config_schema.json`). +* `--ids=/bla/bla/ids`: set the folder for ids database (default: `ids`). ### How to receive files from Telegram ### diff --git a/badger/ids.go b/badger/ids.go new file mode 100644 index 0000000..1295e8a --- /dev/null +++ b/badger/ids.go @@ -0,0 +1,230 @@ +package badger + +import ( + "bytes" + "errors" + "fmt" + "strconv" + + badger "github.com/dgraph-io/badger/v4" + log "github.com/sirupsen/logrus" +) + +// IdsDB represents a Badger database +type IdsDB struct { + db *badger.DB +} + +// IdsDBOpen returns a new DB object +func IdsDBOpen(path string) IdsDB { + bdb, err := badger.Open(badger.DefaultOptions(path)) + if err != nil { + log.Errorf("Failed to open ids database: %v, falling back to in-memory database", path) + bdb, err = badger.Open(badger.DefaultOptions("").WithInMemory(true)) + if err != nil { + log.Fatalf("Couldn't initialize the ids database") + } + } + + return IdsDB{ + db: bdb, + } +} + +// Set stores an id pair +func (db *IdsDB) Set(tgAccount, xmppAccount string, tgChatId, tgMsgId int64, xmppId string) error { + bPrefix := toKeyPrefix(tgAccount, xmppAccount) + bTgId := toTgByteString(tgChatId, tgMsgId) + bXmppId := toXmppByteString(xmppId) + bTgKey := toByteKey(bPrefix, bTgId, "tg") + bXmppKey := toByteKey(bPrefix, bXmppId, "xmpp") + + return db.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(bTgKey, bXmppId); err != nil { + return err + } + return txn.Set(bXmppKey, bTgId) + }) +} + +func (db *IdsDB) getByteValue(key []byte) ([]byte, error) { + var valCopy []byte + err := db.db.View(func(txn *badger.Txn) error { + item, err := txn.Get(key) + if err != nil { + return err + } + + valCopy, err = item.ValueCopy(nil) + return err + }) + return valCopy, err +} + +// GetByTgIds obtains an XMPP id by Telegram chat/message ids +func (db *IdsDB) GetByTgIds(tgAccount, xmppAccount string, tgChatId, tgMsgId int64) (string, error) { + val, err := db.getByteValue(toByteKey( + toKeyPrefix(tgAccount, xmppAccount), + toTgByteString(tgChatId, tgMsgId), + "tg", + )) + if err != nil { + return "", err + } + return string(val), nil +} + +// GetByXmppId obtains Telegram chat/message ids by an XMPP id +func (db *IdsDB) GetByXmppId(tgAccount, xmppAccount, xmppId string) (int64, int64, error) { + val, err := db.getByteValue(toByteKey( + toKeyPrefix(tgAccount, xmppAccount), + toXmppByteString(xmppId), + "xmpp", + )) + if err != nil { + return 0, 0, err + } + return splitTgByteString(val) +} + +func toKeyPrefix(tgAccount, xmppAccount string) []byte { + return []byte(fmt.Sprintf("%v/%v/", tgAccount, xmppAccount)) +} + +func toByteKey(prefix, suffix []byte, typ string) []byte { + key := make([]byte, 0, len(prefix)+len(suffix)+6) + key = append(key, prefix...) + key = append(key, []byte(typ)...) + key = append(key, []byte("/")...) + key = append(key, suffix...) + return key +} + +func toTgByteString(tgChatId, tgMsgId int64) []byte { + return []byte(fmt.Sprintf("%v/%v", tgChatId, tgMsgId)) +} + +func toXmppByteString(xmppId string) []byte { + return []byte(xmppId) +} + +func splitTgByteString(val []byte) (int64, int64, error) { + parts := bytes.Split(val, []byte("/")) + if len(parts) != 2 { + return 0, 0, errors.New("Couldn't parse tg id pair") + } + tgChatId, err := strconv.ParseInt(string(parts[0]), 10, 64) + if err != nil { + return 0, 0, err + } + tgMsgId, err := strconv.ParseInt(string(parts[1]), 10, 64) + return tgChatId, tgMsgId, err +} + +// ReplaceIdPair replaces an old entry by XMPP ID with both new XMPP and Tg ID +func (db *IdsDB) ReplaceIdPair(tgAccount, xmppAccount, oldXmppId, newXmppId string, newMsgId int64) error { + // read old pair + chatId, oldMsgId, err := db.GetByXmppId(tgAccount, xmppAccount, oldXmppId) + if err != nil { + return err + } + + bPrefix := toKeyPrefix(tgAccount, xmppAccount) + + bOldTgId := toTgByteString(chatId, oldMsgId) + bOldXmppId := toXmppByteString(oldXmppId) + bOldTgKey := toByteKey(bPrefix, bOldTgId, "tg") + bOldXmppKey := toByteKey(bPrefix, bOldXmppId, "xmpp") + + bTgId := toTgByteString(chatId, newMsgId) + bXmppId := toXmppByteString(newXmppId) + bTgKey := toByteKey(bPrefix, bTgId, "tg") + bXmppKey := toByteKey(bPrefix, bXmppId, "xmpp") + + return db.db.Update(func(txn *badger.Txn) error { + // save new pair + if err := txn.Set(bTgKey, bXmppId); err != nil { + return err + } + if err := txn.Set(bXmppKey, bTgId); err != nil { + return err + } + // delete old pair + if err := txn.Delete(bOldTgKey); err != nil { + return err + } + return txn.Delete(bOldXmppKey) + }) +} + +// ReplaceXmppId replaces an old XMPP ID with new XMPP ID and keeps Tg ID intact +func (db *IdsDB) ReplaceXmppId(tgAccount, xmppAccount, oldXmppId, newXmppId string) error { + // read old Tg IDs + chatId, msgId, err := db.GetByXmppId(tgAccount, xmppAccount, oldXmppId) + if err != nil { + return err + } + + bPrefix := toKeyPrefix(tgAccount, xmppAccount) + + bOldXmppId := toXmppByteString(oldXmppId) + bOldXmppKey := toByteKey(bPrefix, bOldXmppId, "xmpp") + + bTgId := toTgByteString(chatId, msgId) + bXmppId := toXmppByteString(newXmppId) + bTgKey := toByteKey(bPrefix, bTgId, "tg") + bXmppKey := toByteKey(bPrefix, bXmppId, "xmpp") + + return db.db.Update(func(txn *badger.Txn) error { + // save new pair + if err := txn.Set(bTgKey, bXmppId); err != nil { + return err + } + if err := txn.Set(bXmppKey, bTgId); err != nil { + return err + } + // delete old xmpp->tg entry + return txn.Delete(bOldXmppKey) + }) +} + +// ReplaceTgId replaces an old Tg ID with new Tg ID and keeps Tg chat ID and XMPP ID intact +func (db *IdsDB) ReplaceTgId(tgAccount, xmppAccount string, chatId, oldMsgId, newMsgId int64) error { + // read old XMPP ID + xmppId, err := db.GetByTgIds(tgAccount, xmppAccount, chatId, oldMsgId) + if err != nil { + return err + } + + bPrefix := toKeyPrefix(tgAccount, xmppAccount) + + bOldTgId := toTgByteString(chatId, oldMsgId) + bOldTgKey := toByteKey(bPrefix, bOldTgId, "tg") + + bTgId := toTgByteString(chatId, newMsgId) + bXmppId := toXmppByteString(xmppId) + bTgKey := toByteKey(bPrefix, bTgId, "tg") + bXmppKey := toByteKey(bPrefix, bXmppId, "xmpp") + + return db.db.Update(func(txn *badger.Txn) error { + // save new pair + if err := txn.Set(bTgKey, bXmppId); err != nil { + return err + } + if err := txn.Set(bXmppKey, bTgId); err != nil { + return err + } + // delete old tg->xmpp entry + return txn.Delete(bOldTgKey) + }) +} + +// Gc compacts the value log +func (db *IdsDB) Gc() { + db.db.RunValueLogGC(0.7) +} + +// Close closes a DB +func (db *IdsDB) Close() { + db.db.Close() +} diff --git a/badger/ids_test.go b/badger/ids_test.go new file mode 100644 index 0000000..efafdeb --- /dev/null +++ b/badger/ids_test.go @@ -0,0 +1,72 @@ +package badger + +import ( + "reflect" + "testing" +) + +func TestToKeyPrefix(t *testing.T) { + if !reflect.DeepEqual(toKeyPrefix("+123456789", "test@example.com"), []byte("+123456789/test@example.com/")) { + t.Error("Wrong prefix") + } +} + +func TestToByteKey(t *testing.T) { + if !reflect.DeepEqual(toByteKey([]byte("ababa/galamaga/"), []byte("123"), "ppp"), []byte("ababa/galamaga/ppp/123")) { + t.Error("Wrong key") + } +} + +func TestToTgByteString(t *testing.T) { + if !reflect.DeepEqual(toTgByteString(-2345, 6789), []byte("-2345/6789")) { + t.Error("Wrong tg string") + } +} + +func TestToXmppByteString(t *testing.T) { + if !reflect.DeepEqual(toXmppByteString("aboba"), []byte("aboba")) { + t.Error("Wrong xmpp string") + } +} + +func TestSplitTgByteStringUnparsable(t *testing.T) { + _, _, err := splitTgByteString([]byte("@#U*&$(@#")) + if err == nil { + t.Error("Unparsable should not be parsed") + return + } + if err.Error() != "Couldn't parse tg id pair" { + t.Error("Wrong parse error") + } +} + +func TestSplitTgByteManyParts(t *testing.T) { + _, _, err := splitTgByteString([]byte("a/b/c/d")) + if err == nil { + t.Error("Should not parse many parts") + return + } + if err.Error() != "Couldn't parse tg id pair" { + t.Error("Wrong parse error") + } +} + +func TestSplitTgByteNonNumeric(t *testing.T) { + _, _, err := splitTgByteString([]byte("0/a")) + if err == nil { + t.Error("Should not parse non-numeric msgid") + } +} + +func TestSplitTgByteSuccess(t *testing.T) { + chatId, msgId, err := splitTgByteString([]byte("-198282398/23798478")) + if err != nil { + t.Error("Should be parsed well") + } + if chatId != -198282398 { + t.Error("Wrong chatId") + } + if msgId != 23798478 { + t.Error("Wrong msgId") + } +} diff --git a/config.yml.example b/config.yml.example index b8de1dd..260dc46 100644 --- a/config.yml.example +++ b/config.yml.example @@ -7,6 +7,7 @@ :user: 'www-data' # owner of content files :quota: '256MB' # maximum storage size :tdlib_verbosity: 1 + :mam_threshold: 7 # in days :tdlib: :datadir: './sessions/' :client: diff --git a/config/config.go b/config/config.go index 7c685fb..b214495 100644 --- a/config/config.go +++ b/config/config.go @@ -27,10 +27,11 @@ type XMPPConfig struct { // TelegramConfig is for :telegram: subtree type TelegramConfig struct { - Loglevel string `yaml:":loglevel"` - Content TelegramContentConfig `yaml:":content"` - Verbosity uint8 `yaml:":tdlib_verbosity"` - Tdlib TelegramTdlibConfig `yaml:":tdlib"` + Loglevel string `yaml:":loglevel"` + Content TelegramContentConfig `yaml:":content"` + Verbosity uint8 `yaml:":tdlib_verbosity"` + MAMThreshold uint32 `yaml:":mam_threshold"` + Tdlib TelegramTdlibConfig `yaml:":tdlib"` } // TelegramContentConfig is for :content: subtree diff --git a/config_schema.json b/config_schema.json index ab25307..a58da77 100644 --- a/config_schema.json +++ b/config_schema.json @@ -33,6 +33,9 @@ ":tdlib_verbosity": { "type": "integer" }, + ":mam_threshold": { + "type": "integer" + }, ":tdlib": { "required": [":client"], "type": "object", diff --git a/go.mod b/go.mod index 41f4e67..2a1c994 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,40 @@ module dev.narayana.im/narayana/telegabber -go 1.13 +go 1.19 require ( - github.com/Arman92/go-tdlib v0.0.0-20191002071913-526f4e1d15f7 - github.com/pkg/errors v0.8.1 + github.com/dgraph-io/badger/v4 v4.1.0 + github.com/google/uuid v1.1.1 + github.com/pkg/errors v0.9.1 github.com/santhosh-tekuri/jsonschema v1.2.4 github.com/sirupsen/logrus v1.4.2 github.com/soheilhy/args v0.0.0-20150720134047-6bcf4c78e87e + github.com/xdg-go/stringprep v1.0.4 github.com/zelenin/go-tdlib v0.5.2 gopkg.in/yaml.v2 v2.2.4 gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1 ) -replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 // indirect + github.com/golang/protobuf v1.3.2 // indirect + github.com/golang/snappy v0.0.3 // indirect + github.com/google/flatbuffers v1.12.1 // indirect + github.com/klauspost/compress v1.12.3 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect + go.opencensus.io v0.22.5 // indirect + golang.org/x/net v0.7.0 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + nhooyr.io/websocket v1.6.5 // indirect +) + +replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 + +replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 diff --git a/go.sum b/go.sum index 5fa5f81..83a3e9e 100644 --- a/go.sum +++ b/go.sum @@ -1,34 +1,43 @@ -dev.narayana.im/narayana/go-xmpp v0.0.0-20211218155535-e55463fc9829 h1:qe81G6+t1V1ySRMa7lSu5CayN5aP5GEiHXL2DYwHzuA= -dev.narayana.im/narayana/go-xmpp v0.0.0-20211218155535-e55463fc9829/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615 h1:RRUZJSro+k8FkazNx7QEYLVoO4wZtchvsd0Y2RBWjeU= +dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= +dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268 h1:NCbc2bYuUGQsb/3z5SCIia3N34Ktwq3FwaUAfgF/WEU= +dev.narayana.im/narayana/go-tdlib v0.0.0-20231111182840-bc2f985e6268/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= +dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061 h1:CWAQT74LwQne/3Po5KXDvudu3N0FBWm3XZZZhtl5j2w= +dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f h1:6249ajbMjgYz53Oq0IjTvjHXbxTfu29Mj1J/6swRHs4= dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= -github.com/Arman92/go-tdlib v0.0.0-20191002071913-526f4e1d15f7 h1:GbV1Lv3lVHsSeKAqPTBem72OCsGjXntW4jfJdXciE+w= -github.com/Arman92/go-tdlib v0.0.0-20191002071913-526f4e1d15f7/go.mod h1:ZzkRfuaFj8etIYMj/ECtXtgfz72RE6U+dos27b3XIwk= +dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd h1:+UW+E7JjI88aH4beDn1cw6D8rs1I061hN91HU4Y4pT8= +dev.narayana.im/narayana/go-xmpp v0.0.0-20240131013505-18c46e6c59fd/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 h1:29/NjOGOUDceO73Hk4Nj4uVa1je8MULJlsDSvKxSN/k= +dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a h1:9PPqmhy6HbhhCS5EZzw+sdi4EpWW+LOwnz+/JXTcHjQ= +dev.narayana.im/narayana/go-xmpp v0.0.0-20250818040038-376b5d77528a/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4 h1:HQT33Zp3iRkbCiijWDo943K//wQgzoMccIP7Vb2uEfY= +dev.narayana.im/narayana/go-xmpp v0.0.0-20250823114312-ed4011fc17e4/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/agnivade/wasmbrowsertest v0.3.1/go.mod h1:zQt6ZTdl338xxRaMW395qccVE2eQm0SjC/SDz0mPWQI= -github.com/bodqhrohro/go-tdlib v0.1.1 h1:lmHognymABxP3cmHkfAGhGnWaJaZ3htpJ7RSbZacin4= -github.com/bodqhrohro/go-tdlib v0.1.2-0.20191121200156-e826071d3317 h1:+mv4FwWXl8hTa7PrhekwVzPknH+rHqB60jIPBi2XqI8= -github.com/bodqhrohro/go-tdlib v0.1.2-0.20191121200156-e826071d3317/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= -github.com/bodqhrohro/go-tdlib v0.1.2-0.20191121233100-48d2382034fb h1:y5PnjdAnNVS0q8xuwjm3TxBfLriJmykQdoGiyYZB3s0= -github.com/bodqhrohro/go-tdlib v0.1.2-0.20191121233100-48d2382034fb/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= -github.com/bodqhrohro/go-tdlib v0.4.4-0.20211229000346-ee6018be8ec0 h1:9ysLk2hG2q0NeNdX6StzS+4fTAG2FeZJYKKegCuB4q4= -github.com/bodqhrohro/go-tdlib v0.4.4-0.20211229000346-ee6018be8ec0/go.mod h1:sOdXFpJ3zn6RHRc8aNVkJYALHpoplwBgMwIbRCYABIg= -github.com/bodqhrohro/go-xmpp v0.1.4-0.20191106203535-f3b463f3b26c h1:LzcQyE+Gs+0kAbpnPAUD68FvUCieKZip44URAmH70PI= -github.com/bodqhrohro/go-xmpp v0.1.4-0.20191106203535-f3b463f3b26c/go.mod h1:fWixaMaFvx8cxXcJVJ5kU9csMeD/JN8on7ybassU8rY= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20191105232737-9abd5be0aa1b h1:9BLd/SNO4JJZLRl1Qb1v9mNivIlHuwHDe2c8hQvBxFA= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20191105232737-9abd5be0aa1b/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20211205194122-f8c4ecb59d8b h1:rTK55SNCBmssyRgNAweVwVVfuoRstI8RbL+8Ys/RzxE= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20211205194122-f8c4ecb59d8b/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20211218153313-a8aadd78b65b h1:VDi8z3PzEDhQzazRRuv1fkv662DT3Mm/TY/Lni2Sgrc= -github.com/bodqhrohro/go-xmpp v0.2.1-0.20211218153313-a8aadd78b65b/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20190614062957-d6d2f92b486d/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190621002710-8cbd498dd7a0/go.mod h1:S8mB5wY3vV+vRIzf39xDXsw3XKYewW9X6rW2aEmkrSw= github.com/chromedp/cdproto v0.0.0-20190812224334-39ef923dcb8d/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/cdproto v0.0.0-20190926234355-1b4886c6fad6/go.mod h1:0YChpVzuLJC5CPr+x3xkHN6Z8KOSXjNbL7qV8Wc4GW0= github.com/chromedp/chromedp v0.3.1-0.20190619195644-fd957a4d2901/go.mod h1:mJdvfrVn594N9tfiPecUidF6W5jPRKHymqHfzbobPsM= github.com/chromedp/chromedp v0.4.0/go.mod h1:DC3QUn4mJ24dwjcaGQLoZrhm4X/uPHZ6spDbS2uFhm4= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.1.0 h1:E38jc0f+RATYrycSUf9LMv/t47XAy+3CApyYSq4APOQ= +github.com/dgraph-io/badger/v4 v4.1.0/go.mod h1:P50u28d39ibBRmIJuQC/NSdBOg46HnHw7al2SW5QRHg= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/fatih/color v1.6.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= @@ -38,25 +47,43 @@ github.com/go-interpreter/wagon v0.6.0/go.mod h1:5+b/MBYkclRZngKF5s6qrgWxSLgE9F5 github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/godcong/go-tdlib v0.4.4-0.20211203152853-64d22ab8d4ac h1:5FQGW4yHSkbwm+4i/8ef7FvkIFt4NOM4HexSbvPduRo= -github.com/godcong/go-tdlib v0.4.4-0.20211203152853-64d22ab8d4ac/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= +github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -72,8 +99,9 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis= @@ -87,48 +115,114 @@ github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/twitchyliquid64/golang-asm v0.0.0-20190126203739-365674df15fc/go.mod h1:NoCfSFWosfqMqmmD7hApkirIK9ozpHjxRnRxs1l413A= -github.com/zelenin/go-tdlib v0.1.0 h1:Qq+FGE0/EWdsRB6m26ULDndu2DtW558aFXNzi0Y/FqQ= -github.com/zelenin/go-tdlib v0.1.0/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zelenin/go-tdlib v0.5.2 h1:inEATEM0Pz6/HBI3wTlhd+brDHpmoXGgwdSb8/V6GiA= github.com/zelenin/go-tdlib v0.5.2/go.mod h1:Xs8fXbk5n7VaPyrSs9DP7QYoBScWYsjX+lUcWmx1DIU= go.coder.com/go-tools v0.0.0-20190317003359-0c6a35b74a16/go.mod h1:iKV5yK9t+J5nG9O3uF6KYdPEz3dyfMyB15MN1rbQ8Qw= +go.opencensus.io v0.22.5 h1:dntmOdLpSpHlVqbW5Eay97DelsZHe+55D+xC6i0dDS0= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= golang.org/x/crypto v0.0.0-20180426230345-b49d69b5da94/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181102091132-c10e9556a7bc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190306220234-b354f8bf4d9e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190618155005-516e3c20635f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190927073244-c990c680b611 h1:q9u40nxWT5zRClI/uU9dHCiYGottAg6Nzz4YUQyHxdA= golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190920225731-5eefd052ad72/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -136,12 +230,9 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gosrc.io/xmpp v0.1.3 h1:VYP1bA35irlQ1ZAJqNhJOz8NSsSTkzQRhREfmuG1H80= -gosrc.io/xmpp v0.1.3/go.mod h1:fWixaMaFvx8cxXcJVJ5kU9csMeD/JN8on7ybassU8rY= -gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1 h1:E3uJqX6ImJL9AFdjGbiW04jq8IQ+NcOK+JSiWq2TbRw= -gosrc.io/xmpp v0.5.2-0.20211214110136-5f99e1cd06e1/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY= gotest.tools v2.1.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpAriY= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= mvdan.cc/sh v2.6.4+incompatible/go.mod h1:IeeQbZq+x2SUGBensq/jge5lLQbS3XT2ktyp3wrt4x8= nhooyr.io/websocket v1.6.5 h1:8TzpkldRfefda5JST+CnOH135bzVPz5uzfn/AF+gVKg= nhooyr.io/websocket v1.6.5/go.mod h1:F259lAzPRAH0htX2y3ehpJe09ih1aSHN7udWki1defY= diff --git a/persistence/sessions.go b/persistence/sessions.go index 1658cc9..c75dea0 100644 --- a/persistence/sessions.go +++ b/persistence/sessions.go @@ -3,6 +3,7 @@ package persistence import ( "github.com/pkg/errors" "io/ioutil" + "sync" "time" "dev.narayana.im/narayana/telegabber/yamldb" @@ -34,35 +35,78 @@ type SessionsMap struct { // Session is a key-values subtree type Session struct { - Login string `yaml:":login"` - Timezone string `yaml:":timezone"` - KeepOnline bool `yaml:":keeponline"` - RawMessages bool `yaml:":rawmessages"` - AsciiArrows bool `yaml:":asciiarrows"` - OOBMode bool `yaml:":oobmode"` - Carbons bool `yaml:":carbons"` - HideIds bool `yaml:":hideids"` + Login string `yaml:":login"` + Timezone string `yaml:":timezone"` + KeepOnline bool `yaml:":keeponline"` + RawMessages bool `yaml:":rawmessages"` + AsciiArrows bool `yaml:":asciiarrows"` + MUC bool `yaml:":muc"` + OOBMode bool `yaml:":oobmode"` + Carbons bool `yaml:":carbons"` + HideIds bool `yaml:":hideids"` + Receipts bool `yaml:":receipts"` + NativeEdits bool `yaml:":nativeedits"` + IgnoredChats []int64 `yaml:":ignoredchats"` + ignoredChatsMap map[int64]bool `yaml:"-"` + + IgnoreGroupDeletions bool `yaml:":ignoregroupdeletions"` } -var configKeys = []string{ +const ( + PropertyTypeUnknown byte = iota + PropertyTypeString + PropertyTypeBool +) + +var ConfigKeys = []string{ "timezone", "keeponline", "rawmessages", "asciiarrows", + "muc", "oobmode", "carbons", "hideids", + "receipts", + "nativeedits", + "ignoregroupdeletions", +} + +var Presets = map[string][][]string{ + "modern": [][]string{ + {"asciiarrows", "false"}, + {"oobmode", "true"}, + {"carbons", "true"}, + {"hideids", "true"}, + {"receipts", "true"}, + {"nativeedits", "true"}, + }, + "classic": [][]string{ + {"asciiarrows", "true"}, + {"oobmode", "false"}, + {"carbons", "false"}, + {"hideids", "false"}, + {"receipts", "false"}, + {"nativeedits", "false"}, + }, } var sessionDB *SessionsYamlDB +var sessionsLock sync.Mutex // SessionMarshaller implementation for YamlDB func SessionMarshaller() ([]byte, error) { cleanedMap := SessionsMap{} emptySessionsMap(&cleanedMap) + sessionsLock.Lock() + defer sessionsLock.Unlock() for jid, session := range sessionDB.Data.Sessions { if session.Login != "" { + session.IgnoredChats = make([]int64, 0, len(session.ignoredChatsMap)) + for chatID := range session.ignoredChatsMap { + session.IgnoredChats = append(session.IgnoredChats, chatID) + } cleanedMap.Sessions[jid] = session } } @@ -104,6 +148,16 @@ func initYamlDB(path string, dataPtr *SessionsMap) (*SessionsYamlDB, error) { emptySessionsMap(dataPtr) } + // convert ignored users slice to map + for jid, session := range dataPtr.Sessions { + session.ignoredChatsMap = make(map[int64]bool) + for _, chatID := range session.IgnoredChats { + session.ignoredChatsMap[chatID] = true + } + session.IgnoredChats = nil + dataPtr.Sessions[jid] = session + } + return &SessionsYamlDB{ YamlDB: yamldb.YamlDB{ Path: path, @@ -115,6 +169,13 @@ func initYamlDB(path string, dataPtr *SessionsMap) (*SessionsYamlDB, error) { // Get retrieves a session value func (s *Session) Get(key string) (string, error) { + sessionsLock.Lock() + defer sessionsLock.Unlock() + + return s.get(key) +} + +func (s *Session) get(key string) (string, error) { switch key { case "timezone": return s.Timezone, nil @@ -124,12 +185,20 @@ func (s *Session) Get(key string) (string, error) { return fromBool(s.RawMessages), nil case "asciiarrows": return fromBool(s.AsciiArrows), nil + case "muc": + return fromBool(s.MUC), nil case "oobmode": return fromBool(s.OOBMode), nil case "carbons": return fromBool(s.Carbons), nil case "hideids": return fromBool(s.HideIds), nil + case "receipts": + return fromBool(s.Receipts), nil + case "nativeedits": + return fromBool(s.NativeEdits), nil + case "ignoregroupdeletions": + return fromBool(s.IgnoreGroupDeletions), nil } return "", errors.New("Unknown session property") @@ -137,9 +206,12 @@ func (s *Session) Get(key string) (string, error) { // ToMap converts the session to a map func (s *Session) ToMap() map[string]string { + sessionsLock.Lock() + defer sessionsLock.Unlock() + m := make(map[string]string) - for _, configKey := range configKeys { - value, _ := s.Get(configKey) + for _, configKey := range ConfigKeys { + value, _ := s.get(configKey) m[configKey] = value } @@ -148,6 +220,9 @@ func (s *Session) ToMap() map[string]string { // Set sets a session value func (s *Session) Set(key string, value string) (string, error) { + sessionsLock.Lock() + defer sessionsLock.Unlock() + switch key { case "timezone": s.Timezone = value @@ -173,6 +248,13 @@ func (s *Session) Set(key string, value string) (string, error) { } s.AsciiArrows = b return value, nil + case "muc": + b, err := toBool(value) + if err != nil { + return "", err + } + s.MUC = b + return value, nil case "oobmode": b, err := toBool(value) if err != nil { @@ -194,11 +276,44 @@ func (s *Session) Set(key string, value string) (string, error) { } s.HideIds = b return value, nil + case "receipts": + b, err := toBool(value) + if err != nil { + return "", err + } + s.Receipts = b + return value, nil + case "nativeedits": + b, err := toBool(value) + if err != nil { + return "", err + } + s.NativeEdits = b + return value, nil + case "ignoregroupdeletions": + b, err := toBool(value) + if err != nil { + return "", err + } + s.IgnoreGroupDeletions = b + return value, nil } return "", errors.New("Unknown session property") } +// PropertyType determines the property type +func PropertyType(key string) byte { + switch key { + case "timezone": + return PropertyTypeString + case "keeponline", "rawmessages", "asciiarrows", "muc", "oobmode", "carbons", "hideids", + "receipts", "nativeedits", "ignoregroupdeletions": + return PropertyTypeBool + } + return PropertyTypeUnknown +} + // TimezoneToLocation tries to convert config timezone to location func (s *Session) TimezoneToLocation() *time.Location { time, err := time.Parse("-07:00", s.Timezone) @@ -210,6 +325,51 @@ func (s *Session) TimezoneToLocation() *time.Location { return zeroLocation } +// IgnoreChat adds a chat id to ignore list, returns false if already ignored +func (s *Session) IgnoreChat(chatID int64) bool { + sessionsLock.Lock() + defer sessionsLock.Unlock() + + if s.ignoredChatsMap == nil { + s.ignoredChatsMap = make(map[int64]bool) + } else if _, ok := s.ignoredChatsMap[chatID]; ok { + return false + } + + s.ignoredChatsMap[chatID] = true + return true +} + +// UnignoreChat removes a chat id from ignore list, returns false if not already ignored +func (s *Session) UnignoreChat(chatID int64) bool { + sessionsLock.Lock() + defer sessionsLock.Unlock() + + if s.ignoredChatsMap == nil { + return false + } + + if _, ok := s.ignoredChatsMap[chatID]; !ok { + return false + } + + delete(s.ignoredChatsMap, chatID) + return true +} + +// IsChatIgnored checks the chat id against the ignore list +func (s *Session) IsChatIgnored(chatID int64) bool { + sessionsLock.Lock() + defer sessionsLock.Unlock() + + if s.ignoredChatsMap == nil { + return false + } + + _, ok := s.ignoredChatsMap[chatID] + return ok +} + func fromBool(b bool) string { if b { return "true" @@ -228,3 +388,16 @@ func toBool(s string) (bool, error) { return false, errors.New("Invalid boolean value") } + +// NormalizeProperty converts typed properties with uncertain values to certain ones +func NormalizeProperty(key, value string) string { + if PropertyType(key) == PropertyTypeBool { + if value == "0" { + value = "false" + } + if value == "1" { + value = "true" + } + } + return value +} diff --git a/persistence/sessions_test.go b/persistence/sessions_test.go index 8ca6f4f..187445b 100644 --- a/persistence/sessions_test.go +++ b/persistence/sessions_test.go @@ -47,17 +47,23 @@ func TestSessionToMap(t *testing.T) { session := Session{ Timezone: "klsf", RawMessages: true, + MUC: true, OOBMode: true, + Receipts: true, } m := session.ToMap() sample := map[string]string{ - "timezone": "klsf", - "keeponline": "false", - "rawmessages": "true", - "asciiarrows": "false", - "oobmode": "true", - "carbons": "false", - "hideids": "false", + "timezone": "klsf", + "keeponline": "false", + "muc": "true", + "rawmessages": "true", + "asciiarrows": "false", + "oobmode": "true", + "carbons": "false", + "hideids": "false", + "receipts": "true", + "nativeedits": "false", + "ignoregroupdeletions": "false", } if !reflect.DeepEqual(m, sample) { t.Errorf("Map does not match the sample: %v", m) @@ -85,3 +91,31 @@ func TestSessionSetAbsent(t *testing.T) { t.Error("There shouldn't come a donkey!") } } + +func TestSessionIgnore(t *testing.T) { + session := Session{} + if session.IsChatIgnored(3) { + t.Error("Shouldn't be ignored yet") + } + if !session.IgnoreChat(3) { + t.Error("Shouldn't have been ignored") + } + if session.IgnoreChat(3) { + t.Error("Shouldn't ignore second time") + } + if !session.IsChatIgnored(3) { + t.Error("Should be ignored already") + } + if session.IsChatIgnored(-145) { + t.Error("Wrong chat is ignored") + } + if !session.UnignoreChat(3) { + t.Error("Should successfully unignore") + } + if session.UnignoreChat(3) { + t.Error("Should unignore second time") + } + if session.IsChatIgnored(3) { + t.Error("Shouldn't be ignored already") + } +} diff --git a/staging.Dockerfile b/staging.Dockerfile new file mode 100644 index 0000000..ca5a7e6 --- /dev/null +++ b/staging.Dockerfile @@ -0,0 +1,46 @@ +FROM golang:1.19-bullseye AS base + +RUN apt-get update +RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php + +FROM base AS tdlib + +ARG TD_COMMIT +ARG MAKEOPTS +RUN git clone https://github.com/tdlib/td /src/ +RUN git -C /src/ checkout "${TD_COMMIT}" +RUN mkdir build +WORKDIR /build/ +RUN cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/compiled/ /src/ +RUN cmake --build . --target prepare_cross_compiling ${MAKEOPTS} +WORKDIR /src/ +RUN php SplitSource.php +WORKDIR /build/ +RUN cmake --build . ${MAKEOPTS} +RUN make install + +FROM base AS cache +ARG VERSION +COPY --from=tdlib /compiled/ /usr/local/ +WORKDIR /src +RUN go env -w GOCACHE=/go-cache +RUN go env -w GOMODCACHE=/gomod-cache +RUN --mount=type=cache,target=/gomod-cache \ + --mount=type=bind,source=./,target=/src,rw \ + /bin/bash -c 'go mod tidy; go get' + +FROM cache AS build +ARG MAKEOPTS +WORKDIR /src +RUN --mount=type=bind,source=./,target=/src,rw \ + --mount=type=cache,target=/go-cache \ + --mount=type=cache,target=/gomod-cache \ + --mount=type=cache,destination=/src/release \ + make ${MAKEOPTS} + +FROM build AS release +RUN --mount=type=cache,destination=/src/release \ + cp /src/release/telegabber / + +FROM scratch AS binaries +COPY --from=release /telegabber / diff --git a/tdlib.Dockerfile b/tdlib.Dockerfile new file mode 100644 index 0000000..5774405 --- /dev/null +++ b/tdlib.Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.19-bullseye AS base + +RUN apt-get update +RUN apt-get install -y libssl-dev cmake build-essential gperf libz-dev make git php + +FROM base AS tdlib + +ARG TD_COMMIT +ARG MAKEOPTS +RUN git clone https://github.com/tdlib/td /src/ +RUN git -C /src/ checkout "${TD_COMMIT}" +RUN mkdir build +WORKDIR /build/ +RUN cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/compiled/ /src/ +RUN cmake --build . --target prepare_cross_compiling ${MAKEOPTS} +WORKDIR /src/ +RUN php SplitSource.php +WORKDIR /build/ +RUN cmake --build . ${MAKEOPTS} +RUN make install + +FROM scratch AS binaries +COPY --from=tdlib /compiled/ / diff --git a/telegabber.go b/telegabber.go index 72353bb..5994d6c 100644 --- a/telegabber.go +++ b/telegabber.go @@ -12,10 +12,11 @@ import ( "dev.narayana.im/narayana/telegabber/xmpp" log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" goxmpp "gosrc.io/xmpp" ) -var version string = "1.5.0" +var version string = "2.0.0-dev" var commit string var sm *goxmpp.StreamManager @@ -35,6 +36,8 @@ func main() { var configPath = flag.String("config", "config.yml", "Config file path") // JSON schema (not for editing by a user) var schemaPath = flag.String("schema", "./config_schema.json", "Schema file path") + // Folder for Badger DB of message ids + var idsPath = flag.String("ids", "ids", "Ids folder path") var versionFlag = flag.Bool("version", false, "Print the version and exit") flag.Parse() @@ -58,11 +61,14 @@ func main() { log.Fatal(err) } + client.SetLogVerbosityLevel(&client.SetLogVerbosityLevelRequest{ + NewVerbosityLevel: stringToTdlibLogConstant(config.Telegram.Loglevel), + }) SetLogrusLevel(config.XMPP.Loglevel) log.Infof("Starting telegabber version %v", version) - sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram) + sm, component, err = xmpp.NewComponent(config.XMPP, config.Telegram, *idsPath, version) if err != nil { log.Fatal(err) } @@ -87,6 +93,25 @@ func main() { } } +var tdlibLogConstants = map[string]int32{ + ":fatal": 0, + ":error": 1, + ":warn": 2, + ":info": 3, + ":debug": 4, + ":verbose": 5, + ":all": 1023, +} + +func stringToTdlibLogConstant(c string) int32 { + level, ok := tdlibLogConstants[c] + if !ok { + level = 0 + } + + return level +} + func exit() { xmpp.Close(component) close(cleanupDone) diff --git a/telegabber_test.go b/telegabber_test.go new file mode 100644 index 0000000..459f333 --- /dev/null +++ b/telegabber_test.go @@ -0,0 +1,19 @@ +package main + +import ( + "testing" +) + +func TestTdlibLogInfo(t *testing.T) { + tdlibConstant := stringToTdlibLogConstant(":info") + if tdlibConstant != 3 { + t.Errorf("Wrong TDlib constant for info") + } +} + +func TestTdlibLogInvalid(t *testing.T) { + tdlibConstant := stringToTdlibLogConstant("ziz") + if tdlibConstant != 0 { + t.Errorf("Unknown strings should return fatal loglevel") + } +} diff --git a/telegram/cache/cache.go b/telegram/cache/cache.go index c4a59bf..2e66781 100644 --- a/telegram/cache/cache.go +++ b/telegram/cache/cache.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/zelenin/go-tdlib/client" + "gosrc.io/xmpp/stanza" ) // Status stores formatted data for XMPP presence @@ -16,10 +17,12 @@ type Status struct { // Cache allows operating the chats and users cache in // a thread-safe manner type Cache struct { - chats map[int64]*client.Chat + ownChats map[int64]*client.Chat + auxChats map[int64]*client.Chat users map[int64]*client.User statuses map[int64]*Status capsVers map[int64]string + verDiscos map[string]*stanza.DiscoInfo chatsLock sync.Mutex usersLock sync.Mutex statusesLock sync.Mutex @@ -29,9 +32,12 @@ type Cache struct { // NewCache initializes a cache func NewCache() *Cache { return &Cache{ - chats: map[int64]*client.Chat{}, - users: map[int64]*client.User{}, - statuses: map[int64]*Status{}, + ownChats: map[int64]*client.Chat{}, + auxChats: map[int64]*client.Chat{}, + users: map[int64]*client.User{}, + statuses: map[int64]*Status{}, + capsVers: map[int64]string{}, + verDiscos: map[string]*stanza.DiscoInfo{}, } } @@ -42,7 +48,23 @@ func (cache *Cache) ChatsKeys() []int64 { defer cache.chatsLock.Unlock() var keys []int64 - for id := range cache.chats { + for id := range cache.ownChats { + keys = append(keys, id) + } + for id := range cache.auxChats { + keys = append(keys, id) + } + return keys +} + +// OwnChatsKeys grabs only own chat ids synchronously to avoid lockups +// while they are used +func (cache *Cache) OwnChatsKeys() []int64 { + cache.chatsLock.Lock() + defer cache.chatsLock.Unlock() + + var keys []int64 + for id := range cache.ownChats { keys = append(keys, id) } return keys @@ -86,7 +108,10 @@ func (cache *Cache) GetChat(id int64) (*client.Chat, bool) { cache.chatsLock.Lock() defer cache.chatsLock.Unlock() - chat, ok := cache.chats[id] + chat, ok := cache.ownChats[id] + if !ok { + chat, ok = cache.auxChats[id] + } return chat, ok } @@ -109,20 +134,43 @@ func (cache *Cache) GetStatus(id int64) (*Status, bool) { } // GetCapsVer retrieves capabilities verification string by id if it's present in the cache -func (cache *Cache) GetCapsVer(id int64) (string, bool) { +func (cache *Cache) GetCapsVer(id int64) (string, *stanza.DiscoInfo, bool) { cache.capsVersLock.Lock() defer cache.capsVersLock.Unlock() ver, ok := cache.capsVers[id] - return ver, ok + var di *stanza.DiscoInfo + if ok { + di, ok = cache.verDiscos[ver] + } + return ver, di, ok +} + +// GetVerDisco retrieves disco info by capability verification string if it's present in the cache +func (cache *Cache) GetVerDisco(ver string) (*stanza.DiscoInfo, bool) { + cache.capsVersLock.Lock() + defer cache.capsVersLock.Unlock() + + di, ok := cache.verDiscos[ver] + return di, ok } // SetChat stores a chat in the cache -func (cache *Cache) SetChat(id int64, chat *client.Chat) { +func (cache *Cache) SetChat(id int64, chat *client.Chat, own bool) { cache.chatsLock.Lock() defer cache.chatsLock.Unlock() - cache.chats[id] = chat + if own { + cache.ownChats[id] = chat + // move from aux to own, but not vice versa + // (own: true means that presences for the chat are needed + // for sure, false means just "not necessarily") + if _, ok := cache.auxChats[id]; ok { + delete(cache.auxChats, id) + } + } else { + cache.auxChats[id] = chat + } } // SetUser stores a user in the cache @@ -146,9 +194,20 @@ func (cache *Cache) SetStatus(id int64, show string, status string) { } // SetCapsVer stores a capabilities verification string in the cache -func (cache *Cache) SetCapsVer(id int64, ver string) { +func (cache *Cache) SetCapsVer(id int64, ver string, di *stanza.DiscoInfo) { cache.capsVersLock.Lock() defer cache.capsVersLock.Unlock() cache.capsVers[id] = ver + cache.verDiscos[ver] = di +} + +// Destruct splits a cached status into show, description and type +func (status *Status) Destruct() (show, description, typ string) { + show, description = status.XMPP, status.Description + if show == "unavailable" { + typ = show + show = "" + } + return } diff --git a/telegram/client.go b/telegram/client.go index 71d8125..74876c4 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -10,41 +10,111 @@ import ( "dev.narayana.im/narayana/telegabber/config" "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/telegram/cache" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" "github.com/zelenin/go-tdlib/client" "gosrc.io/xmpp" ) -var logConstants = map[string]int32{ - ":fatal": 0, - ":error": 1, - ":warn": 2, - ":info": 3, - ":debug": 4, - ":verbose": 5, - ":all": 1023, -} - -func stringToLogConstant(c string) int32 { - level, ok := logConstants[c] - if !ok { - level = 0 - } - - return level -} - // DelayedStatus describes an online status expiring on timeout type DelayedStatus struct { TimestampOnline int64 TimestampExpired int64 } +// IntPair holds two int64 values +type IntPair struct { + ChatId int64 + MessageId int64 +} + +type barrier struct { + mu sync.Mutex + open bool + released bool + releaseCh chan struct{} +} + +// Wait blocks until the barrier is released. +func (b *barrier) Wait() { + b.mu.Lock() + + if b.released { + b.mu.Unlock() + return + } + + if !b.open { + b.open = true + b.releaseCh = make(chan struct{}) // Reinitialize the channel + } + b.mu.Unlock() + + // Wait for the barrier to be released + <-b.releaseCh +} + +// Done releases the barrier. +func (b *barrier) Done() { + b.mu.Lock() + defer b.mu.Unlock() + + if b.open { + close(b.releaseCh) // Close the channel to release waiting goroutines + b.open = false // Mark the barrier as closed + } else { + b.released = true + } +} + +// IsPending checks if the barrier is currently being waited +func (b *barrier) IsPending() bool { + b.mu.Lock() + defer b.mu.Unlock() + + return b.open +} + +// NewId stores message ids and timestamps of their additions so old ones can be truncated to save memory +type newId struct { + Id int64 + Ts int64 + lock sync.Mutex + ownLock sync.Mutex + locked bool + fired bool +} + +func newNewId() *newId { + return &newId{Ts: time.Now().Unix()} +} + +func (i *newId) Lock() { + i.ownLock.Lock() + if i.fired { + i.ownLock.Unlock() + return + } + i.locked = true + i.ownLock.Unlock() + i.lock.Lock() +} + +func (i *newId) Unlock() { + i.ownLock.Lock() + if i.locked { + i.lock.Unlock() + i.locked = false + i.fired = true + } + i.ownLock.Unlock() +} + // Client stores the metadata for lazily invoked TDlib instance type Client struct { client *client.Client authorizer *clientAuthorizer - parameters *client.TdlibParameters + parameters *client.SetTdlibParametersRequest options []client.Option me *client.User @@ -56,26 +126,70 @@ type Client struct { cache *cache.Cache online bool + loginWizard *loginWizardMetadata + loginStage LoginStage + + lastAuthorizationStateType string + + outbox map[string]string + editOutbox map[string]string + pinOutbox map[IntPair]chan int64 + DelayedStatuses map[int64]*DelayedStatus DelayedStatusesLock sync.Mutex - locks clientLocks + lastMsgHashes map[int64]uint64 + lastMsgIds map[int64]string + + mucCache map[int64]*MUCState + + uploadingFiles map[int32]string + + LastBotCmdString string + + XmppClientFeatures map[string]*[]string + XmppClientFeaturesLock sync.Mutex + + avatarHashes map[int64]*gateway.HashedAvatar + avatarHashesLock sync.Mutex + + MessageIdChanges map[int64]map[int64]*newId + MessageIdChangesLock sync.Mutex + + locks clientLocks + SendMessageLock sync.Mutex } type clientLocks struct { authorizationReady sync.Mutex chatMessageLocks map[int64]*sync.Mutex resourcesLock sync.Mutex + outboxLock sync.Mutex + mucCacheLock sync.Mutex + editOutboxLock sync.Mutex + pinOutboxLock sync.Mutex + lastMsgHashesLock sync.Mutex + lastMsgIdsLock sync.RWMutex + loginFinish barrier + uploadingFilesLock sync.Mutex + + authorizerReadLock sync.Mutex + authorizerWriteLock sync.Mutex + + loginWizardReadLock sync.Mutex + loginWizardWriteLock sync.Mutex +} + +type loginWizardMetadata struct { + nextStage chan LoginStage + chanBusy bool + commandSent bool } // NewClient instantiates a Telegram App func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component, session *persistence.Session) (*Client, error) { var options []client.Option - options = append(options, client.WithLogVerbosity(&client.SetLogVerbosityLevelRequest{ - NewVerbosityLevel: stringToLogConstant(conf.Loglevel), - })) - if conf.Tdlib.Client.CatchTimeout != 0 { options = append(options, client.WithCatchTimeout( time.Duration(conf.Tdlib.Client.CatchTimeout)*time.Second, @@ -92,7 +206,7 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component datadir = "./sessions/" // ye olde defaute } - parameters := client.TdlibParameters{ + parameters := client.SetTdlibParametersRequest{ UseTestDc: false, DatabaseDirectory: filepath.Join(datadir, jid), @@ -116,17 +230,32 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component } return &Client{ - parameters: ¶meters, - xmpp: component, - jid: jid, - Session: session, - resources: make(map[string]bool), - content: &conf.Content, - cache: cache.NewCache(), - options: options, - DelayedStatuses: make(map[int64]*DelayedStatus), + parameters: ¶meters, + xmpp: component, + jid: jid, + Session: session, + resources: make(map[string]bool), + content: &conf.Content, + cache: cache.NewCache(), + outbox: make(map[string]string), + editOutbox: make(map[string]string), + pinOutbox: make(map[IntPair]chan int64), + mucCache: make(map[int64]*MUCState), + uploadingFiles: make(map[int32]string), + options: options, + DelayedStatuses: make(map[int64]*DelayedStatus), + lastMsgHashes: make(map[int64]uint64), + lastMsgIds: make(map[int64]string), + XmppClientFeatures: make(map[string]*[]string), + avatarHashes: make(map[int64]*gateway.HashedAvatar), + MessageIdChanges: make(map[int64]map[int64]*newId), locks: clientLocks{ chatMessageLocks: make(map[int64]*sync.Mutex), }, }, nil } + +// GetPersistenceSession retrieves the internal session configuration +func (c *Client) GetPersistenceSession() *persistence.Session { + return c.Session +} diff --git a/telegram/client_test.go b/telegram/client_test.go deleted file mode 100644 index 4c757e1..0000000 --- a/telegram/client_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package telegram - -import ( - "testing" -) - -func TestLogInfo(t *testing.T) { - tdlibConstant := stringToLogConstant(":info") - if tdlibConstant != 3 { - t.Errorf("Wrong TDlib constant for info") - } -} - -func TestLogInvalid(t *testing.T) { - tdlibConstant := stringToLogConstant("ziz") - if tdlibConstant != 0 { - t.Errorf("Unknown strings should return fatal loglevel") - } -} diff --git a/telegram/commands.go b/telegram/commands.go index 2a72219..a0e28ba 100644 --- a/telegram/commands.go +++ b/telegram/commands.go @@ -8,18 +8,20 @@ import ( "time" "unicode" + "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/xmpp/gateway" log "github.com/sirupsen/logrus" "github.com/zelenin/go-tdlib/client" ) +const unknownCommand string = "Unknown command" const notEnoughArguments string = "Not enough arguments" -const telegramNotInitialized string = "Telegram connection is not initialized yet" +const TelegramNotInitialized string = "Telegram connection is not initialized yet" +const TelegramAuthDone string = "Authorization is done already" const notOnline string = "Not online" -var permissionsAdmin = client.ChatMemberStatusAdministrator{ - CanBeEdited: true, +var permissionsAdmin = client.ChatAdministratorRights{ CanChangeInfo: true, CanPostMessages: true, CanEditMessages: true, @@ -30,112 +32,59 @@ var permissionsAdmin = client.ChatMemberStatusAdministrator{ CanPromoteMembers: false, } var permissionsMember = client.ChatPermissions{ - CanSendMessages: true, - CanSendMediaMessages: true, + CanSendBasicMessages: true, + CanSendAudios: true, + CanSendDocuments: true, + CanSendPhotos: true, + CanSendVideos: true, + CanSendVideoNotes: true, + CanSendVoiceNotes: true, CanSendPolls: true, CanSendOtherMessages: true, CanAddWebPagePreviews: true, CanChangeInfo: true, CanInviteUsers: true, CanPinMessages: true, + CanManageTopics: true, } var permissionsReadonly = client.ChatPermissions{} -var transportCommands = map[string]command{ - "login": command{"phone", "sign in"}, - "logout": command{"", "sign out"}, - "code": command{"", "check one-time code"}, - "password": command{"", "check 2fa password"}, - "setusername": command{"", "update @username"}, - "setname": command{"first last", "update name"}, - "setbio": command{"", "update about"}, - "setpassword": command{"[old] [new]", "set or remove password"}, - "config": command{"[param] [value]", "view or update configuration options"}, - "report": command{"[chat] [comment]", "report a chat by id or @username"}, - "add": command{"@username", "add @username to your chat list"}, - "join": command{"https://t.me/invite_link", "join to chat via invite link or @publicname"}, - "supergroup": command{"title description", "create new supergroup «title» with «description»"}, - "channel": command{"title description", "create new channel «title» with «description»"}, -} - -var chatCommands = map[string]command{ - "d": command{"[n]", "delete your last message(s)"}, - "s": command{"edited message", "edit your last message"}, - "silent": command{"message", "send a message without sound"}, - "schedule": command{"{online | 2006-01-02T15:04:05 | 15:04:05} message", "schedules a message either to timestamp or to whenever the user goes online"}, - "forward": command{"message_id target_chat", "forwards a message"}, - "add": command{"@username", "add @username to your chat list"}, - "join": command{"https://t.me/invite_link", "join to chat via invite link or @publicname"}, - "group": command{"title", "create groupchat «title» with current user"}, - "supergroup": command{"title description", "create new supergroup «title» with «description»"}, - "channel": command{"title description", "create new channel «title» with «description»"}, - "secret": command{"", "create secretchat with current user"}, - "search": command{"string [limit]", "search in current chat"}, - "history": command{"[limit]", "get last [limit] messages from current chat"}, - "block": command{"", "blacklist current user"}, - "unblock": command{"", "unblacklist current user"}, - "invite": command{"id or @username", "add user to current chat"}, - "link": command{"", "get invite link for current chat"}, - "kick": command{"id or @username", "remove user to current chat"}, - "mute": command{"id or @username [hours]", "mute user in current chat"}, - "unmute": command{"id or @username", "unrestrict user from current chat"}, - "ban": command{"id or @username [hours]", "restrict @username from current chat for [hours] or forever"}, - "unban": command{"id or @username", "unbans @username in current chat (and devotes from admins)"}, - "promote": command{"id or @username [title]", "promote user to admin in current chat"}, - "leave": command{"", "leave current chat"}, - "leave!": command{"", "leave current chat (for owners)"}, - "ttl": command{"", "set secret chat messages TTL before self-destroying (in seconds)"}, - "close": command{"", "close current secret chat"}, - "delete": command{"", "delete current chat from chat list"}, - "members": command{"[query]", "search members [by optional query] in current chat (requires admin rights)"}, -} - var transportConfigurationOptions = map[string]configurationOption{ - "timezone": configurationOption{"", "adjust timezone for Telegram user statuses (example: +02:00)"}, - "keeponline": configurationOption{"", "always keep telegram session online and rely on jabber offline messages (example: true)"}, - "rawmessages": configurationOption{"", "do not add additional info (message id, origin etc.) to incoming messages (example: true)"}, + "timezone": configurationOption{"", "adjust timezone for Telegram user statuses (example: +02:00)"}, + "keeponline": configurationOption{"", "always keep telegram session online and rely on jabber offline messages (true/false)"}, + "rawmessages": configurationOption{"", "do not add additional info (message id, origin etc.) to incoming messages (true/false)"}, + "asciiarrows": configurationOption{"", "replace some Unicode symbols with ASCII alternatives for better compatibility (true/false)"}, + "muc": configurationOption{"", "use MUCs instead of the legacy PM representation of group chats (true/false)"}, + "oobmode": configurationOption{"", "use XEP-0066 (OOB); pros: some modern clients won't show images without it, cons: very restricted, Tkabber would flood with popups (true/false)"}, + "carbons": configurationOption{"", "send carbons to your another clients, will turn on only if supported by the server (true/false)"}, + "hideids": configurationOption{"", "hide message IDs from message info (true/false)"}, + "receipts": configurationOption{"", "if enabled, XMPP read receipts are synced to Telegram, otherwise, messages are marked as read automatically (true/false)"}, + "nativeedits": configurationOption{"", "if possible, edit XMPP messages instead of showing Telegram edits as separate messages (true/false)"}, + "ignoregroupdeletions": configurationOption{"", "suppress message deletion messages in group chats (true/false)"}, } -type command struct { +type configurationOption struct { arguments string description string } -type configurationOption command -type helpType int - -const ( - helpTypeTransport helpType = iota - helpTypeChat -) - -func helpString(ht helpType) string { +func (c *Client) helpString(typ gateway.CommandType, chatId int64) string { var str strings.Builder - var commandMap map[string]command - switch ht { - case helpTypeTransport: - commandMap = transportCommands - case helpTypeChat: - commandMap = chatCommands - } + commandMap := gateway.GetCommands(typ) + chatType, _, _ := c.GetChatType(chatId, true) str.WriteString("Available commands:\n") - for name, command := range commandMap { - str.WriteString("/") - str.WriteString(name) - if command.arguments != "" { - str.WriteString(" ") - str.WriteString(command.arguments) - } - str.WriteString(" — ") - str.WriteString(command.description) - str.WriteString("\n") - } - if ht == helpTypeTransport { - str.WriteString("Configuration options\n") - for name, option := range transportConfigurationOptions { + if typ == gateway.CommandTypeTransport { + gateway.CommandsToHelpString(&str, chatType, gateway.OnlineFilterNotOnline, commandMap) + + str.WriteString("\nOnline-only commands:\n") + gateway.CommandsToHelpString(&str, chatType, gateway.OnlineFilterOnline, commandMap) + + str.WriteString("\nConfiguration options\n") + for _, name := range persistence.ConfigKeys { + option := transportConfigurationOptions[name] str.WriteString(name) str.WriteString(" ") str.WriteString(option.arguments) @@ -143,6 +92,8 @@ func helpString(ht helpType) string { str.WriteString(option.description) str.WriteString("\n") } + } else if typ == gateway.CommandTypeChat { + gateway.CommandsToHelpString(&str, chatType, gateway.OnlineFilterAny, commandMap) } str.WriteString("\nYou may use ! instead of / if it conflicts with internal commands of a client") @@ -172,30 +123,8 @@ func rawCmdArguments(cmdline string, start uint8) string { return "" } -func (c *Client) unsubscribe(chatID int64) error { - return gateway.SendPresence( - c.xmpp, - c.jid, - gateway.SPFrom(strconv.FormatInt(chatID, 10)), - gateway.SPType("unsubscribed"), - ) -} - -func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message) { - for i := len(messages) - 1; i >= 0; i-- { - message := messages[i] - reply, _ := c.getMessageReply(message) - - gateway.SendMessage( - c.jid, - strconv.FormatInt(chatID, 10), - c.formatMessage(0, 0, false, message), - strconv.FormatInt(message.Id, 10), - c.xmpp, - reply, - false, - ) - } +func keyValueString(key, value string) string { + return fmt.Sprintf("%s: %s", key, value) } func (c *Client) usernameOrIDToID(username string) (int64, error) { @@ -219,74 +148,86 @@ func (c *Client) usernameOrIDToID(username string) (int64, error) { } // ProcessTransportCommand executes a command sent directly to the component -// and returns a response -func (c *Client) ProcessTransportCommand(cmdline string, resource string) string { +// and returns a response and execution success result +func (c *Client) ProcessTransportCommand(cmdline string, resource string) (string, bool) { cmd, args := parseCommand(cmdline) + command, ok := gateway.TransportCommands[cmd] + if !ok { + return unknownCommand, false + } + if len(args) < command.RequiredArgs { + return notEnoughArguments, false + } + if command.OnlineOnly && !c.Online() { + return notOnline, false + } + switch cmd { case "login", "code", "password": if cmd == "login" && c.Session.Login != "" { - return "" - } - - if len(args) < 1 { - return notEnoughArguments + return "Phone number already provided, use /cancelauth to start over", false } if cmd == "login" { - wasSessionLoginEmpty := c.Session.Login == "" - c.Session.Login = args[0] + err := c.TryLogin(resource, args[0]) + if err != nil { + return err.Error(), false + } - if wasSessionLoginEmpty && c.authorizer == nil { - go func() { - err := c.Connect(resource) - if err != nil { - log.Error(errors.Wrap(err, "TDlib connection failure")) - } - }() - // a quirk for authorizer to become ready. If it's still not, - // nothing bad: the command just needs to be resent again - time.Sleep(1e5) + c.locks.authorizerWriteLock.Lock() + defer c.locks.authorizerWriteLock.Unlock() + + c.authorizer.PhoneNumber <- args[0] + } else { + c.locks.authorizerWriteLock.Lock() + defer c.locks.authorizerWriteLock.Unlock() + + if c.authorizer == nil { + return TelegramNotInitialized, false + } + + if c.authorizer.isClosed { + return TelegramAuthDone, false + } + + switch cmd { + // check auth code + case "code": + c.authorizer.Code <- args[0] + // check auth password + case "password": + c.authorizer.Password <- args[0] } } - - if c.authorizer == nil { - return telegramNotInitialized - } - - switch cmd { - // sign in - case "login": - c.authorizer.PhoneNumber <- args[0] - // check auth code - case "code": - c.authorizer.Code <- args[0] - // check auth password - case "password": - c.authorizer.Password <- args[0] - } + return "", true // sign out case "logout": - if !c.Online() { - return notOnline - } - - for _, id := range c.cache.ChatsKeys() { - c.unsubscribe(id) + if !c.Online() && !c.locks.loginFinish.IsPending() { + return notOnline, false } _, err := c.client.LogOut() if err != nil { - c.forceClose() - return errors.Wrap(err, "Logout error").Error() + return errors.Wrap(err, "Logout error").Error(), false } + c.unsubscribeFromAll() + c.Session.Login = "" + c.wizardStageOrPrompt(LoginStageCancel, "") + c.online = false + // cleanup + case "cleanup": + c.unsubscribeFromAll() + // cancel auth + case "cancelauth": + if c.Online() { + return "Not allowed when online, use /logout instead", false + } + c.cancelAuth() + return "Cancelled", true // set @username case "setusername": - if !c.Online() { - return notOnline - } - var username string if len(args) > 0 { username = args[0] @@ -296,28 +237,29 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string Username: username, }) if err != nil { - return errors.Wrap(err, "Couldn't set username").Error() + return errors.Wrap(err, "Couldn't set username").Error(), false } // set My Name case "setname": - var firstname string + firstname := args[0] var lastname string - if len(args) > 0 { - firstname = args[0] - } + if firstname == "" { - return "The name should contain at least one character" + return "The name should contain at least one character", false } if len(args) > 1 { lastname = rawCmdArguments(cmdline, 1) } + c.locks.authorizerWriteLock.Lock() if c.authorizer != nil && !c.authorizer.isClosed { c.authorizer.FirstName <- firstname c.authorizer.LastName <- lastname + c.locks.authorizerWriteLock.Unlock() } else { + c.locks.authorizerWriteLock.Unlock() if !c.Online() { - return notOnline + return notOnline, false } _, err := c.client.SetName(&client.SetNameRequest{ @@ -325,32 +267,25 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string LastName: lastname, }) if err != nil { - return errors.Wrap(err, "Couldn't set name").Error() + return errors.Wrap(err, "Couldn't set name").Error(), false } } // set About case "setbio": - if !c.Online() { - return notOnline - } - _, err := c.client.SetBio(&client.SetBioRequest{ Bio: rawCmdArguments(cmdline, 0), }) if err != nil { - return errors.Wrap(err, "Couldn't set bio").Error() + return errors.Wrap(err, "Couldn't set bio").Error(), false } // set password case "setpassword": - if !c.Online() { - return notOnline - } - var oldPassword string var newPassword string - // 0 or 1 argument is ignored and the password is reset - if len(args) > 1 { + if len(args) > 0 { oldPassword = args[0] + } + if len(args) > 1 { newPassword = args[1] } _, err := c.client.SetPassword(&client.SetPasswordRequest{ @@ -358,56 +293,73 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string NewPassword: newPassword, }) if err != nil { - return errors.Wrap(err, "Couldn't set password").Error() + return errors.Wrap(err, "Couldn't set password").Error(), false } case "config": if len(args) > 1 { - if !gateway.MessageOutgoingPermission && args[0] == "carbons" && args[1] == "true" { - return "The server did not allow to enable carbons" + if gateway.MessageOutgoingPermissionVersion == 0 && args[0] == "carbons" && args[1] == "true" { + return "The server did not allow to enable carbons", false } value, err := c.Session.Set(args[0], args[1]) if err != nil { - return err.Error() + return err.Error(), false + } + if args[0] == "muc" { + switch args[1] { + case "true": + go c.MigrateToMUCs() + case "false": + go c.MigrateFromMUCs() + } + if c.loginStage == LoginStageMUC { + c.wizardStageOrPrompt(LoginStageSuccess, "") + } } gateway.DirtySessions = true - return fmt.Sprintf("%s set to %s", args[0], value) + return fmt.Sprintf("%s set to %s", args[0], value), true } else if len(args) > 0 { value, err := c.Session.Get(args[0]) if err != nil { - return err.Error() + return err.Error(), false } - return fmt.Sprintf("%s is set to %s", args[0], value) + return fmt.Sprintf("%s is set to %s", args[0], value), true } var entries []string - for key, value := range c.Session.ToMap() { + for _, key := range persistence.ConfigKeys { + value, err := c.Session.Get(key) + if err != nil { + log.Errorf("Achtung! Programming error in sessions with key %v", key) + continue + } entries = append(entries, fmt.Sprintf("%s is set to %s", key, value)) } - return strings.Join(entries, "\n") + return strings.Join(entries, "\n"), true + case "status": + return fmt.Sprintf("Login stage: %v\nLogin: %v", c.loginStage, c.Session.Login), true case "report": - if len(args) < 2 { - return "Not enough arguments" - } - - contact, _, err := c.GetContactByUsername(args[0]) + contact, _, err := c.GetContactByUsername(args[0], false) if err != nil { - return err.Error() + return err.Error(), false + } + if contact == nil { + return "Contact not found", false } text := rawCmdArguments(cmdline, 1) _, err = c.client.ReportChat(&client.ReportChatRequest{ ChatId: contact.Id, - Reason: &client.ChatReportReasonCustom{}, + Reason: &client.ReportReasonCustom{}, Text: text, }) if err != nil { - return err.Error() + return err.Error(), false } else { - return "Reported" + return "Reported", true } case "add": return c.cmdAdd(args) @@ -418,32 +370,87 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string case "channel": return c.cmdChannel(args, cmdline) case "help": - return helpString(helpTypeTransport) + return c.helpString(gateway.CommandTypeTransport, 0), true + case "preset": + cancelNextStage := false + + defer func() { + if !cancelNextStage { + go c.promptMUC() + } + }() + switch args[0] { + case "modern", "classic": + for _, row := range persistence.Presets[args[0]] { + _, err := c.Session.Set(row[0], row[1]) + if err != nil { + return err.Error(), false + } + } + gateway.DirtySessions = true + return fmt.Sprintf("Applied preset %s", args[0]), true + default: + cancelNextStage = true + return "Invalid argument. Allowed ones are " + gateway.TransportCommands["preset"].Arguments[0], false + } + case "pass": + switch c.loginStage { + case LoginStagePreset: + go c.promptMUC() + case LoginStageMUC: + c.wizardStageOrPrompt(LoginStageSuccess, "") + default: + return "Not applicable here", false + } + case "finish": + switch c.loginStage { + case LoginStagePreset, LoginStageMUC: + c.wizardStageOrPrompt(LoginStageSuccess, "") + default: + return "Not applicable here", false + } } - return "" + return "", true +} + +func (c *Client) promptMUC() { + c.wizardStageOrPrompt(LoginStageMUC, "Enable MUCs? Telegabber still supports the legacy approach of mapping Telegram group chats to personal messages in XMPP which might be suitable for some cases like logging or reliable participation in all group chats. Use `/config muc {true|false}` or /finish") } // ProcessChatCommand executes a command sent in a mapped chat -// and returns a response and the status of command support -func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) { - if !c.Online() { - return notOnline, true +// and returns a response, the status of command support and the execution success result +func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool, bool) { + cmd, args := parseCommand(cmdline) + command, ok := gateway.ChatCommands[cmd] + if !ok { + return unknownCommand, false, false + } + if len(args) < command.RequiredArgs { + return notEnoughArguments, true, false + } + if command.OnlineOnly && !c.Online() { + return notOnline, true, false + } + + + chatType, _, chatTypeErr := c.GetChatType(chatID, true) + if chatTypeErr == nil && !gateway.IsCommandForChatType(command, chatType) { + return "Not applicable for this chat type", true, false } - cmd, args := parseCommand(cmdline) switch cmd { // delete message case "d": if c.me == nil { - return "@me is not initialized", true + return "@me is not initialized", true, false } var limit int32 if len(args) > 0 { limit64, err := strconv.ParseInt(args[0], 10, 32) if err != nil { - return err.Error(), true + return err.Error(), true, false } limit = int32(limit64) } else { @@ -452,7 +459,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) messages, err := c.getLastMessages(chatID, "", c.me.Id, limit) if err != nil { - return err.Error(), true + return err.Error(), true, false } log.Debugf("pre-deletion query: %#v %#v", messages, messages.Messages) @@ -469,48 +476,46 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) Revoke: true, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } + return "", true, true // edit message case "s": if c.me == nil { - return "@me is not initialized", true - } - if len(args) < 1 { - return "Not enough arguments", true + return "@me is not initialized", true, false } messages, err := c.getLastMessages(chatID, "", c.me.Id, 1) if err != nil { - return err.Error(), true + return err.Error(), true, false } if len(messages.Messages) == 0 { - return "No last message", true + return "No last message", true, false } message := messages.Messages[0] if message == nil { - return "Last message is empty", true + return "Last message is empty", true, false } - content := c.ProcessOutgoingMessage(0, rawCmdArguments(cmdline, 0), "", 0) + content := c.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 0)) if content != nil { - c.client.EditMessageText(&client.EditMessageTextRequest{ + _, err = c.client.EditMessageText(&client.EditMessageTextRequest{ ChatId: chatID, MessageId: message.Id, InputMessageContent: content, }) + if err != nil { + return "Message editing error", true, false + } } else { - return "Message processing error", true + return "Message processing error", true, false } + return "", true, true // send without sound case "silent": - if len(args) < 1 { - return "Not enough arguments", true - } - - content := c.ProcessOutgoingMessage(0, rawCmdArguments(cmdline, 0), "", 0) + content := c.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 0)) if content != nil { _, err := c.client.SendMessage(&client.SendMessageRequest{ @@ -521,17 +526,13 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) }, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } } else { - return "Message processing error", true + return "Message processing error", true, false } // schedule a message to timestamp or to going online case "schedule": - if len(args) < 2 { - return "Not enough arguments", true - } - var state client.MessageSchedulingState var result string due := args[0] @@ -539,11 +540,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) state = &client.MessageSchedulingStateSendWhenOnline{} result = due } else { - if c.Session.Timezone == "" { - due += "Z" - } else { - due += c.Session.Timezone - } + due += c.GetTZD() switch 0 { default: @@ -585,11 +582,11 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) break } - return "Invalid schedule time specifier", true + return "Invalid schedule time specifier", true, false } } - content := c.ProcessOutgoingMessage(0, rawCmdArguments(cmdline, 1), "", 0) + content := c.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 1)) if content != nil { _, err := c.client.SendMessage(&client.SendMessageRequest{ @@ -600,27 +597,39 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) }, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - return "Scheduled to " + result, true + return "Scheduled to " + result, true, true } else { - return "Message processing error", true + return "Message processing error", true, false } + // sends a raw non-interpreted message + case "raw": + content := c.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 0)) + + if content != nil { + _, err := c.client.SendMessage(&client.SendMessageRequest{ + ChatId: chatID, + InputMessageContent: content, + }) + if err != nil { + return err.Error(), true, false + } + } else { + return "Message processing error", true, false + } + return "", true, true // forward a message to chat case "forward": - if len(args) < 2 { - return notEnoughArguments, true - } - messageId, err := strconv.ParseInt(args[0], 10, 64) if err != nil { - return "Cannot parse message ID", true + return "Cannot parse message ID", true, false } targetChatParts := strings.Split(args[1], "@") // full JIDs are supported too targetChatId, err := strconv.ParseInt(targetChatParts[0], 10, 64) if err != nil { - return "Cannot parse target chat ID", true + return "Cannot parse target chat ID", true, false } messages, err := c.client.ForwardMessages(&client.ForwardMessagesRequest{ @@ -629,73 +638,87 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) MessageIds: []int64{messageId}, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } if messages != nil && messages.Messages != nil { for _, message := range messages.Messages { c.ProcessIncomingMessage(targetChatId, message) } } + // print vCard + case "vcard": + info, err := c.GetVcardInfo(chatID) + if err != nil { + return err.Error(), true, false + } + _, link := c.PermastoreFile(info.Photo, true) + entries := []string{ + keyValueString("Chat title", info.Fn), + keyValueString("Photo", link), + keyValueString("Usernames", c.usernamesToString(info.Nicknames)), + keyValueString("Full name", info.Given+" "+info.Family), + keyValueString("Phone number", info.Tel), + } + return strings.Join(entries, "\n"), true, true // add @contact case "add": - return c.cmdAdd(args), true + response, success := c.cmdAdd(args) + return response, true, success // join https://t.me/publichat or @publicchat case "join": - return c.cmdJoin(args), true + response, success := c.cmdJoin(args) + return response, true, success // create new supergroup case "supergroup": - return c.cmdSupergroup(args, cmdline), true + response, success := c.cmdSupergroup(args, cmdline) + return response, true, success // create new channel case "channel": - return c.cmdChannel(args, cmdline), true + response, success := c.cmdChannel(args, cmdline) + return response, true, success // create new secret chat with current user case "secret": _, err := c.client.CreateNewSecretChat(&client.CreateNewSecretChatRequest{ UserId: chatID, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // create group chat with current user case "group": - if len(args) < 1 { - return notEnoughArguments, true - } - _, err := c.client.CreateNewBasicGroupChat(&client.CreateNewBasicGroupChatRequest{ UserIds: []int64{chatID}, Title: args[0], }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // blacklists current user case "block": - _, err := c.client.ToggleMessageSenderIsBlocked(&client.ToggleMessageSenderIsBlockedRequest{ + _, err := c.client.SetMessageSenderBlockList(&client.SetMessageSenderBlockListRequest{ SenderId: &client.MessageSenderUser{UserId: chatID}, - IsBlocked: true, + BlockList: &client.BlockListMain{}, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // unblacklists current user case "unblock": - _, err := c.client.ToggleMessageSenderIsBlocked(&client.ToggleMessageSenderIsBlockedRequest{ + _, err := c.client.SetMessageSenderBlockList(&client.SetMessageSenderBlockListRequest{ SenderId: &client.MessageSenderUser{UserId: chatID}, - IsBlocked: false, + BlockList: nil, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // invite @username to current groupchat case "invite": - if len(args) < 1 { - return notEnoughArguments, true - } - - contact, _, err := c.GetContactByUsername(args[0]) + contact, _, err := c.GetContactByUsername(args[0], false) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } _, err = c.client.AddChatMember(&client.AddChatMemberRequest{ @@ -704,7 +727,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) ForwardLimit: 100, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // get link to current chat case "link": @@ -712,155 +735,128 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) ChatId: chatID, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - return link.InviteLink, true + return link.InviteLink, true, true // kick @username from current group chat case "kick": - if len(args) < 1 { - return notEnoughArguments, true + contact, _, err := c.GetContactByUsername(args[0], false) + if err != nil { + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } - contact, _, err := c.GetContactByUsername(args[0]) + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusKicked, 0, "", "") if err != nil { - return err.Error(), true + return err.Error(), true, false } - - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &client.ChatMemberStatusLeft{}, - }) - if err != nil { - return err.Error(), true - } - // mute @username [n hours] + // mute [@username [n hours]] case "mute": - if len(args) < 1 { - return notEnoughArguments, true - } - - contact, _, err := c.GetContactByUsername(args[0]) - if err != nil { - return err.Error(), true - } - - var hours int64 - if len(args) > 1 { - hours, err = strconv.ParseInt(args[1], 10, 32) + if len(args) > 0 { + contact, _, err := c.GetContactByUsername(args[0], false) if err != nil { - return "Invalid number of hours", true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } - } - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &client.ChatMemberStatusRestricted{ - IsMember: true, - RestrictedUntilDate: c.formatBantime(hours), - Permissions: &permissionsReadonly, - }, - }) - if err != nil { - return err.Error(), true + var hours int64 + if len(args) > 1 { + hours, err = strconv.ParseInt(args[1], 10, 32) + if err != nil { + return "Invalid number of hours", true, false + } + } + + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusMuted, hours, "", "") + if err != nil { + return err.Error(), true, false + } + } else { + if !c.Session.IgnoreChat(chatID) { + return "Chat is already ignored", true, false + } + gateway.DirtySessions = true } - // unmute @username + // unmute [@username] case "unmute": - if len(args) < 1 { - return notEnoughArguments, true - } + if len(args) > 0 { + contact, _, err := c.GetContactByUsername(args[0], false) + if err != nil { + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false + } - contact, _, err := c.GetContactByUsername(args[0]) - if err != nil { - return err.Error(), true - } - - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &client.ChatMemberStatusRestricted{ - IsMember: true, - RestrictedUntilDate: 0, - Permissions: &permissionsMember, - }, - }) - if err != nil { - return err.Error(), true + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusUnmuted, 0, "", "") + if err != nil { + return err.Error(), true, false + } + } else { + if !c.Session.UnignoreChat(chatID) { + return "Chat wasn't ignored", true, false + } + gateway.DirtySessions = true } // ban @username from current chat [for N hours] case "ban": - if len(args) < 1 { - return notEnoughArguments, true - } - - contact, _, err := c.GetContactByUsername(args[0]) + contact, _, err := c.GetContactByUsername(args[0], false) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } var hours int64 if len(args) > 1 { hours, err = strconv.ParseInt(args[1], 10, 32) if err != nil { - return "Invalid number of hours", true + return "Invalid number of hours", true, false } } - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &client.ChatMemberStatusBanned{ - BannedUntilDate: c.formatBantime(hours), - }, - }) + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusBanned, hours, "", "") if err != nil { - return err.Error(), true + return err.Error(), true, false } // unban @username case "unban": - if len(args) < 1 { - return notEnoughArguments, true + contact, _, err := c.GetContactByUsername(args[0], false) + if err != nil { + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } - contact, _, err := c.GetContactByUsername(args[0]) + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusUnbanned, 0, "", "") if err != nil { - return err.Error(), true - } - - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &client.ChatMemberStatusMember{}, - }) - if err != nil { - return err.Error(), true + return err.Error(), true, false } // promote @username to admin case "promote": - if len(args) < 1 { - return notEnoughArguments, true + contact, _, err := c.GetContactByUsername(args[0], false) + if err != nil { + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } - contact, _, err := c.GetContactByUsername(args[0]) - if err != nil { - return err.Error(), true + var customTitle string + if len(args) > 1 { + customTitle = args[1] } // clone the permissions - status := permissionsAdmin - - if len(args) > 1 { - status.CustomTitle = args[1] - } - - _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ - ChatId: chatID, - MemberId: &client.MessageSenderUser{UserId: contact.Id}, - Status: &status, - }) + err = c.SetChatMemberStatus(chatID, contact.Id, ChatMemberStatusPromoted, 0, customTitle, "") if err != nil { - return err.Error(), true + return err.Error(), true, false } // leave current chat case "leave": @@ -868,25 +864,23 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) ChatId: chatID, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - err = c.unsubscribe(chatID) + err = c.leaveChat(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } // leave current chat (for owners) case "leave!": - _, err := c.client.DeleteChat(&client.DeleteChatRequest{ - ChatId: chatID, - }) + err := c.DeleteChat(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } - err = c.unsubscribe(chatID) + err = c.leaveChat(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } // set TTL case "ttl": @@ -895,22 +889,25 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) if len(args) > 0 { ttl, err = strconv.ParseInt(args[0], 10, 32) if err != nil { - return "Invalid TTL", true + return "Invalid TTL", true, false } } - _, err = c.client.SetChatMessageTtl(&client.SetChatMessageTtlRequest{ - ChatId: chatID, - Ttl: int32(ttl), + _, err = c.client.SetChatMessageAutoDeleteTime(&client.SetChatMessageAutoDeleteTimeRequest{ + ChatId: chatID, + MessageAutoDeleteTime: int32(ttl), }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // close secret chat case "close": - chat, _, err := c.GetContactByID(chatID, nil) + chat, _, err := c.GetContactByID(chatID, nil, true) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if chat == nil { + return "Chat not found", true, false } chatType := chat.Type.ChatTypeType() @@ -920,12 +917,12 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) SecretChatId: chatTypeSecret.SecretChatId, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - err = c.unsubscribe(chatID) + err = c.leaveChat(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } } // delete current chat @@ -936,12 +933,12 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) Revoke: true, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - err = c.unsubscribe(chatID) + err = c.leaveChat(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } // message search case "search": @@ -960,10 +957,11 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) messages, err := c.getLastMessages(chatID, query, 0, limit) if err != nil { - return err.Error(), true + return err.Error(), true, false } - c.sendMessagesReverse(chatID, messages.Messages) + c.sendMessagesReverse(chatID, messages.Messages, true, "") + return "", true, true // get latest entries from history case "history": var limit int32 = 10 @@ -974,32 +972,12 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) } } - var newMessages *client.Messages - var messages []*client.Message - var err error - var fromId int64 - for _ = range make([]struct{}, limit) { // safety limit - if len(messages) > 0 { - fromId = messages[len(messages)-1].Id - } - - newMessages, err = c.client.GetChatHistory(&client.GetChatHistoryRequest{ - ChatId: chatID, - FromMessageId: fromId, - Limit: limit, - }) - if err != nil { - return err.Error(), true - } - - messages = append(messages, newMessages.Messages...) - - if len(newMessages.Messages) == 0 || len(messages) >= int(limit) { - break - } + messages, err := c.getNLastMessages(chatID, NewMessageLimitMessages(limit)) + if err != nil { + return err.Error(), true, false } - - c.sendMessagesReverse(chatID, messages) + c.sendMessagesReverse(chatID, messages, true, "") + return "", true, true // chat members case "members": var query string @@ -1007,126 +985,97 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) query = args[0] } - members, err := c.client.SearchChatMembers(&client.SearchChatMembersRequest{ - ChatId: chatID, - Limit: 9999, - Query: query, - Filter: &client.ChatMembersFilterMembers{}, - }) + members, err := c.GetChatMembers(chatID, false, query, MembersListMembers) if err != nil { - return err.Error(), true + return err.Error(), true, false } var entries []string - for _, member := range members.Members { - var senderId int64 - switch member.MemberId.MessageSenderType() { - case client.TypeMessageSenderUser: - memberUser, _ := member.MemberId.(*client.MessageSenderUser) - senderId = memberUser.UserId - case client.TypeMessageSenderChat: - memberChat, _ := member.MemberId.(*client.MessageSenderChat) - senderId = memberChat.ChatId - } + for _, member := range members { + senderId := c.GetSenderId(member.MemberId) entries = append(entries, fmt.Sprintf( "%v | role: %v", - c.formatContact(senderId), + c.FormatContact(senderId), member.Status.ChatMemberStatusType(), )) } - return strings.Join(entries, "\n"), true + return strings.Join(entries, "\n"), true, true case "help": - return helpString(helpTypeChat), true + return c.helpString(gateway.CommandTypeChat, chatID), true, true default: - return "", false + return "", false, false } - return "", true + return "Success", true, true } -func (c *Client) cmdAdd(args []string) string { - if len(args) < 1 { - return notEnoughArguments - } - +func (c *Client) cmdAdd(args []string) (string, bool) { chat, err := c.client.SearchPublicChat(&client.SearchPublicChatRequest{ Username: args[0], }) if err != nil { - return err.Error() + return err.Error(), false } if chat == nil { - return "No error, but chat is nil" + return "No error, but chat is nil", false } - c.subscribeToID(chat.Id, chat) + c.subscribeToID(chat.Id, chat, true) - return "" + return "Subscription sent", true } -func (c *Client) cmdJoin(args []string) string { - if len(args) < 1 { - return notEnoughArguments - } - +func (c *Client) cmdJoin(args []string) (string, bool) { if strings.HasPrefix(args[0], "@") { chat, err := c.client.SearchPublicChat(&client.SearchPublicChatRequest{ Username: args[0], }) if err != nil { - return err.Error() + return err.Error(), false } if chat == nil { - return "No error, but chat is nil" + return "No error, but chat is nil", false } _, err = c.client.JoinChat(&client.JoinChatRequest{ ChatId: chat.Id, }) if err != nil { - return err.Error() + return err.Error(), false } } else { _, err := c.client.JoinChatByInviteLink(&client.JoinChatByInviteLinkRequest{ InviteLink: args[0], }) if err != nil { - return err.Error() + return err.Error(), false } } - return "" + return "Joined", true } -func (c *Client) cmdSupergroup(args []string, cmdline string) string { - if len(args) < 1 { - return notEnoughArguments - } - +func (c *Client) cmdSupergroup(args []string, cmdline string) (string, bool) { _, err := c.client.CreateNewSupergroupChat(&client.CreateNewSupergroupChatRequest{ Title: args[0], Description: rawCmdArguments(cmdline, 1), }) if err != nil { - return err.Error() + return err.Error(), false } - return "" + return "Created", true } -func (c *Client) cmdChannel(args []string, cmdline string) string { - if len(args) < 1 { - return notEnoughArguments - } - +func (c *Client) cmdChannel(args []string, cmdline string) (string, bool) { _, err := c.client.CreateNewSupergroupChat(&client.CreateNewSupergroupChatRequest{ Title: args[0], Description: rawCmdArguments(cmdline, 1), IsChannel: true, }) if err != nil { - return err.Error() + return err.Error(), false } - return "" + return "Created", true } diff --git a/telegram/connect.go b/telegram/connect.go index 2633980..3909d59 100644 --- a/telegram/connect.go +++ b/telegram/connect.go @@ -2,7 +2,7 @@ package telegram import ( "github.com/pkg/errors" - "strconv" + "time" "dev.narayana.im/narayana/telegabber/xmpp/gateway" @@ -13,7 +13,7 @@ import ( const chatsLimit int32 = 999 type clientAuthorizer struct { - TdlibParameters chan *client.TdlibParameters + TdlibParameters chan *client.SetTdlibParametersRequest PhoneNumber chan string Code chan string State chan client.AuthorizationState @@ -24,17 +24,14 @@ type clientAuthorizer struct { } func (stateHandler *clientAuthorizer) Handle(c *client.Client, state client.AuthorizationState) error { + if stateHandler.isClosed { + return errors.New("Channel is closed") + } stateHandler.State <- state switch state.AuthorizationStateType() { case client.TypeAuthorizationStateWaitTdlibParameters: - _, err := c.SetTdlibParameters(&client.SetTdlibParametersRequest{ - Parameters: <-stateHandler.TdlibParameters, - }) - return err - - case client.TypeAuthorizationStateWaitEncryptionKey: - _, err := c.CheckDatabaseEncryptionKey(&client.CheckDatabaseEncryptionKeyRequest{}) + _, err := c.SetTdlibParameters(<-stateHandler.TdlibParameters) return err case client.TypeAuthorizationStateWaitPhoneNumber: @@ -71,10 +68,10 @@ func (stateHandler *clientAuthorizer) Handle(c *client.Client, state client.Auth return nil case client.TypeAuthorizationStateLoggingOut: - return client.ErrNotSupportedAuthorizationState + return nil case client.TypeAuthorizationStateClosing: - return client.ErrNotSupportedAuthorizationState + return nil case client.TypeAuthorizationStateClosed: return client.ErrNotSupportedAuthorizationState @@ -84,6 +81,9 @@ func (stateHandler *clientAuthorizer) Handle(c *client.Client, state client.Auth } func (stateHandler *clientAuthorizer) Close() { + if stateHandler.isClosed { + return + } stateHandler.isClosed = true close(stateHandler.TdlibParameters) close(stateHandler.PhoneNumber) @@ -95,7 +95,7 @@ func (stateHandler *clientAuthorizer) Close() { } // Connect starts TDlib connection -func (c *Client) Connect(resource string) error { +func (c *Client) Connect(resource string, wasSessionLoginEmpty bool) error { log.Warn("Attempting to connect to Telegram network...") // avoid conflict if another authorization is pending already @@ -109,8 +109,9 @@ func (c *Client) Connect(resource string) error { log.Warn("Connecting to Telegram network...") + c.locks.authorizerWriteLock.Lock() c.authorizer = &clientAuthorizer{ - TdlibParameters: make(chan *client.TdlibParameters, 1), + TdlibParameters: make(chan *client.SetTdlibParametersRequest, 1), PhoneNumber: make(chan string, 1), Code: make(chan string, 1), State: make(chan client.AuthorizationState, 10), @@ -120,12 +121,15 @@ func (c *Client) Connect(resource string) error { } go c.interactor() + log.Warn("Interactor launched") c.authorizer.TdlibParameters <- c.parameters + c.locks.authorizerWriteLock.Unlock() tdlibClient, err := client.NewClient(c.authorizer, c.options...) if err != nil { c.locks.authorizationReady.Unlock() + c.wizardStageOrPrompt(LoginStageCancel, "") return errors.Wrap(err, "Couldn't initialize a Telegram client instance") } @@ -141,27 +145,81 @@ func (c *Client) Connect(resource string) error { c.Session.Login = c.me.PhoneNumber } + log.Debug("waiting for loginFinish") + c.locks.loginFinish.Wait() + go c.updateHandler() + log.Warn("Going online") c.online = true c.locks.authorizationReady.Unlock() c.addResource(resource) go func() { - _, err = c.client.GetChats(&client.GetChatsRequest{ + chats, err := c.client.GetChats(&client.GetChatsRequest{ Limit: chatsLimit, }) if err != nil { log.Errorf("Could not retrieve chats: %v", err) + } else { + log.Infof("Obtained ≈%v chats for initialization", chats.TotalCount) } - gateway.SendPresence(c.xmpp, c.jid, gateway.SPType("subscribe")) - gateway.SendPresence(c.xmpp, c.jid, gateway.SPType("subscribed")) - gateway.SendPresence(c.xmpp, c.jid, gateway.SPStatus("Logged in as: "+c.Session.Login)) + gateway.SubscribeToTransport(c.xmpp, c.jid) + loggedInString := "Logged in as: " + c.Session.Login + c.sendPresence(gateway.SPStatus(loggedInString)) + if wasSessionLoginEmpty { + for _, jid := range c.GetCarbonFullJids(true, "", false) { + gateway.SendServiceMessage(jid, loggedInString, c.xmpp) + } + } }() + log.Warn("Client connected!") return nil } +func (c *Client) TryLogin(resource string, login string) error { + wasSessionLoginEmpty := c.Session.Login == "" + c.Session.Login = login + + if wasSessionLoginEmpty && c.authorizer == nil { + go func() { + err := c.Connect(resource, wasSessionLoginEmpty) + if err != nil { + log.Error(errors.Wrap(err, "TDlib connection failure")) + } + }() + // a quirk for authorizer to become ready. If it's still not, + // nothing bad: just re-login again + time.Sleep(1e5) + } + + c.locks.authorizerWriteLock.Lock() + defer c.locks.authorizerWriteLock.Unlock() + + if c.authorizer == nil { + return errors.New(TelegramNotInitialized) + } + + if c.authorizer.isClosed { + return errors.New(TelegramAuthDone) + } + + return nil +} + +func (c *Client) SetPhoneNumber(login string) error { + c.locks.authorizerWriteLock.Lock() + defer c.locks.authorizerWriteLock.Unlock() + + if c.authorizer == nil || c.authorizer.isClosed { + return errors.New("Authorization not needed") + } + + c.authorizer.PhoneNumber <- login + return nil +} + // Disconnect drops TDlib connection and // returns the flag indicating if disconnecting is permitted func (c *Client) Disconnect(resource string, quit bool) bool { @@ -182,36 +240,46 @@ func (c *Client) Disconnect(resource string, quit bool) bool { log.Warn("Disconnecting from Telegram network...") // we're offline (unsubscribe if logout) - for _, id := range c.cache.ChatsKeys() { - gateway.SendPresence( - c.xmpp, - c.jid, - gateway.SPFrom(strconv.FormatInt(id, 10)), - gateway.SPType("unavailable"), - ) + for _, id := range c.cache.OwnChatsKeys() { + args := gateway.SimplePresence(id, "unavailable") + c.sendPresence(args...) } - _, err := c.client.Close() - if err != nil { - log.Errorf("Couldn't close the Telegram instance: %v; %#v", err, c) + if c.Session.MUC { + c.locks.mucCacheLock.Lock() + for chatID := range c.mucCache { + c.kickMeFromMUC(chatID, []uint16{110, 332}, false, c.mucCache[chatID]) + } + c.locks.mucCacheLock.Unlock() } - c.forceClose() + + c.close() return true } func (c *Client) interactor() { + wasSessionLoginEmpty := c.Session.Login == "" for { + c.locks.authorizerReadLock.Lock() + if c.authorizer == nil { + log.Warn("Authorizer is lost, halting the interactor") + c.locks.authorizerReadLock.Unlock() + return + } state, ok := <-c.authorizer.State if !ok { log.Warn("Interactor is disconnected") - return + c.locks.authorizerReadLock.Unlock() + break } stateType := state.AuthorizationStateType() log.Infof("Telegram authorization state: %#v", stateType) log.Debugf("%#v", state) + c.lastAuthorizationStateType = stateType + switch stateType { // stage 0: set login case client.TypeAuthorizationStateWaitPhoneNumber: @@ -219,12 +287,12 @@ func (c *Client) interactor() { if c.Session.Login != "" { c.authorizer.PhoneNumber <- c.Session.Login } else { - gateway.SendServiceMessage(c.jid, "Please, enter your Telegram login via /login 12345", c.xmpp) + c.wizardStageOrPrompt(LoginStageLogin, "Please, enter your Telegram login via /login 12345, or use the Login Wizard via Ad-Hoc commands") } // stage 1: wait for auth code case client.TypeAuthorizationStateWaitCode: log.Warn("Waiting for authorization code...") - gateway.SendServiceMessage(c.jid, "Please, enter authorization code via /code 12345", c.xmpp) + c.wizardStageOrPrompt(LoginStageCode, "Please, enter authorization code via /code 12345") // stage 1b: wait for registration case client.TypeAuthorizationStateWaitRegistration: log.Warn("Waiting for full name...") @@ -232,16 +300,53 @@ func (c *Client) interactor() { // stage 2: wait for 2fa case client.TypeAuthorizationStateWaitPassword: log.Warn("Waiting for 2FA password...") - gateway.SendServiceMessage(c.jid, "Please, enter 2FA passphrase via /password 12345", c.xmpp) + c.wizardStageOrPrompt(LoginStagePassword, "Please, enter 2FA passphrase via /password 12345") + } + c.locks.authorizerReadLock.Unlock() + } + if c.loginStage != LoginStageCancel { + if wasSessionLoginEmpty { + c.wizardStageOrPrompt(LoginStagePreset, "Do you want to use a config preset? `/preset modern` enables brand new XMPP features, `/preset classic` targets legacy clients stuck in 00s. /pass proceeds to the next stage.") + } else { + c.wizardStageOrPrompt(LoginStageSuccess, "") } } } func (c *Client) forceClose() { + c.locks.authorizerReadLock.Lock() + c.locks.authorizerWriteLock.Lock() + defer c.locks.authorizerReadLock.Unlock() + defer c.locks.authorizerWriteLock.Unlock() + c.online = false c.authorizer = nil } +func (c *Client) close() { + c.locks.authorizerWriteLock.Lock() + if c.authorizer != nil && !c.authorizer.isClosed { + log.Debug("Closing authorizer") + c.authorizer.Close() + } + c.locks.authorizerWriteLock.Unlock() + + if c.client != nil { + _, err := c.client.Close() + if err != nil { + log.Errorf("Couldn't close the Telegram instance: %v; %#v", err, c) + } + } + c.forceClose() +} + +func (c *Client) cancelAuth() { + c.wizardStageOrPrompt(LoginStageCancel, "") + c.StopLoginWizard() + c.close() + c.Session.Login = "" +} + // Online checks if the updates listener is alive func (c *Client) Online() bool { return c.online diff --git a/telegram/formatter/formatter.go b/telegram/formatter/formatter.go index 740fa09..2c06ac4 100644 --- a/telegram/formatter/formatter.go +++ b/telegram/formatter/formatter.go @@ -2,21 +2,39 @@ package formatter import ( "sort" + "strings" "unicode" log "github.com/sirupsen/logrus" "github.com/zelenin/go-tdlib/client" ) -// Insertion is a piece of text in given position -type Insertion struct { - Offset int32 - Runes []rune +type insertionType int + +const ( + insertionOpening insertionType = iota + insertionClosing + insertionUnpaired +) + +type MarkupModeType int + +const ( + MarkupModeXEP0393 MarkupModeType = iota + MarkupModeMarkdown +) + +// insertion is a piece of text in given position +type insertion struct { + Offset int32 + Runes []rune + Type insertionType + Replacing bool } -// InsertionStack contains the sequence of insertions +// insertionStack contains the sequence of insertions // from the start or from the end -type InsertionStack []*Insertion +type insertionStack []*insertion var boldRunesMarkdown = []rune("**") var boldRunesXEP0393 = []rune("*") @@ -24,13 +42,18 @@ var italicRunes = []rune("_") var strikeRunesMarkdown = []rune("~~") var strikeRunesXEP0393 = []rune("~") var codeRunes = []rune("`") -var preRuneStart = []rune("```\n") -var preRuneEnd = []rune("\n```") +var preRunesStart = []rune("```\n") +var preRunesEnd = []rune("\n```") +var quoteRunes = []rune("> ") +var newlineRunes = []rune("\n") +var doubleNewlineRunes = []rune("\n\n") +var newlineCode = rune(0x0000000a) +var bmpCeil = rune(0x0000ffff) // rebalance pumps all the values until the given offset to current stack (growing // from start) from given stack (growing from end); should be called // before any insertions to the current stack at the given offset -func (s InsertionStack) rebalance(s2 InsertionStack, offset int32) (InsertionStack, InsertionStack) { +func (s insertionStack) rebalance(s2 insertionStack, offset int32) (insertionStack, insertionStack) { for len(s2) > 0 && s2[len(s2)-1].Offset <= offset { s = append(s, s2[len(s2)-1]) s2 = s2[:len(s2)-1] @@ -41,10 +64,10 @@ func (s InsertionStack) rebalance(s2 InsertionStack, offset int32) (InsertionSta // NewIterator is a second order function that sequentially scans and returns // stack elements; starts returning nil when elements are ended -func (s InsertionStack) NewIterator() func() *Insertion { +func (s insertionStack) NewIterator() func() *insertion { i := -1 - return func() *Insertion { + return func() *insertion { i++ if i < len(s) { return s[i] @@ -53,6 +76,14 @@ func (s InsertionStack) NewIterator() func() *Insertion { } } +func isReplacing(entity *client.TextEntity) bool { + switch entity.Type.TextEntityTypeType() { + case client.TypeTextEntityTypeMention, client.TypeTextEntityTypeMentionName: + return true + } + return false +} + // SortEntities arranges the entities in traversal-ready order func SortEntities(entities []*client.TextEntity) []*client.TextEntity { sortedEntities := make([]*client.TextEntity, len(entities)) @@ -64,6 +95,9 @@ func SortEntities(entities []*client.TextEntity) []*client.TextEntity { if entity1.Offset < entity2.Offset { return true } else if entity1.Offset == entity2.Offset { + if entity1.Length == entity2.Length { + return !isReplacing(entity1) && isReplacing(entity2) + } return entity1.Length > entity2.Length } return false @@ -120,21 +154,10 @@ func MergeAdjacentEntities(entities []*client.TextEntity) []*client.TextEntity { } // ClaspDirectives to the following span as required by XEP-0393 -func ClaspDirectives(text string, entities []*client.TextEntity) []*client.TextEntity { +func ClaspDirectives(doubledRunes []rune, entities []*client.TextEntity) []*client.TextEntity { alignedEntities := make([]*client.TextEntity, len(entities)) copy(alignedEntities, entities) - // transform the source text into a form with uniform runes and code points, - // by duplicating the Basic Multilingual Plane - doubledRunes := make([]rune, 0, len(text)*2) - - for _, cp := range text { - if cp > 0x0000ffff { - doubledRunes = append(doubledRunes, cp, cp) - } else { - doubledRunes = append(doubledRunes, cp) - } - } for i, entity := range alignedEntities { var dirty bool endOffset := entity.Offset + entity.Length @@ -167,18 +190,105 @@ func ClaspDirectives(text string, entities []*client.TextEntity) []*client.TextE return alignedEntities } -func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) (*Insertion, *Insertion) { - return &Insertion{ - Offset: entity.Offset, - Runes: lbrace, - }, &Insertion{ - Offset: entity.Offset + entity.Length, - Runes: rbrace, - } +func mentionBraces(entity *client.TextEntity, nickname string) []*insertion { + return []*insertion{ + &insertion{ + Offset: entity.Offset, + Runes: []rune("@" + nickname), + Type: insertionOpening, + Replacing: true, + }, + &insertion{ + Offset: entity.Offset + entity.Length, + Type: insertionClosing, + Replacing: true, + }, + } } -// EntityToMarkdown generates the wrapping Markdown tags -func EntityToMarkdown(entity *client.TextEntity) (*Insertion, *Insertion) { +func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) []*insertion { + return []*insertion{ + &insertion{ + Offset: entity.Offset, + Runes: lbrace, + Type: insertionOpening, + }, + &insertion{ + Offset: entity.Offset + entity.Length, + Runes: rbrace, + Type: insertionClosing, + }, + } +} + +func quotePrependNewlines(entity *client.TextEntity, doubledRunes []rune, markupMode MarkupModeType) []*insertion { + if len(doubledRunes) == 0 { + return []*insertion{} + } + + startRunes := []rune("\n> ") + if entity.Offset == 0 || doubledRunes[entity.Offset-1] == newlineCode { + startRunes = quoteRunes + } + insertions := []*insertion{ + &insertion{ + Offset: entity.Offset, + Runes: startRunes, + Type: insertionUnpaired, + }, + } + + entityEnd := entity.Offset + entity.Length + entityEndInt := int(entityEnd) + + var wasNewline bool + // last newline is omitted, there's no need to put quote mark after the quote + for i := entity.Offset; i < entityEnd-1; i++ { + isNewline := doubledRunes[i] == newlineCode + if (isNewline && markupMode == MarkupModeXEP0393) || (wasNewline && isNewline && markupMode == MarkupModeMarkdown) { + insertions = append(insertions, &insertion{ + Offset: i + 1, + Runes: quoteRunes, + Type: insertionUnpaired, + }) + } + + if isNewline { + wasNewline = true + } else { + wasNewline = false + } + } + + var rbrace []rune + if len(doubledRunes) > entityEndInt { + if doubledRunes[entityEnd] == newlineCode { + if markupMode == MarkupModeMarkdown && len(doubledRunes) > entityEndInt+1 && doubledRunes[entityEndInt+1] != newlineCode { + rbrace = newlineRunes + } + } else { + if markupMode == MarkupModeMarkdown { + rbrace = doubleNewlineRunes + } else { + rbrace = newlineRunes + } + } + } + insertions = append(insertions, &insertion{ + Offset: entityEnd, + Runes: rbrace, + Type: insertionClosing, + }) + + return insertions +} + +// entityToMarkdown generates the wrapping Markdown tags +func entityToMarkdown(entity *client.TextEntity, doubledRunes []rune, markupMode MarkupModeType) []*insertion { + if entity == nil || entity.Type == nil { + return []*insertion{} + } + switch entity.Type.TextEntityTypeType() { case client.TypeTextEntityTypeBold: return markupBraces(entity, boldRunesMarkdown, boldRunesMarkdown) @@ -189,22 +299,24 @@ func EntityToMarkdown(entity *client.TextEntity) (*Insertion, *Insertion) { case client.TypeTextEntityTypeCode: return markupBraces(entity, codeRunes, codeRunes) case client.TypeTextEntityTypePre: - return markupBraces(entity, preRuneStart, preRuneEnd) + return markupBraces(entity, preRunesStart, preRunesEnd) case client.TypeTextEntityTypePreCode: preCode, _ := entity.Type.(*client.TextEntityTypePreCode) - return markupBraces(entity, []rune("\n```"+preCode.Language+"\n"), codeRunes) + return markupBraces(entity, []rune("\n```"+preCode.Language+"\n"), preRunesEnd) + case client.TypeTextEntityTypeBlockQuote: + return quotePrependNewlines(entity, doubledRunes, MarkupModeMarkdown) case client.TypeTextEntityTypeTextUrl: textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl) return markupBraces(entity, []rune("["), []rune("]("+textURL.Url+")")) } - return nil, nil + return []*insertion{} } -// EntityToXEP0393 generates the wrapping XEP-0393 tags -func EntityToXEP0393(entity *client.TextEntity) (*Insertion, *Insertion) { +// entityToXEP0393 generates the wrapping XEP-0393 tags +func entityToXEP0393(entity *client.TextEntity, doubledRunes []rune, markupMode MarkupModeType) []*insertion { if entity == nil || entity.Type == nil { - return nil, nil + return []*insertion{} } switch entity.Type.TextEntityTypeType() { @@ -217,33 +329,85 @@ func EntityToXEP0393(entity *client.TextEntity) (*Insertion, *Insertion) { case client.TypeTextEntityTypeCode: return markupBraces(entity, codeRunes, codeRunes) case client.TypeTextEntityTypePre: - return markupBraces(entity, preRuneStart, preRuneEnd) + return markupBraces(entity, preRunesStart, preRunesEnd) case client.TypeTextEntityTypePreCode: preCode, _ := entity.Type.(*client.TextEntityTypePreCode) - return markupBraces(entity, []rune("\n```"+preCode.Language+"\n"), codeRunes) + return markupBraces(entity, []rune("\n```"+preCode.Language+"\n"), preRunesEnd) + case client.TypeTextEntityTypeBlockQuote: + return quotePrependNewlines(entity, doubledRunes, MarkupModeXEP0393) case client.TypeTextEntityTypeTextUrl: textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl) // non-standard, Pidgin-specific return markupBraces(entity, []rune{}, []rune(" <"+textURL.Url+">")) } - return nil, nil + return []*insertion{} +} + +// transform the source text into a form with uniform runes and code points, +// by duplicating anything beyond the Basic Multilingual Plane +func textToDoubledRunes(text string) []rune { + doubledRunes := make([]rune, 0, len(text)*2) + for _, cp := range text { + if cp > bmpCeil { + doubledRunes = append(doubledRunes, cp, cp) + } else { + doubledRunes = append(doubledRunes, cp) + } + } + + return doubledRunes +} + +// cuts a substring back from doubled runes +func cutTextFromDoubledRunes(doubledRunes []rune, offset, length int32) string { + runeSlice := doubledRunes[offset:offset+length] + var str strings.Builder + var skipNext bool + for _, cp := range runeSlice { + if skipNext { + skipNext = false + continue + } + + str.WriteRune(cp) + + if cp > bmpCeil { + skipNext = true + } + } + return str.String() +} + +type MentionRetriever interface { + GetMUCNicknameByUsername(username string) (string, error) + GetMUCNickname(id int64) string } // Format traverses an already sorted list of entities and wraps the text in a markup func Format( sourceText string, entities []*client.TextEntity, - entityToMarkup func(*client.TextEntity) (*Insertion, *Insertion), + markupMode MarkupModeType, + mentionRetriever MentionRetriever, ) string { if len(entities) == 0 { return sourceText } - mergedEntities := SortEntities(ClaspDirectives(sourceText, MergeAdjacentEntities(SortEntities(entities)))) + var entityToMarkup func(*client.TextEntity, []rune, MarkupModeType) []*insertion + if markupMode == MarkupModeXEP0393 { + entityToMarkup = entityToXEP0393 + } else { + entityToMarkup = entityToMarkdown + } - startStack := make(InsertionStack, 0, len(sourceText)) - endStack := make(InsertionStack, 0, len(sourceText)) + doubledRunes := textToDoubledRunes(sourceText) + + mergedEntities := SortEntities(ClaspDirectives(doubledRunes, MergeAdjacentEntities(SortEntities(entities)))) + + startStack := make(insertionStack, 0, len(sourceText)) + endStack := make(insertionStack, 0, len(sourceText)) // convert entities to a stack of brackets var maxEndOffset int32 @@ -260,40 +424,115 @@ func Format( startStack, endStack = startStack.rebalance(endStack, entity.Offset) - startInsertion, endInsertion := entityToMarkup(entity) - if startInsertion != nil { - startStack = append(startStack, startInsertion) + var insertions []*insertion + if entity != nil && entity.Type != nil { + switch entity.Type.TextEntityTypeType() { + case client.TypeTextEntityTypeMention: + username := cutTextFromDoubledRunes(doubledRunes, entity.Offset, entity.Length) + nickname, err := mentionRetriever.GetMUCNicknameByUsername(username) + if err == nil { + insertions = mentionBraces(entity, nickname) + } + case client.TypeTextEntityTypeMentionName: + mentionName, _ := entity.Type.(*client.TextEntityTypeMentionName) + nickname := mentionRetriever.GetMUCNickname(mentionName.UserId) + insertions = mentionBraces(entity, nickname) + default: + insertions = entityToMarkup(entity, doubledRunes, markupMode) + } } - if endInsertion != nil { - endStack = append(endStack, endInsertion) + if len(insertions) > 1 { + startStack = append(startStack, insertions[0:len(insertions)-1]...) + } + if len(insertions) > 0 { + endStack = append(endStack, insertions[len(insertions)-1]) } } // flush the closing brackets that still remain in endStack startStack, endStack = startStack.rebalance(endStack, maxEndOffset) + // sort unpaired insertions + sort.SliceStable(startStack, func(i int, j int) bool { + ins1 := startStack[i] + ins2 := startStack[j] + if ins1.Type == insertionUnpaired && ins2.Type == insertionUnpaired { + return ins1.Offset < ins2.Offset + } + if ins1.Type == insertionUnpaired { + if ins1.Offset == ins2.Offset { + if ins2.Type == insertionOpening { // > ** + return true + } else if ins2.Type == insertionClosing { // **> + return false + } + } else { + return ins1.Offset < ins2.Offset + } + } + if ins2.Type == insertionUnpaired { + if ins1.Offset == ins2.Offset { + if ins1.Type == insertionOpening { // > ** + return false + } else if ins1.Type == insertionClosing { // **> + return true + } + } else { + return ins1.Offset < ins2.Offset + } + } + return false + }) // merge brackets into text markupRunes := make([]rune, 0, len(sourceText)) nextInsertion := startStack.NewIterator() insertion := nextInsertion() - var runeI int32 + var skipNext bool + var insideReplacingEntity bool + + for i, cp := range doubledRunes { + if skipNext { + skipNext = false + continue + } + + // loop through possible multiple insertions at this point + for insertion != nil && int(insertion.Offset) <= i { + if !insideReplacingEntity { + markupRunes = append(markupRunes, insertion.Runes...) + } + + // if replacing entity encountered, ignore all entities inside it until it's closed + // (replacing entities are assumed to be not nested or overlapped) + if insertion.Replacing { + if insertion.Type == insertionOpening { + insideReplacingEntity = true + } else if insertion.Type == insertionClosing { + insideReplacingEntity = false + } + } - for _, cp := range sourceText { - for insertion != nil && insertion.Offset <= runeI { - markupRunes = append(markupRunes, insertion.Runes...) insertion = nextInsertion() } + if insideReplacingEntity { + continue + } + markupRunes = append(markupRunes, cp) // skip two UTF-16 code units (not points actually!) if needed - if cp > 0x0000ffff { - runeI += 2 - } else { - runeI++ + if cp > bmpCeil { + skipNext = true } } + // flush closing insertions for insertion != nil { - markupRunes = append(markupRunes, insertion.Runes...) + if !insideReplacingEntity { + markupRunes = append(markupRunes, insertion.Runes...) + } + if insertion.Replacing && insertion.Type == insertionClosing { + insideReplacingEntity = false + } insertion = nextInsertion() } diff --git a/telegram/formatter/formatter_test.go b/telegram/formatter/formatter_test.go index e4bdd23..a3f5816 100644 --- a/telegram/formatter/formatter_test.go +++ b/telegram/formatter/formatter_test.go @@ -1,13 +1,28 @@ package formatter import ( + "errors" + "strings" "testing" "github.com/zelenin/go-tdlib/client" ) +type MentionRetrieverMock struct {} + +func (m *MentionRetrieverMock) GetMUCNicknameByUsername(username string) (string, error) { + if strings.HasPrefix(username, "@") { + return username[1:], nil + } + return "", errors.New("Я@ТЫ@Я@ТЫ@Я@ТЫ@Я@ТЫ@Я@ТЫ@") +} + +func (m *MentionRetrieverMock) GetMUCNickname(id int64) (string) { + return "42" +} + func TestNoFormatting(t *testing.T) { - markup := Format("abc\ndef", []*client.TextEntity{}, EntityToMarkdown) + markup := Format("abc\ndef", []*client.TextEntity{}, MarkupModeMarkdown, &MentionRetrieverMock{}) if markup != "abc\ndef" { t.Errorf("No formatting expected, but: %v", markup) } @@ -20,7 +35,7 @@ func TestFormattingSimple(t *testing.T) { Length: 4, Type: &client.TextEntityTypeBold{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown, &MentionRetrieverMock{}) if markup != "👙**🐧🐖**" { t.Errorf("Wrong simple formatting: %v", markup) } @@ -40,7 +55,7 @@ func TestFormattingAdjacent(t *testing.T) { Url: "https://narayana.im/", }, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown, &MentionRetrieverMock{}) if markup != "a👙_🐧_[🐖](https://narayana.im/)" { t.Errorf("Wrong adjacent formatting: %v", markup) } @@ -63,18 +78,18 @@ func TestFormattingAdjacentAndNested(t *testing.T) { Length: 2, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown, &MentionRetrieverMock{}) if markup != "```\n**👙**🐧\n```_🐖_" { t.Errorf("Wrong adjacent&nested formatting: %v", markup) } } func TestRebalanceTwoZero(t *testing.T) { - s1 := InsertionStack{ - &Insertion{Offset: 7}, - &Insertion{Offset: 8}, + s1 := insertionStack{ + &insertion{Offset: 7}, + &insertion{Offset: 8}, } - s2 := InsertionStack{} + s2 := insertionStack{} s1, s2 = s1.rebalance(s2, 7) if !(len(s1) == 2 && len(s2) == 0 && s1[0].Offset == 7 && s1[1].Offset == 8) { t.Errorf("Wrong rebalance 2–0: %#v %#v", s1, s2) @@ -82,13 +97,13 @@ func TestRebalanceTwoZero(t *testing.T) { } func TestRebalanceNeeded(t *testing.T) { - s1 := InsertionStack{ - &Insertion{Offset: 7}, - &Insertion{Offset: 8}, + s1 := insertionStack{ + &insertion{Offset: 7}, + &insertion{Offset: 8}, } - s2 := InsertionStack{ - &Insertion{Offset: 10}, - &Insertion{Offset: 9}, + s2 := insertionStack{ + &insertion{Offset: 10}, + &insertion{Offset: 9}, } s1, s2 = s1.rebalance(s2, 9) if !(len(s1) == 3 && len(s2) == 1 && @@ -99,13 +114,13 @@ func TestRebalanceNeeded(t *testing.T) { } func TestRebalanceNotNeeded(t *testing.T) { - s1 := InsertionStack{ - &Insertion{Offset: 7}, - &Insertion{Offset: 8}, + s1 := insertionStack{ + &insertion{Offset: 7}, + &insertion{Offset: 8}, } - s2 := InsertionStack{ - &Insertion{Offset: 10}, - &Insertion{Offset: 9}, + s2 := insertionStack{ + &insertion{Offset: 10}, + &insertion{Offset: 9}, } s1, s2 = s1.rebalance(s2, 8) if !(len(s1) == 2 && len(s2) == 2 && @@ -116,13 +131,13 @@ func TestRebalanceNotNeeded(t *testing.T) { } func TestRebalanceLate(t *testing.T) { - s1 := InsertionStack{ - &Insertion{Offset: 7}, - &Insertion{Offset: 8}, + s1 := insertionStack{ + &insertion{Offset: 7}, + &insertion{Offset: 8}, } - s2 := InsertionStack{ - &Insertion{Offset: 10}, - &Insertion{Offset: 9}, + s2 := insertionStack{ + &insertion{Offset: 10}, + &insertion{Offset: 9}, } s1, s2 = s1.rebalance(s2, 10) if !(len(s1) == 4 && len(s2) == 0 && @@ -133,7 +148,7 @@ func TestRebalanceLate(t *testing.T) { } func TestIteratorEmpty(t *testing.T) { - s := InsertionStack{} + s := insertionStack{} g := s.NewIterator() v := g() if v != nil { @@ -142,9 +157,9 @@ func TestIteratorEmpty(t *testing.T) { } func TestIterator(t *testing.T) { - s := InsertionStack{ - &Insertion{Offset: 7}, - &Insertion{Offset: 8}, + s := insertionStack{ + &insertion{Offset: 7}, + &insertion{Offset: 8}, } g := s.NewIterator() v := g() @@ -208,7 +223,7 @@ func TestSortEmpty(t *testing.T) { } func TestNoFormattingXEP0393(t *testing.T) { - markup := Format("abc\ndef", []*client.TextEntity{}, EntityToXEP0393) + markup := Format("abc\ndef", []*client.TextEntity{}, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "abc\ndef" { t.Errorf("No formatting expected, but: %v", markup) } @@ -221,7 +236,7 @@ func TestFormattingXEP0393Simple(t *testing.T) { Length: 4, Type: &client.TextEntityTypeBold{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "👙*🐧🐖*" { t.Errorf("Wrong simple formatting: %v", markup) } @@ -241,7 +256,7 @@ func TestFormattingXEP0393Adjacent(t *testing.T) { Url: "https://narayana.im/", }, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "a👙_🐧_🐖 " { t.Errorf("Wrong adjacent formatting: %v", markup) } @@ -264,7 +279,7 @@ func TestFormattingXEP0393AdjacentAndNested(t *testing.T) { Length: 2, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "```\n*👙*🐧\n```_🐖_" { t.Errorf("Wrong adjacent&nested formatting: %v", markup) } @@ -287,7 +302,7 @@ func TestFormattingXEP0393AdjacentItalicBoldItalic(t *testing.T) { Length: 69, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "_раса двуногих крысолюдей, *которую так редко замечают, что многие отрицают само их существование*_" { t.Errorf("Wrong adjacent italic/bold-italic formatting: %v", markup) } @@ -315,7 +330,7 @@ func TestFormattingXEP0393MultipleAdjacent(t *testing.T) { Length: 1, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "a*bcd*_e_" { t.Errorf("Wrong multiple adjacent formatting: %v", markup) } @@ -343,7 +358,7 @@ func TestFormattingXEP0393Intersecting(t *testing.T) { Length: 1, Type: &client.TextEntityTypeBold{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "a*b*_*cd*e_" { t.Errorf("Wrong intersecting formatting: %v", markup) } @@ -361,7 +376,7 @@ func TestFormattingXEP0393InlineCode(t *testing.T) { Length: 25, Type: &client.TextEntityTypePre{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "Is `Gajim` a thing?\n\n```\necho 'Hello'\necho 'world'\n```\n\nhruck(" { t.Errorf("Wrong intersecting formatting: %v", markup) } @@ -374,7 +389,7 @@ func TestFormattingMarkdownStrikethrough(t *testing.T) { Length: 3, Type: &client.TextEntityTypeStrikethrough{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown, &MentionRetrieverMock{}) if markup != "Everyone ~~dis~~likes cake." { t.Errorf("Wrong strikethrough formatting: %v", markup) } @@ -387,14 +402,14 @@ func TestFormattingXEP0393Strikethrough(t *testing.T) { Length: 3, Type: &client.TextEntityTypeStrikethrough{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393, &MentionRetrieverMock{}) if markup != "Everyone ~dis~likes cake." { t.Errorf("Wrong strikethrough formatting: %v", markup) } } func TestClaspLeft(t *testing.T) { - text := "a b c" + text := textToDoubledRunes("a b c") entities := []*client.TextEntity{ &client.TextEntity{ Offset: 1, @@ -409,7 +424,7 @@ func TestClaspLeft(t *testing.T) { } func TestClaspBoth(t *testing.T) { - text := "a b c" + text := textToDoubledRunes("a b c") entities := []*client.TextEntity{ &client.TextEntity{ Offset: 1, @@ -424,7 +439,7 @@ func TestClaspBoth(t *testing.T) { } func TestClaspNotNeeded(t *testing.T) { - text := " abc " + text := textToDoubledRunes(" abc ") entities := []*client.TextEntity{ &client.TextEntity{ Offset: 1, @@ -439,7 +454,7 @@ func TestClaspNotNeeded(t *testing.T) { } func TestClaspNested(t *testing.T) { - text := "a b c" + text := textToDoubledRunes("a b c") entities := []*client.TextEntity{ &client.TextEntity{ Offset: 1, @@ -459,7 +474,7 @@ func TestClaspNested(t *testing.T) { } func TestClaspEmoji(t *testing.T) { - text := "a 🐖 c" + text := textToDoubledRunes("a 🐖 c") entities := []*client.TextEntity{ &client.TextEntity{ Offset: 1, @@ -472,3 +487,186 @@ func TestClaspEmoji(t *testing.T) { t.Errorf("Wrong claspemoji: %#v", entities) } } + +func TestNoNewlineBlockquoteXEP0393(t *testing.T) { + markup := Format("yes it can i think", []*client.TextEntity{ + &client.TextEntity{ + Offset: 4, + Length: 6, + Type: &client.TextEntityTypeBlockQuote{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "yes \n> it can\n i think" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestNoNewlineBlockquoteMarkdown(t *testing.T) { + markup := Format("yes it can i think", []*client.TextEntity{ + &client.TextEntity{ + Offset: 4, + Length: 6, + Type: &client.TextEntityTypeBlockQuote{}, + }, + }, MarkupModeMarkdown, &MentionRetrieverMock{}) + if markup != "yes \n> it can\n\n i think" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestMultilineBlockquoteXEP0393(t *testing.T) { + markup := Format("hruck\npuck\n\nshuck\ntext", []*client.TextEntity{ + &client.TextEntity{ + Offset: 0, + Length: 17, + Type: &client.TextEntityTypeBlockQuote{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "> hruck\n> puck\n> \n> shuck\ntext" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestMultilineBlockquoteMarkdown(t *testing.T) { + markup := Format("hruck\npuck\n\nshuck\ntext", []*client.TextEntity{ + &client.TextEntity{ + Offset: 0, + Length: 17, + Type: &client.TextEntityTypeBlockQuote{}, + }, + }, MarkupModeMarkdown, &MentionRetrieverMock{}) + if markup != "> hruck\npuck\n\n> shuck\n\ntext" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestMixedBlockquoteXEP0393(t *testing.T) { + markup := Format("hruck\npuck\nshuck\ntext", []*client.TextEntity{ + &client.TextEntity{ + Offset: 0, + Length: 16, + Type: &client.TextEntityTypeBlockQuote{}, + }, + &client.TextEntity{ + Offset: 0, + Length: 16, + Type: &client.TextEntityTypeBold{}, + }, + &client.TextEntity{ + Offset: 0, + Length: 10, + Type: &client.TextEntityTypeItalic{}, + }, + &client.TextEntity{ + Offset: 7, + Length: 2, + Type: &client.TextEntityTypeStrikethrough{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "> *_hruck\n> p~uc~k_\n> shuck*\ntext" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestMixedBlockquoteMarkdown(t *testing.T) { + markup := Format("hruck\npuck\nshuck\ntext", []*client.TextEntity{ + &client.TextEntity{ + Offset: 0, + Length: 16, + Type: &client.TextEntityTypeBlockQuote{}, + }, + &client.TextEntity{ + Offset: 0, + Length: 16, + Type: &client.TextEntityTypeBold{}, + }, + &client.TextEntity{ + Offset: 0, + Length: 10, + Type: &client.TextEntityTypeItalic{}, + }, + &client.TextEntity{ + Offset: 7, + Length: 2, + Type: &client.TextEntityTypeStrikethrough{}, + }, + }, MarkupModeMarkdown, &MentionRetrieverMock{}) + if markup != "> **_hruck\np~~uc~~k_\nshuck**\n\ntext" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} + +func TestUsernameMention(t *testing.T) { + markup := Format("a @b c", []*client.TextEntity{ + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeMention{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "a @b c" { + t.Errorf("Wrong mention formatting: %v", markup) + } +} + +func TestUsernameMentionName(t *testing.T) { + markup := Format("a bb c", []*client.TextEntity{ + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeMentionName{UserId: 100500}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "a @42 c" { + t.Errorf("Wrong mention name formatting: %v", markup) + } +} + +func TestUsernameMentionNested(t *testing.T) { + markup := Format("a @b c", []*client.TextEntity{ + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeMention{}, + }, + &client.TextEntity{ + Offset: 2, + Length: 1, + Type: &client.TextEntityTypeBold{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "a @b c" { + t.Errorf("Wrong formatting of mention with nested entity: %v", markup) + } +} + +func TestUsernameMentionNestedEven(t *testing.T) { + markup := Format("a @b c", []*client.TextEntity{ + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeMention{}, + }, + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeBold{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "a *@b* c" { + t.Errorf("Wrong formatting of mention with even nested entity: %v", markup) + } +} + +func TestUsernameMentionError(t *testing.T) { + markup := Format("a bb c", []*client.TextEntity{ + &client.TextEntity{ + Offset: 2, + Length: 2, + Type: &client.TextEntityTypeMention{}, + }, + }, MarkupModeXEP0393, &MentionRetrieverMock{}) + if markup != "a bb c" { + t.Errorf("Wrong formatting of erroneous mention: %v", markup) + } +} diff --git a/telegram/handlers.go b/telegram/handlers.go index bd768ae..2a00bce 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -11,14 +11,11 @@ import ( "dev.narayana.im/narayana/telegabber/telegram/formatter" "dev.narayana.im/narayana/telegabber/xmpp/gateway" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/zelenin/go-tdlib/client" ) -func uhOh() { - log.Fatal("Update type mismatch") -} - func int64SliceToStringSlice(ints []int64) []string { strings := make([]string, len(ints)) wg := sync.WaitGroup{} @@ -55,6 +52,31 @@ func (c *Client) cleanTempFile(path string) { } } +func (c *Client) sendMarker(chatId, messageId int64, typ gateway.MarkerType) { + xmppId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, chatId, messageId) + if err != nil { + xmppId = strconv.FormatInt(messageId, 10) + } + + var stringType string + if typ == gateway.MarkerTypeReceived { + stringType = "received" + } else if typ == gateway.MarkerTypeDisplayed { + stringType = "displayed" + } + log.WithFields(log.Fields{ + "xmppId": xmppId, + }).Debugf("marker: %s", stringType) + + gateway.SendMessageMarker( + c.jid, + gateway.CHATNODE(chatId), + c.xmpp, + typ, + xmppId, + ) +} + func (c *Client) updateHandler() { listener := c.client.GetListener() defer listener.Close() @@ -63,84 +85,60 @@ func (c *Client) updateHandler() { if update.GetClass() == client.ClassUpdate { switch update.GetType() { case client.TypeUpdateUser: - typedUpdate, ok := update.(*client.UpdateUser) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateUser) c.updateUser(typedUpdate) log.Debugf("%#v", typedUpdate.User) case client.TypeUpdateUserStatus: - typedUpdate, ok := update.(*client.UpdateUserStatus) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateUserStatus) c.updateUserStatus(typedUpdate) log.Debugf("%#v", typedUpdate.Status) case client.TypeUpdateNewChat: - typedUpdate, ok := update.(*client.UpdateNewChat) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateNewChat) c.updateNewChat(typedUpdate) log.Debugf("%#v", typedUpdate.Chat) case client.TypeUpdateChatPosition: - typedUpdate, ok := update.(*client.UpdateChatPosition) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateChatPosition) c.updateChatPosition(typedUpdate) log.Debugf("%#v", typedUpdate) case client.TypeUpdateChatLastMessage: - typedUpdate, ok := update.(*client.UpdateChatLastMessage) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateChatLastMessage) c.updateChatLastMessage(typedUpdate) log.Debugf("%#v", typedUpdate) case client.TypeUpdateNewMessage: - typedUpdate, ok := update.(*client.UpdateNewMessage) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateNewMessage) c.updateNewMessage(typedUpdate) log.Debugf("%#v", typedUpdate.Message) case client.TypeUpdateMessageContent: - typedUpdate, ok := update.(*client.UpdateMessageContent) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateMessageContent) c.updateMessageContent(typedUpdate) log.Debugf("%#v", typedUpdate.NewContent) case client.TypeUpdateDeleteMessages: - typedUpdate, ok := update.(*client.UpdateDeleteMessages) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateDeleteMessages) c.updateDeleteMessages(typedUpdate) case client.TypeUpdateAuthorizationState: - typedUpdate, ok := update.(*client.UpdateAuthorizationState) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateAuthorizationState) c.updateAuthorizationState(typedUpdate) case client.TypeUpdateMessageSendSucceeded: - typedUpdate, ok := update.(*client.UpdateMessageSendSucceeded) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateMessageSendSucceeded) c.updateMessageSendSucceeded(typedUpdate) case client.TypeUpdateMessageSendFailed: - typedUpdate, ok := update.(*client.UpdateMessageSendFailed) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateMessageSendFailed) c.updateMessageSendFailed(typedUpdate) case client.TypeUpdateChatTitle: - typedUpdate, ok := update.(*client.UpdateChatTitle) - if !ok { - uhOh() - } + typedUpdate, _ := update.(*client.UpdateChatTitle) c.updateChatTitle(typedUpdate) + case client.TypeUpdateChatReadOutbox: + typedUpdate, _ := update.(*client.UpdateChatReadOutbox) + c.updateChatReadOutbox(typedUpdate) + case client.TypeUpdateBasicGroupFullInfo: + typedUpdate, _ := update.(*client.UpdateBasicGroupFullInfo) + c.updateBasicGroupFullInfo(typedUpdate) + case client.TypeUpdateChatPermissions: + typedUpdate, _ := update.(*client.UpdateChatPermissions) + c.updateChatPermissions(typedUpdate) + case client.TypeUpdateFile: + typedUpdate, _ := update.(*client.UpdateFile) + c.updateFile(typedUpdate) default: // log only handled types continue @@ -153,15 +151,22 @@ func (c *Client) updateHandler() { // new user discovered func (c *Client) updateUser(update *client.UpdateUser) { + // check if MUC nicknames should be updated + oldCacheUser, ok := c.cache.GetUser(update.User.Id) c.cache.SetUser(update.User.Id, update.User) + if ok && (oldCacheUser.FirstName != update.User.FirstName || oldCacheUser.LastName != update.User.LastName) { + newNickname := c.GetMUCNickname(update.User.Id) + c.updateMUCsNickname(update.User.Id, newNickname) + } + show, status, presenceType := c.userStatusToText(update.User.Status, update.User.Id) - go c.ProcessStatusUpdate(update.User.Id, status, show, gateway.SPType(presenceType)) + go c.ProcessStatusUpdate(update.User.Id, status, show, false, gateway.SPType(presenceType)) } // user status changed func (c *Client) updateUserStatus(update *client.UpdateUserStatus) { show, status, presenceType := c.userStatusToText(update.Status, update.UserId) - go c.ProcessStatusUpdate(update.UserId, status, show, gateway.SPImmed(false), gateway.SPType(presenceType)) + go c.ProcessStatusUpdate(update.UserId, status, show, false, gateway.SPImmed(false), gateway.SPType(presenceType)) } // new chat discovered @@ -175,14 +180,14 @@ func (c *Client) updateNewChat(update *client.UpdateNewChat) { } } - c.cache.SetChat(update.Chat.Id, update.Chat) + c.cache.SetChat(update.Chat.Id, update.Chat, true) if update.Chat.Positions != nil && len(update.Chat.Positions) > 0 { - c.subscribeToID(update.Chat.Id, update.Chat) + c.subscribeToID(update.Chat.Id, update.Chat, false) } if update.Chat.Id < 0 { - c.ProcessStatusUpdate(update.Chat.Id, update.Chat.Title, "chat") + c.ProcessStatusUpdate(update.Chat.Id, update.Chat.Title, "chat", true) } }() } @@ -190,31 +195,47 @@ func (c *Client) updateNewChat(update *client.UpdateNewChat) { // chat position is updated func (c *Client) updateChatPosition(update *client.UpdateChatPosition) { if update.Position != nil && update.Position.Order != 0 { - go c.subscribeToID(update.ChatId, nil) + go c.subscribeToID(update.ChatId, nil, false) } } // chat last message is updated func (c *Client) updateChatLastMessage(update *client.UpdateChatLastMessage) { if update.Positions != nil && len(update.Positions) > 0 { - go c.subscribeToID(update.ChatId, nil) + go c.subscribeToID(update.ChatId, nil, false) } } // message received func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { - go func() { - chatId := update.Message.ChatId + chatId := update.Message.ChatId + if c.Session.IsChatIgnored(chatId) { + return + } - // guarantee sequential message delivering per chat - lock := c.getChatMessageLock(chatId) + // guarantee sequential message delivering per chat + lock := c.getChatMessageLock(chatId) + go func() { lock.Lock() defer lock.Unlock() + var forceCmd bool + if c.LastBotCmdString != "" && update.Message.IsOutgoing { + if update.Message.Content.MessageContentType() == client.TypeMessageText { + textMessage, _ := update.Message.Content.(*client.MessageText) + + if textMessage.Text != nil && textMessage.Text.Text == c.LastBotCmdString { + forceCmd = true + c.LastBotCmdString = "" + } + } + } + // ignore self outgoing messages if update.Message.IsOutgoing && update.Message.SendingState != nil && - update.Message.SendingState.MessageSendingStateType() == client.TypeMessageSendingStatePending { + update.Message.SendingState.MessageSendingStateType() == client.TypeMessageSendingStatePending && + !forceCmd { return } @@ -228,27 +249,203 @@ func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { // message content updated func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { - markupFunction := formatter.EntityToXEP0393 + if c.Session.IsChatIgnored(update.ChatId) { + return + } + + markupFunction := c.getFormatter() + + log.Debugf("newContent: %#v", update.NewContent) + + lock := c.getChatMessageLock(update.ChatId) + lock.Lock() + lock.Unlock() + c.SendMessageLock.Lock() + c.SendMessageLock.Unlock() + + xmppId, xmppIdErr := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, update.ChatId, update.MessageId) + var ignoredResource string + if xmppIdErr == nil { + ignoredResource = c.popFromEditOutbox(xmppId) + } else { + log.Infof("Couldn't retrieve XMPP message ids for %v, an echo may happen", update.MessageId) + } + log.Infof("ignoredResource: %v", ignoredResource) + + chat, _, _ := c.GetContactByID(update.ChatId, nil, true) + isMUC := c.Session.MUC && c.IsGroup(chat) + + var jids []string + if isMUC { + _, jids = c.getMUCJoinedJIDs(update.ChatId, nil, true) + } else { + jids = c.GetCarbonFullJids(true, ignoredResource, true) + } + if len(jids) == 0 { + log.Info("The only resource is ignored, aborting") + return + } + if update.NewContent.MessageContentType() == client.TypeMessageText { + safeToSend := true + textContent := update.NewContent.(*client.MessageText) - var editChar string - if c.Session.AsciiArrows { - editChar = "e " - } else { - editChar = "✎ " - } - text := editChar + fmt.Sprintf("%v | %s", update.MessageId, formatter.Format( - textContent.Text.Text, - textContent.Text.Entities, - markupFunction, - )) - gateway.SendMessage(c.jid, strconv.FormatInt(update.ChatId, 10), text, "e"+strconv.FormatInt(update.MessageId, 10), c.xmpp, nil, false) + log.Debugf("textContent: %#v", textContent.Text) + + var replaceId string + sId := strconv.FormatInt(update.MessageId, 10) + var isCarbon bool + + go func() { + message, messageErr := c.client.GetMessage(&client.GetMessageRequest{ + ChatId: update.ChatId, + MessageId: update.MessageId, + }) + if messageErr != nil { + // odnako za vremya puti + // sobaka mogla podrasti + c.MessageIdChangesLock.Lock() + idsMap, idsMapOk := c.MessageIdChanges[update.ChatId] + hadNoId := false + if idsMapOk { + newId, newIdOk := idsMap[update.MessageId] + if newIdOk { + if newId.Id == 0 { + hadNoId = true + c.MessageIdChangesLock.Unlock() + newId.Lock() + } + log.Infof("falling back to updated message id: %v/%v->%v", update.ChatId, update.MessageId, newId.Id) + message, messageErr = c.client.GetMessage(&client.GetMessageRequest{ + ChatId: update.ChatId, + MessageId: newId.Id, + }) + } + } + if !hadNoId { + c.MessageIdChangesLock.Unlock() + } + } + + var prefix string + if messageErr == nil { + if message.EditDate == 0 { + return + } + log.Debugf("editDate: %v", message.EditDate) + + isCarbon = c.isCarbonsEnabled() && message.IsOutgoing && !isMUC + // reply correction support in clients is suboptimal yet, so cut them out for now + prefix, _ = c.messageToPrefix(message, "", "", true) + } else { + log.Errorf("No message %v/%v found, cannot reliably determine if it is a carbon and if it is edited: %v", update.ChatId, update.MessageId, messageErr.Error()) + } + + // use XEP-0308 edits only if the last message is edited for sure, fallback otherwise + if c.Session.NativeEdits { + lastXmppId, ok := c.getLastChatMessageId(update.ChatId) + if xmppIdErr != nil { + xmppId = sId + } + if ok && lastXmppId == xmppId { + replaceId = xmppId + } else { + log.Infof("Mismatching message ids: %v %v, falling back to separate edit message", lastXmppId, xmppId) + } + } + + var forceFallback bool + + var from string + var originalFrom string + var nickname string + if isMUC { + if messageErr == nil { + senderId := c.getMessageSenderId(message) + nickname = c.GetMUCNickname(senderId) + originalFrom = gateway.CHATJID(senderId, true) + + safeToSend = c.assureMUCOccupant(update.ChatId, senderId, message.SenderId, chat) + from = gateway.MUCJID(update.ChatId) + "/" + nickname + } else { + nickname = "#ERROR#" + forceFallback = true + from = gateway.MUCJID(update.ChatId) + } + } else { + from = gateway.CHATNODE(update.ChatId) + } + + var text strings.Builder + + if replaceId == "" || forceFallback { + var editChar string + if c.Session.AsciiArrows { + editChar = "e" + } else { + editChar = "✎" + } + text.WriteString(fmt.Sprintf("%s %v | ", editChar, update.MessageId)) + } else if prefix != "" { + text.WriteString(prefix) + text.WriteString(c.getPrefixSeparator(update.ChatId)) + } + + text.WriteString(formatter.Format( + textContent.Text.Text, + textContent.Text.Entities, + markupFunction, + c, + )) + + id := "e"+sId + uuid, err := uuid.NewRandom() + if err == nil { + id = id+":"+uuid.String() + } + for _, jid := range jids { + if safeToSend { + gateway.SendMessage(jid, from, text.String(), id, c.xmpp, nil, 0, replaceId, isCarbon, isMUC, false, originalFrom, "", "", "", nil) + } else { + gateway.SendMUCAnnouncement(jid, from, text.String(), nickname, id, c.xmpp) + } + } + }() } } // message(s) deleted func (c *Client) updateDeleteMessages(update *client.UpdateDeleteMessages) { + c.locks.pinOutboxLock.Lock() + for _, messageId := range update.MessageIds { + ch, chOk := c.pinOutbox[IntPair{update.ChatId, messageId}] + if chOk { + ch <-0 + } + } + c.locks.pinOutboxLock.Unlock() + if update.IsPermanent { + for _, deleteId := range update.MessageIds { + c.tryUnlockMessageId(update.ChatId, deleteId) + } + + if c.Session.IsChatIgnored(update.ChatId) { + return + } + if c.Session.IgnoreGroupDeletions { + chatType, _, chatTypeErr := c.GetChatType(update.ChatId, false) + if chatTypeErr == nil && (chatType == gateway.ChatTypeBasicGroup || chatType == gateway.ChatTypeSupergroup) { + return + } + } + + var isGroupchat bool + chat, _, _ := c.GetContactByID(update.ChatId, nil, false) + if c.Session.MUC && c.IsGroup(chat) { + isGroupchat = true + } + var deleteChar string if c.Session.AsciiArrows { deleteChar = "X " @@ -256,7 +453,26 @@ func (c *Client) updateDeleteMessages(update *client.UpdateDeleteMessages) { deleteChar = "✗ " } text := deleteChar + strings.Join(int64SliceToStringSlice(update.MessageIds), ",") - gateway.SendTextMessage(c.jid, strconv.FormatInt(update.ChatId, 10), text, c.xmpp) + + var fromJid string + var jids []string + if isGroupchat { + fromJid = gateway.MUCJID(update.ChatId) + _, jids = c.getMUCJoinedJIDs(update.ChatId, nil, true) + var nickname string + if chat != nil { + nickname = chat.Title + } + for _, jid := range jids { + gateway.SendMUCAnnouncement(jid, fromJid, text, nickname, "", c.xmpp) + } + } else { + fromJid = gateway.CHATNODE(update.ChatId) + jids = c.GetCarbonFullJids(true, "", false) + for _, jid := range jids { + gateway.SendTextMessage(jid, fromJid, text, c.xmpp, isGroupchat) + } + } } } @@ -270,14 +486,53 @@ func (c *Client) updateAuthorizationState(update *client.UpdateAuthorizationStat } } -// clean uploaded files func (c *Client) updateMessageSendSucceeded(update *client.UpdateMessageSendSucceeded) { + c.locks.pinOutboxLock.Lock() + ch, chOk := c.pinOutbox[IntPair{update.Message.ChatId, update.OldMessageId}] + if chOk { + ch <-update.Message.Id + } + c.locks.pinOutboxLock.Unlock() + + // replace message ID in local database + log.Debugf("replace message %v with %v", update.OldMessageId, update.Message.Id) + if err := gateway.IdsDB.ReplaceTgId(c.Session.Login, c.jid, update.Message.ChatId, update.OldMessageId, update.Message.Id); err != nil { + log.Errorf("failed to replace %v with %v: %v", update.OldMessageId, update.Message.Id, err.Error()) + } + + c.MessageIdChangesLock.Lock() + idsMap, ok := c.MessageIdChanges[update.Message.ChatId] + if !ok { + idsMap = make(map[int64]*newId) + c.MessageIdChanges[update.Message.ChatId] = idsMap + } + id, ok := idsMap[update.OldMessageId] + if !ok { + id = newNewId() + idsMap[update.OldMessageId] = id + } + id.Id = update.Message.Id + c.MessageIdChangesLock.Unlock() + + c.sendMarker(update.Message.ChatId, update.Message.Id, gateway.MarkerTypeReceived) + + // clean uploaded files file, _ := c.contentToFile(update.Message.Content) if file != nil && file.Local != nil { c.cleanTempFile(file.Local.Path) } } func (c *Client) updateMessageSendFailed(update *client.UpdateMessageSendFailed) { + c.tryUnlockMessageId(update.Message.ChatId, update.OldMessageId) + + c.locks.pinOutboxLock.Lock() + ch, chOk := c.pinOutbox[IntPair{update.Message.ChatId, update.OldMessageId}] + if chOk { + ch <-0 + } + c.locks.pinOutboxLock.Unlock() + + // clean uploaded files file, _ := c.contentToFile(update.Message.Content) if file != nil && file.Local != nil { c.cleanTempFile(file.Local.Path) @@ -286,11 +541,104 @@ func (c *Client) updateMessageSendFailed(update *client.UpdateMessageSendFailed) // chat title changed func (c *Client) updateChatTitle(update *client.UpdateChatTitle) { - gateway.SetNickname(c.jid, strconv.FormatInt(update.ChatId, 10), update.Title, c.xmpp) + chat, user, _ := c.GetContactByID(update.ChatId, nil, false) + if c.Session.MUC && c.IsGroup(chat) { + return + } + + gateway.SetNickname(c.jid, gateway.CHATNODE(update.ChatId), update.Title, c.xmpp) // set also the status (for group chats only) - _, user, _ := c.GetContactByID(update.ChatId, nil) if user == nil { - c.ProcessStatusUpdate(update.ChatId, update.Title, "chat", gateway.SPImmed(true)) + c.ProcessStatusUpdate(update.ChatId, update.Title, "chat", false, gateway.SPImmed(true)) + } + + // update chat title in the cache + if chat != nil { + chat.Title = update.Title } } + +func (c *Client) updateChatReadOutbox(update *client.UpdateChatReadOutbox) { + c.sendMarker(update.ChatId, update.LastReadOutboxMessageId, gateway.MarkerTypeDisplayed) +} + +func (c *Client) updateBasicGroupFullInfo(update *client.UpdateBasicGroupFullInfo) { + if c.Session.MUC && update.BasicGroupFullInfo != nil { + chatID := -update.BasicGroupId + + c.locks.mucCacheLock.Lock() + + mucState, ok := c.mucCache[chatID] + if ok && mucState != nil { + mucState.Occupants.Clear() + c.updateMUCOccupants(mucState, chatID, update.BasicGroupFullInfo.Members) + } + + c.locks.mucCacheLock.Unlock() + } +} + +func (c *Client) updateChatPermissions(update *client.UpdateChatPermissions) { + chat, _, _ := c.GetContactByID(update.ChatId, nil, false) + + // update chat permissions in the cache + if chat != nil { + chat.Permissions = update.Permissions + } + + if c.Session.MUC { + c.locks.mucCacheLock.Lock() + + mucState, ok := c.mucCache[update.ChatId] + if ok && mucState != nil { + _, toJids := c.getMUCJoinedJIDs(update.ChatId, mucState, false) + for occupant := range mucState.Occupants.Range() { + affiliation, role := c.memberStatusToAffiliationAndRole(occupant.Status, chat) + if affiliation != occupant.Affiliation || role != occupant.Role { + occupant.Affiliation = affiliation + occupant.Role = role + + c.sendPresence( + gateway.SPFrom(gateway.MUCNODE(update.ChatId)), + gateway.SPResource(occupant.Nickname), + gateway.SPImmed(true), + gateway.SPMUCJid(gateway.CHATJID(occupant.id, true)), + gateway.SPMUCAffiliation(affiliation), + gateway.SPMUCRole(role), + gateway.SPToJids(toJids), + ) + } + } + } + + c.locks.mucCacheLock.Unlock() + } +} + +func (c *Client) updateFile(update *client.UpdateFile) { + if update.File != nil && update.File.Local != nil { + // not really needed, why did I even write this then lol (TODO: maybe clean by some heur anyway) + /* c.locks.uploadingFilesLock.Lock() + if _, ok := c.uploadingFiles[update.File.Id]; ok && update.File.Local.CanBeDeleted && update.File.Local.Path != "" { + err := os.Remove(update.File.Local.Path) + if err != nil { + log.Warningf("Couldn't delete uploaded file: %v", err.Error()) + } + delete(c.uploadingFiles, update.File.Id) + } + c.locks.uploadingFilesLock.Unlock() */ + } +} + +func (c *Client) tryUnlockMessageId(chatId, messageId int64) { + c.MessageIdChangesLock.Lock() + idsMap, ok := c.MessageIdChanges[chatId] + if ok { + id, ok := idsMap[messageId] + if ok { + id.Unlock() + } + } + c.MessageIdChangesLock.Unlock() +} diff --git a/telegram/loginwizard.go b/telegram/loginwizard.go new file mode 100644 index 0000000..1eb4ac8 --- /dev/null +++ b/telegram/loginwizard.go @@ -0,0 +1,121 @@ +package telegram + +import ( + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +type LoginStage string +const ( + LoginStageNone LoginStage = "" + LoginStageLogin LoginStage = "login" + LoginStageCode LoginStage = "code" + LoginStagePassword LoginStage = "password" + LoginStagePreset LoginStage = "preset" + LoginStageMUC LoginStage = "muc" + LoginStageSuccess LoginStage = "success" + LoginStageCancel LoginStage = "cancel" +) + +// setLoginStage updates loginState and triggers session initialization on login sucess +func (c *Client) setLoginStage(stage LoginStage) { + c.loginStage = stage + switch c.loginStage { + case LoginStageSuccess, LoginStageCancel: + c.locks.loginFinish.Done() + } + log.Debugf("set loginStage %v", stage) +} + +// StartLoginWizard initiates a loginWizard object +func (c *Client) StartLoginWizard(inCommand bool) { + if c.loginWizard == nil { + c.loginWizard = &loginWizardMetadata{ + nextStage: make(chan LoginStage, 1), + commandSent: inCommand, + } + } else { + c.loginWizard.commandSent = inCommand + } +} + +// StopLoginWizard safely destroys the loginWizard object +func (c *Client) StopLoginWizard() { + c.locks.loginWizardReadLock.Lock() + c.locks.loginWizardWriteLock.Lock() + if c.loginWizard != nil { + close(c.loginWizard.nextStage) + c.loginWizard = nil + } + c.locks.loginWizardReadLock.Unlock() + c.locks.loginWizardWriteLock.Unlock() +} + +// GetLoginWizardNextStage waits for the next stage from the channel +func (c *Client) GetLoginWizardNextStage() LoginStage { + c.locks.loginWizardReadLock.Lock() + defer c.locks.loginWizardReadLock.Unlock() + + if c.loginWizard != nil { + if c.loginWizard.commandSent { + log.Debugf("waiting for nextStage...") + nextStage := <-c.loginWizard.nextStage + c.loginWizard.commandSent = false + c.loginWizard.chanBusy = false + log.Debugf("yielded stage %v", nextStage) + return nextStage + } else { + log.Debugf("commandSent is false") + + if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber || + c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing || + c.Session.Login == "" { + return LoginStageLogin + } + switch c.lastAuthorizationStateType { + case client.TypeAuthorizationStateWaitCode: + return LoginStageCode + case client.TypeAuthorizationStateWaitPassword: + return LoginStagePassword + } + + switch c.loginStage { + case LoginStagePreset: + return LoginStageMUC + case LoginStageMUC: + return LoginStageNone + } + } + } + + return LoginStageNone +} + +func (c *Client) wizardStageOrPrompt(stage LoginStage, message string) { + log.Debugf("loginStage: %v stage: %v", c.loginStage, stage) + if c.loginStage == stage { + return + } + + c.locks.loginWizardWriteLock.Lock() + c.setLoginStage(stage) + if c.loginWizard == nil { + c.locks.loginWizardWriteLock.Unlock() + if message != "" { + for _, jid := range c.GetCarbonFullJids(true, "", false) { + gateway.SendServiceMessage(jid, message, c.xmpp) + } + } + } else { + if !c.loginWizard.chanBusy { + log.Debugf("writing wizard stage %v", stage) + c.loginWizard.nextStage <- stage + } else { + log.Warnf("Skipping stage %v, wizard cannot keep up", stage) + } + c.loginWizard.chanBusy = true + c.locks.loginWizardWriteLock.Unlock() + } +} diff --git a/telegram/muc.go b/telegram/muc.go new file mode 100644 index 0000000..0470f7d --- /dev/null +++ b/telegram/muc.go @@ -0,0 +1,205 @@ +package telegram + +import ( + "sync" + + "github.com/zelenin/go-tdlib/client" + log "github.com/sirupsen/logrus" +) + +const MUCOccupantsLimit int32 = 200 + +// MUCState holds MUC metadata +type MUCState struct { + Resources map[string]bool + Occupants *MUCOccupantsLRU +} + +// MUCOccupant represents a MUC occupant +type MUCOccupant struct { + Nickname string + Affiliation string + Role string + Status client.ChatMemberStatus + prev *MUCOccupant + next *MUCOccupant + id int64 +} + +func (o *MUCOccupant) cutOut() (prev, next *MUCOccupant) { + prev = o.prev + next = o.next + + // -- * --- * -X- * -X- * --- * -- + o.prev = nil + o.next = nil + if prev != nil { + prev.next = next + } + if next != nil { + next.prev = prev + } + + return +} + +func NewMUCState() *MUCState { + return &MUCState{ + Resources: make(map[string]bool), + Occupants: NewMUCOccupantsLRU(), + } +} + +type MUCOccupantsLRU struct { + m map[int64]*MUCOccupant + rev map[string]int64 + oldest *MUCOccupant + newest *MUCOccupant + lock sync.Mutex +} + +func NewMUCOccupantsLRU() *MUCOccupantsLRU { + return &MUCOccupantsLRU{ + m: make(map[int64]*MUCOccupant), + rev: make(map[string]int64), + } +} + +func (lru *MUCOccupantsLRU) Get(id int64) (*MUCOccupant, bool) { + lru.lock.Lock() + defer lru.lock.Unlock() + + occupant, ok := lru.m[id] + return occupant, ok +} + +func (lru *MUCOccupantsLRU) GetIdByNickname(nickname string) (int64, bool) { + lru.lock.Lock() + defer lru.lock.Unlock() + + id, ok := lru.rev[nickname] + return id, ok +} + +func (lru *MUCOccupantsLRU) cutOut(oldOccupant *MUCOccupant) (prev, next *MUCOccupant) { + prev, next = oldOccupant.cutOut() + if lru.oldest == oldOccupant { + lru.oldest = next + } + if lru.newest == oldOccupant { + lru.newest = prev + } + + return +} + +func (lru *MUCOccupantsLRU) insertNewest(occupant *MUCOccupant) { + lru.newest.next = occupant + occupant.prev = lru.newest + occupant.next = nil + lru.newest = occupant +} + +// Set adds or replaces an occupant and possibly returns an occupant removed instead because of overflow +func (lru *MUCOccupantsLRU) Set(id int64, occupant *MUCOccupant) (deleted *MUCOccupant) { + lru.lock.Lock() + defer lru.lock.Unlock() + + occupant.id = id + + oldOccupant, oldOk := lru.m[id] + lru.m[id] = occupant + + if oldOk { + lru.cutOut(oldOccupant) + + delete(lru.rev, oldOccupant.Nickname) + lru.rev[occupant.Nickname] = id + } + + if (lru.oldest == nil) != (lru.newest == nil) { + log.Fatal("MRD MUDAQ") + } + + if lru.oldest == nil && lru.newest == nil { + lru.oldest = occupant + lru.newest = occupant + occupant.prev = nil + occupant.next = nil + } else { + lru.insertNewest(occupant) + } + + if len(lru.m) > int(MUCOccupantsLimit) && lru.oldest != nil { + deleted = lru.oldest + delete(lru.m, lru.oldest.id) + delete(lru.rev, lru.oldest.Nickname) + lru.cutOut(lru.oldest) + } + + return +} + +// Delete occupant by member ID +func (lru *MUCOccupantsLRU) Delete(id int64) { + lru.lock.Lock() + defer lru.lock.Unlock() + + oldOccupant, oldOk := lru.m[id] + delete(lru.m, id) + + if oldOk { + lru.cutOut(oldOccupant) + delete(lru.rev, oldOccupant.Nickname) + } +} + +// Bump raises the occupant in LRU +func (lru *MUCOccupantsLRU) Bump(occupant *MUCOccupant) { + lru.lock.Lock() + defer lru.lock.Unlock() + + if lru.newest == occupant { + // already at the top, nothing to do + return + } + + lru.cutOut(occupant) + lru.insertNewest(occupant) +} + +// Range loops over all occupants +func (lru *MUCOccupantsLRU) Range() chan *MUCOccupant { + lru.lock.Lock() + + occupantChan := make(chan *MUCOccupant, 1) + + go func() { + defer func() { + lru.lock.Unlock() + close(occupantChan) + }() + + for _, occupant := range lru.m { + occupantChan <- occupant + } + }() + + return occupantChan +} + +// Clear properly removes all occupants and their possible mutual references (not necessary in Golang, yet still) +func (lru *MUCOccupantsLRU) Clear() { + lru.lock.Lock() + defer lru.lock.Unlock() + + for _, occupant := range lru.m { + occupant.prev = nil + occupant.next = nil + } + lru.m = make(map[int64]*MUCOccupant) + lru.rev = make(map[string]int64) + + lru.oldest = nil + lru.newest = nil +} diff --git a/telegram/muc_test.go b/telegram/muc_test.go new file mode 100644 index 0000000..f03fef1 --- /dev/null +++ b/telegram/muc_test.go @@ -0,0 +1,419 @@ +package telegram + +import ( + "testing" +) + +// -x->[]-x-> +(.) +func TestSetMUCOccupantsLRUSetInitiallyEmpty(t *testing.T) { + // init + occupants := NewMUCOccupantsLRU() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(1, newOccupant) + + // checks + if occupants.oldest != newOccupant || newOccupant.prev != nil || newOccupant.next != nil || occupants.newest != newOccupant { + t.Error("Broken") + } +} + +func testMUCOccupantsLRUChainOfOne() (occupants *MUCOccupantsLRU, occupant1 *MUCOccupant) { + occupants = NewMUCOccupantsLRU() + occupant1 = &MUCOccupant{} + occupants.m[1] = occupant1 + occupants.oldest = occupant1 + occupants.newest = occupant1 + + return +} + +func testMUCOccupantsLRUChainOfThree() (occupants *MUCOccupantsLRU, occupant1, occupant2, occupant3 *MUCOccupant) { + occupants = NewMUCOccupantsLRU() + occupant1 = &MUCOccupant{} + occupant2 = &MUCOccupant{} + occupant3 = &MUCOccupant{} + occupants.m[1] = occupant1 + occupants.m[2] = occupant2 + occupants.m[3] = occupant3 + occupants.oldest = occupant1 + occupants.newest = occupant3 + occupant1.next = occupant2 + occupant2.prev = occupant1 + occupant2.next = occupant3 + occupant3.prev = occupant2 + + return +} + +// ->[]->()-> +(.) +func TestSetMUCOccupantsLRUSetOneOther(t *testing.T) { + // init + occupants, occupant1 := testMUCOccupantsLRUChainOfOne() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(2, newOccupant) + + // checks + if occupants.oldest != occupant1 || occupant1.prev != nil || occupant1.next != newOccupant || newOccupant.prev != occupant1 || newOccupant.next != nil || occupants.newest != newOccupant { + t.Error("Broken") + } +} + +// ->[]->()->()->()-> +(.) +func TestSetMUCOccupantsLRUSetThreeOthers(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(4, newOccupant) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant2 || + occupant2.prev != occupant1 || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != newOccupant || + newOccupant.prev != occupant3 || + newOccupant.next != nil || + occupants.newest != newOccupant { + t.Error("Broken") + } +} + +// ->[]->(.)->()->()-> +(.) +func TestSetMUCOccupantsLRUSetReplaceFirst(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(1, newOccupant) + + // checks + if occupants.oldest != occupant2 || + occupant2.prev != nil || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != newOccupant || + newOccupant.prev != occupant3 || + newOccupant.next != nil || + occupants.newest != newOccupant || + occupant1.prev != nil || + occupant1.next != nil { + t.Error("Broken") + } +} + +// ->[]->()->(.)->()-> +(.) +func TestSetMUCOccupantsLRUSetReplaceMiddle(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(2, newOccupant) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant3 || + occupant3.prev != occupant1 || + occupant3.next != newOccupant || + newOccupant.prev != occupant3 || + newOccupant.next != nil || + occupants.newest != newOccupant || + occupant2.prev != nil || + occupant2.next != nil { + t.Error("Broken") + } +} + +// ->[]->()->()->(.)-> +(.) +func TestSetMUCOccupantsLRUSetReplaceLast(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(3, newOccupant) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant2 || + occupant2.prev != occupant1 || + occupant2.next != newOccupant || + newOccupant.prev != occupant2 || + newOccupant.next != nil || + occupants.newest != newOccupant || + occupant3.prev != nil || + occupant3.next != nil { + t.Error("Broken") + } +} + +// ->[]->(.)-> +(.) +func TestSetMUCOccupantsLRUSetReplaceOnly(t *testing.T) { + // init + occupants, occupant1 := testMUCOccupantsLRUChainOfOne() + + // addition + newOccupant := &MUCOccupant{} + occupants.Set(1, newOccupant) + + // checks + if occupants.oldest != newOccupant || + occupants.newest != newOccupant || + newOccupant.prev != nil || + newOccupant.next != nil || + occupant1.prev != nil || + occupant1.next != nil { + t.Error("Broken") + } +} + +// ->[]->(.)->()->()-> +s(.) +func TestSetMUCOccupantsLRUSetReplaceFirstWithSame(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + occupants.Set(1, occupant1) + + // checks + if occupants.oldest != occupant2 || + occupant2.prev != nil || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != occupant1 || + occupant1.prev != occupant3 || + occupant1.next != nil || + occupants.newest != occupant1 { + t.Error("Broken") + } +} + +// ->[]->()->(.)->()-> +s(.) +func TestSetMUCOccupantsLRUSetReplaceMiddleWithSame(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + occupants.Set(2, occupant2) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant3 || + occupant3.prev != occupant1 || + occupant3.next != occupant2 || + occupant2.prev != occupant3 || + occupant2.next != nil || + occupants.newest != occupant2 { + t.Error("Broken") + } +} + +// ->[]->()->()->(.)-> +s(.) +func TestSetMUCOccupantsLRUSetReplaceLastWithSame(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // addition + occupants.Set(3, occupant3) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant2 || + occupant2.prev != occupant1 || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != nil || + occupants.newest != occupant3 { + t.Error("Broken") + } +} + +// ->[]->(.)-> +(.) +func TestSetMUCOccupantsLRUSetReplaceOnlyWithSame(t *testing.T) { + // init + occupants, occupant1 := testMUCOccupantsLRUChainOfOne() + + // addition + occupants.Set(1, occupant1) + + // checks + if occupants.oldest != occupant1 || + occupants.newest != occupant1 || + occupant1.prev != nil || + occupant1.next != nil { + t.Error("Broken") + } +} + +// ->[]->(X)-> +func TestSetMUCOccupantsLRUDeleteOnly(t *testing.T) { + // init + occupants, occupant1 := testMUCOccupantsLRUChainOfOne() + + // deletion + occupants.Delete(1) + + // checks + if occupants.oldest != nil || + occupants.newest != nil || + occupant1.prev != nil || + occupant1.next != nil { + t.Error("Broken") + } +} + +// ->[]->(X)->()->()-> +func TestSetMUCOccupantsLRUDeleteFirst(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // deletion + occupants.Delete(1) + + // checks + if occupants.oldest != occupant2 || + occupant2.prev != nil || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != nil || + occupants.newest != occupant3 || + occupant1.prev != nil || + occupant1.next != nil { + t.Error("Broken") + } +} + +// ->[]->()->(X)->()-> +func TestSetMUCOccupantsLRUDeleteMiddle(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // deletion + occupants.Delete(2) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant3 || + occupant3.prev != occupant1 || + occupant3.next != nil || + occupants.newest != occupant3 || + occupant2.prev != nil || + occupant2.next != nil { + t.Error("Broken") + } +} + +// ->[]->()->()->(X)-> +func TestSetMUCOccupantsLRUDeleteLast(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // deletion + occupants.Delete(3) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant2 || + occupant2.prev != occupant1 || + occupant2.next != nil || + occupants.newest != occupant2 || + occupant3.prev != nil || + occupant3.next != nil { + t.Error("Broken") + } +} + +// ->[]->(.)-> +func TestSetMUCOccupantsLRUBumpOnly(t *testing.T) { + // init + occupants, occupant1 := testMUCOccupantsLRUChainOfOne() + + // bump + occupants.Bump(occupant1) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != nil || + occupants.newest != occupant1 { + t.Error("Broken") + } +} + +// ->[]->(.)->()->()-> +func TestSetMUCOccupantsLRUBumpFirst(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // bump + occupants.Bump(occupant1) + + // checks + if occupants.oldest != occupant2 || + occupant2.prev != nil || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != occupant1 || + occupant1.prev != occupant3 || + occupant1.next != nil || + occupants.newest != occupant1 { + t.Error("Broken") + } +} + +// ->[]->()->(.)->()-> +func TestSetMUCOccupantsLRUBumpMiddle(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // bump + occupants.Bump(occupant2) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant3 || + occupant3.prev != occupant1 || + occupant3.next != occupant2 || + occupant2.prev != occupant3 || + occupant2.next != nil || + occupants.newest != occupant2 { + t.Error("Broken") + } +} + +// ->[]->()->()->(.)-> +func TestSetMUCOccupantsLRUBumpLast(t *testing.T) { + // init + occupants, occupant1, occupant2, occupant3 := testMUCOccupantsLRUChainOfThree() + + // bump + occupants.Bump(occupant3) + + // checks + if occupants.oldest != occupant1 || + occupant1.prev != nil || + occupant1.next != occupant2 || + occupant2.prev != occupant1 || + occupant2.next != occupant3 || + occupant3.prev != occupant2 || + occupant3.next != nil || + occupants.newest != occupant3 { + t.Error("Broken") + } +} diff --git a/telegram/utils.go b/telegram/utils.go index 7fa13d3..8ff25b2 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -1,7 +1,9 @@ package telegram import ( + "bytes" "crypto/sha1" + "encoding/base64" "fmt" "github.com/pkg/errors" "io" @@ -11,9 +13,11 @@ import ( osUser "os/user" "path/filepath" "regexp" + "sort" "strconv" "strings" "time" + "unicode/utf8" "dev.narayana.im/narayana/telegabber/telegram/cache" "dev.narayana.im/narayana/telegabber/telegram/formatter" @@ -22,18 +26,119 @@ import ( log "github.com/sirupsen/logrus" "github.com/soheilhy/args" "github.com/zelenin/go-tdlib/client" + "gosrc.io/xmpp/stanza" +) + +type VCardInfo struct { + Fn string + Photo *client.File + Nicknames []string + Given string + Family string + Tel string + Info string +} + +type messageStub struct { + MessageId int64 + ChatId int64 + Sender string + Date int32 + Text string +} + +type BotCommand struct { + Command string + Description string +} + +type BotLink struct { + Description string + Link string +} + +const ( + typeFileDataSha1 byte = iota + typeFileDataBase64 ) var errOffline = errors.New("TDlib instance is offline") +var errOverLimit = errors.New("Over limit") var spaceRegex = regexp.MustCompile(`\s+`) var replyRegex = regexp.MustCompile("\\A>>? ?([0-9]+)\\n") const newlineChar string = "\n" -const messageHeaderSeparator string = " | " +const messageHeaderSeparator string = " | " // no hrunicode allowed here yet + +var bmpCeil = rune(0x0000ffff) + +// MembersList is an enum of member list filters +type MembersList int + +const ( + MembersListNone MembersList = iota + MembersListMembers + MembersListRestricted + MembersListBanned + MembersListBannedAndAdministrators + MembersListAdministrators + MembersListCreators +) + +const ( + MessageLimitMessages = iota + MessageLimitChars + MessageLimitSince +) + +// MessageLimitType is an enum of MUC history limit types +type MessageLimitType int + +// MessageLimit stores a MUC history limit +type MessageLimit struct { + Type MessageLimitType + Messages int32 + Chars int + Since int64 +} + +func NewMessageLimitMessages(stanzas int32) *MessageLimit { + var limit MessageLimit + limit.Type = MessageLimitMessages + limit.Messages = stanzas + return &limit +} + +func NewMessageLimitChars(chars int) *MessageLimit { + var limit MessageLimit + limit.Type = MessageLimitChars + limit.Chars = chars + return &limit +} + +func NewMessageLimitSince(since int64) *MessageLimit { + var limit MessageLimit + limit.Type = MessageLimitSince + limit.Since = since + return &limit +} + +const AVATAR_SIZE_LIMIT int64 = 128 * 1024 + +const ( + ChatMemberStatusIllegal = iota + ChatMemberStatusKicked + ChatMemberStatusMuted + ChatMemberStatusUnmuted + ChatMemberStatusBanned + ChatMemberStatusUnbanned + ChatMemberStatusPromoted +) +type ChatMemberStatus int // GetContactByUsername resolves username to user id retrieves user and chat information -func (c *Client) GetContactByUsername(username string) (*client.Chat, *client.User, error) { +func (c *Client) GetContactByUsername(username string, own bool) (*client.Chat, *client.User, error) { if !c.Online() { return nil, nil, errOffline } @@ -58,11 +163,11 @@ func (c *Client) GetContactByUsername(username string) (*client.Chat, *client.Us } } - return c.GetContactByID(userID, chat) + return c.GetContactByID(userID, chat, own) } // GetContactByID gets user and chat information from cache (or tries to retrieve it, if missing) -func (c *Client) GetContactByID(id int64, chat *client.Chat) (*client.Chat, *client.User, error) { +func (c *Client) GetContactByID(id int64, chat *client.Chat, own bool) (*client.Chat, *client.User, error) { if !c.Online() || id == 0 { return nil, nil, errOffline } @@ -97,9 +202,9 @@ func (c *Client) GetContactByID(id int64, chat *client.Chat) (*client.Chat, *cli return nil, nil, err } - c.cache.SetChat(id, cacheChat) + c.cache.SetChat(id, cacheChat, own) } else { - c.cache.SetChat(id, chat) + c.cache.SetChat(id, chat, own) } } if chat == nil { @@ -109,10 +214,22 @@ func (c *Client) GetContactByID(id int64, chat *client.Chat) (*client.Chat, *cli return chat, user, nil } -// IsPM checks if a chat is PM -func (c *Client) IsPM(id int64) (bool, error) { +// GetChatByID gets exactly a chat from a cache, or error if chat is not found +func (c *Client) GetChatByID(id int64, chat *client.Chat, own bool) (*client.Chat, error) { + chat, _, err := c.GetContactByID(id, nil, own) + if err != nil { + return nil, err + } else if chat == nil { + return nil, errors.New("Chat not found") + } + + return chat, nil +} + +// GetChatType obtains chat type from its information +func (c *Client) GetChatType(id int64, own bool) (gateway.ChatType, *client.Chat, error) { if !c.Online() || id == 0 { - return false, errOffline + return gateway.ChatTypeUnknown, nil, errOffline } var err error @@ -123,17 +240,75 @@ func (c *Client) IsPM(id int64) (bool, error) { ChatId: id, }) if err != nil { - return false, err + return gateway.ChatTypeUnknown, nil, err } - c.cache.SetChat(id, chat) + c.cache.SetChat(id, chat, own) } chatType := chat.Type.ChatTypeType() - if chatType == client.TypeChatTypePrivate || chatType == client.TypeChatTypeSecret { - return true, nil + if chatType == client.TypeChatTypePrivate { + return gateway.ChatTypePrivate, chat, nil + } else if chatType == client.TypeChatTypeBasicGroup { + return gateway.ChatTypeBasicGroup, chat, nil + } else if chatType == client.TypeChatTypeSupergroup { + supergroup, _ := chat.Type.(*client.ChatTypeSupergroup) + if supergroup.IsChannel { + return gateway.ChatTypeChannel, chat, nil + } + return gateway.ChatTypeSupergroup, chat, nil + } else if chatType == client.TypeChatTypeSecret { + return gateway.ChatTypeSecret, chat, nil } - return false, nil + + return gateway.ChatTypeUnknown, chat, errors.New("Unknown chat type") +} + +// IsPM checks if a chat is PM +func (c *Client) IsPM(id int64, own bool) (bool, *client.Chat, error) { + typ, chat, err := c.GetChatType(id, own) + if err != nil { + return false, chat, err + } + + if typ == gateway.ChatTypePrivate || typ == gateway.ChatTypeSecret { + return true, chat, nil + } + return false, chat, nil +} + +// IsBot checks if a chat is a bot +func (c *Client) IsBot(id int64, own bool) (bool, error) { + _, user, err := c.GetContactByID(id, nil, own) + if err != nil { + return false, err + } + if user == nil || user.Type == nil { + return false, nil + } + + return user.Type.UserTypeType() == client.TypeUserTypeBot, nil +} + +// GetBotMenu retrieves the bot's attachment menu +func (c *Client) GetBotMenu(id int64) (*BotLink, []*BotCommand, error) { + fullInfo, err := c.client.GetUserFullInfo(&client.GetUserFullInfoRequest{ + UserId: id, + }) + if err == nil && fullInfo.BotInfo != nil { + if fullInfo.BotInfo.MenuButton != nil { + menuButton := fullInfo.BotInfo.MenuButton + return &BotLink{menuButton.Text, menuButton.Url}, nil, nil + } else { + var commands []*BotCommand + for _, command := range fullInfo.BotInfo.Commands { + commands = append(commands, &BotCommand{command.Command, command.Description}) + } + return nil, commands, nil + } + } + + return nil, nil, err } func (c *Client) userStatusToText(status client.UserStatus, chatID int64) (string, string, string) { @@ -190,8 +365,119 @@ func (c *Client) LastSeenStatus(timestamp int64) string { Format("Last seen at 15:04 02/01/2006") } +func (c *Client) getFileData(tgFile *client.File, typ byte) string { + var priority int32 + if typ == typeFileDataSha1 { + priority = 1 + } else if typ == typeFileDataBase64 { + priority = 32 + } + + // avoid not-well-formed stanza errors + if typ == typeFileDataBase64 && c.GetPhotoSize(tgFile) > AVATAR_SIZE_LIMIT { + log.Warnf("Photo %v skipped as it's too huge", tgFile.Id) + return "" + } + + file, path, err := c.ForceOpenFile(tgFile, priority) + if err == nil { + defer file.Close() + + if typ == typeFileDataSha1 { + hash := sha1.New() + _, err = io.Copy(hash, file) + if err == nil { + return fmt.Sprintf("%x", hash.Sum(nil)) + } else { + log.Errorf("Error calculating hash: %v", path) + } + } else if typ == typeFileDataBase64 { + buf := new(bytes.Buffer) + binval := base64.NewEncoder(base64.StdEncoding, buf) + _, err = io.Copy(binval, file) + binval.Close() + if err == nil { + return buf.String() + } else { + log.Errorf("Error calculating base64: %v", path) + } + } + } else if path != "" { + log.Errorf("Photo does not exist: %v", path) + } else { + log.Errorf("PHOTO: %#v", err.Error()) + } + + return "" +} + +// SetEmptyAvatarHash puts a dummy value into the cache to avoid attempting to fetch surely missing avatars +func (c *Client) SetEmptyAvatarHash(chatId int64) { + c.avatarHashesLock.Lock() + c.avatarHashes[chatId] = &gateway.HashedAvatar{ + Hash: "", + File: 0, + } + c.avatarHashesLock.Unlock() +} + +// GetPhotoSize return at least a rough size +func (c *Client) GetPhotoSize(photo *client.File) int64 { + if photo == nil { + return 0 + } + size := photo.Size + if size == 0 { + size = photo.ExpectedSize + } + return size +} + +// GetPhotoSha1 computes the photo hash +func (c *Client) GetPhotoSha1(photo *client.File, chatId int64) string { + sha1 := c.getFileData(photo, typeFileDataSha1) + c.avatarHashesLock.Lock() + c.avatarHashes[chatId] = &gateway.HashedAvatar{ + Hash: sha1, + File: photo.Id, + } + c.avatarHashesLock.Unlock() + return sha1 +} + +// GetPhotoBase64 reads file data as Base64 +func (c *Client) GetPhotoBase64(photo *client.File) string { + return c.getFileData(photo, typeFileDataBase64) +} + +// GetHashedAvatar obtain the avatar hash from cache or requests the avatar file immediately to calculate it +func (c *Client) GetHashedAvatar(chatId int64) *gateway.HashedAvatar { + c.avatarHashesLock.Lock() + hashedAvatar, ok := c.avatarHashes[chatId] + c.avatarHashesLock.Unlock() + + if !ok { + log.Info("Could not find avatar in cache, fetching immediately") + + chat, _, err := c.GetContactByID(chatId, nil, true) + if err != nil || chat == nil || chat.Photo == nil { + return nil + } + + file := chat.Photo.Small + + sha1 := c.GetPhotoSha1(file, chatId) + hashedAvatar = &gateway.HashedAvatar{ + Hash: sha1, + File: file.Id, + } + } + + return hashedAvatar +} + // ProcessStatusUpdate sets contact status -func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, oldArgs ...args.V) error { +func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, own bool, oldArgs ...args.V) error { if !c.Online() { return nil } @@ -200,27 +486,23 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o "chat_id": chatID, }).Info("Status update for") - chat, user, err := c.GetContactByID(chatID, nil) + chat, user, err := c.GetContactByID(chatID, nil, own) if err != nil { return err } + var isMUC bool + if chat != nil && c.Session.MUC && c.IsGroup(chat) { + // allow MUC presence hack for avatars, still discard the rest + if status != "" || show != "" { + return nil + } + isMUC = true + } + var photo string if chat != nil && chat.Photo != nil { - file, path, err := c.OpenPhotoFile(chat.Photo.Small, 1) - if err == nil { - defer file.Close() - - hash := sha1.New() - _, err = io.Copy(hash, file) - if err == nil { - photo = fmt.Sprintf("%x", hash.Sum(nil)) - } else { - log.Errorf("Error calculating hash: %v", path) - } - } else if path != "" { - log.Errorf("Photo does not exist: %v", path) - } + photo = c.GetPhotoSha1(chat.Photo.Small, chatID) } var presenceType string @@ -228,58 +510,568 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o presenceType = gateway.SPType.Get(oldArgs) } - cachedStatus, ok := c.cache.GetStatus(chatID) - if status == "" { - if ok { - show, status = cachedStatus.XMPP, cachedStatus.Description - } else if user != nil && user.Status != nil { - show, status, presenceType = c.userStatusToText(user.Status, chatID) - } else { - show, status = "chat", chat.Title + // skip cache for MUCs + if !isMUC { + cachedStatus, ok := c.cache.GetStatus(chatID) + if status == "" { + if ok { + var typ string + show, status, typ = cachedStatus.Destruct() + if presenceType == "" { + presenceType = typ + } + log.WithFields(log.Fields{ + "show": show, + "status": status, + "presenceType": presenceType, + }).Debug("Cached status") + } else if user != nil && user.Status != nil { + show, status, presenceType = c.userStatusToText(user.Status, chatID) + log.WithFields(log.Fields{ + "show": show, + "status": status, + "presenceType": presenceType, + }).Debug("Status to text") + } else { + show, status = "chat", chat.Title + } } + + cacheShow := show + if presenceType == "unavailable" { + cacheShow = presenceType + } + c.cache.SetStatus(chatID, cacheShow, status) } - c.cache.SetStatus(chatID, show, status) - newArgs := []args.V{ - gateway.SPFrom(strconv.FormatInt(chatID, 10)), gateway.SPShow(show), gateway.SPStatus(status), gateway.SPPhoto(photo), - gateway.SPResource(gateway.Jid.Resource), gateway.SPImmed(gateway.SPImmed.Get(oldArgs)), } if presenceType != "" { newArgs = append(newArgs, gateway.SPType(presenceType)) } - ver, ok := c.cache.GetCapsVer(chatID) - if !ok { - if c.isCallable(chat, user) { - ver, err = gateway.GetCapsVer([]gateway.CapsType{gateway.CapsAudio}}) + c.locks.mucCacheLock.Lock() + chatJid := gateway.CHATJID(chatID, true) + for mucId, state := range c.mucCache { + occupant, ok := state.Occupants.Get(chatID) + if ok { + if show == "" { + // Bump occupants who just went online + state.Occupants.Bump(occupant) + } + + _, toJids := c.getMUCJoinedJIDs(mucId, state, false) + newMucArgs := append( + newArgs, + gateway.SPFrom(gateway.MUCNODE(mucId)), + gateway.SPResource(occupant.Nickname), + gateway.SPMUCAffiliation(occupant.Affiliation), + gateway.SPMUCRole(occupant.Role), + gateway.SPMUCJid(chatJid), + gateway.SPToJids(toJids), + ) + err := c.sendPresence(newMucArgs...) if err != nil { - log.Errorf("", err.Error()) + c.locks.mucCacheLock.Unlock() + return err } } - c.cache.SetCapsVer(ver) } - if ver != "" { - newArgs = append(newArgs, gateway.SPCaps(ver)) + c.locks.mucCacheLock.Unlock() + + if isMUC { + newArgs = append(newArgs, gateway.SPFullFrom(gateway.MUCJID(chatID))) + } else { + newArgs = gateway.SPAppendFrom(newArgs, chatID) + + ver, di, ok := c.cache.GetCapsVer(chatID) + if !ok { + if c.CanBeCalled(chatID) { + var errorCode int + ver, di, errorCode = gateway.GetCapsVer(c, fmt.Sprintf("%v@%s", chatID, gateway.Jid)) + if errorCode != 0 { + log.Errorf("", errorCode) + } else { + c.cache.SetCapsVer(chatID, ver, di) + } + } + } + if ver != "" { + newArgs = append(newArgs, gateway.SPCaps(ver)) + } } - return gateway.SendPresence( - c.xmpp, - c.jid, - newArgs..., + return c.sendPresence(newArgs...) +} + +// JoinMUC saves MUC join fact and sends initialization data +func (c *Client) JoinMUC(chatId int64, resource string, limit *MessageLimit) { + // save the nickname in this MUC, also as a marker of join + c.locks.mucCacheLock.Lock() + mucState, ok := c.mucCache[chatId] + if !ok || mucState == nil { + mucState = NewMUCState() + c.mucCache[chatId] = mucState + } + _, ok = mucState.Resources[resource] + if ok { + // already joined, initializing anyway + } else { + mucState.Resources[resource] = true + } + c.locks.mucCacheLock.Unlock() + + log.Debugf("Resources in MUC %v: %v", chatId, mucState.Resources) + + c.sendMUCStatuses(chatId) + + messages, err := c.getNLastMessages(chatId, limit) + if err == nil { + c.sendMessagesReverse(chatId, messages, false, c.jid+"/"+resource) + } + + c.sendMUCSubject(chatId, resource) + + c.client.OpenChat(&client.OpenChatRequest{ + ChatId: chatId, + }) +} + +// LeaveMUC removes MUC date from the cache +func (c *Client) LeaveMUC(chatId int64, resource string) { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + mucState, ok := c.mucCache[chatId] + if !ok || mucState == nil { + return + } + delete(mucState.Resources, resource) + log.Debugf("Resources in MUC %v: %v", chatId, mucState.Resources) + + if len(mucState.Resources) == 0 { + delete(c.mucCache, chatId) + } + + c.client.CloseChat(&client.CloseChatRequest{ + ChatId: chatId, + }) +} + +// DestroyMUC removes everyone from the MUC +func (c *Client) DestroyMUC(chatId int64) error { + err := c.DeleteChat(chatId) + if err != nil { + return err + } + + c.deleteMUC(chatId, nil, true) + + return nil +} + +func (c *Client) deleteMUC(chatId int64, statusCodes []uint16, destroy bool) { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + mucState, ok := c.mucCache[chatId] + if !ok || mucState == nil { + return + } + + c.kickMeFromMUC(chatId, statusCodes, destroy, mucState) + delete(c.mucCache, chatId) +} + +func (c *Client) sendMUCStatuses(chatID int64) { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + mucState, ok := c.mucCache[chatID] + if !ok || mucState == nil { + mucState = NewMUCState() + c.mucCache[chatID] = mucState + } + + members, err := c.client.SearchChatMembers(&client.SearchChatMembersRequest{ + ChatId: chatID, + Limit: MUCOccupantsLimit, + Filter: &client.ChatMembersFilterMembers{}, + }) + if err == nil { + c.updateMUCOccupants(mucState, chatID, members.Members) + } +} + +// achtung: assuming a locked mucState context +func (c *Client) updateMUCOccupants(mucState *MUCState, chatID int64, members []*client.ChatMember) { + sChatId := gateway.MUCNODE(chatID) + myNickname := "me" + var myJid string + if c.me != nil { + myNickname = c.GetMUCNickname(c.me.Id) + myJid = gateway.CHATJID(c.me.Id, true) + } + myAffiliation := "member" + myRole := "participant" + + chat, _, _ := c.GetContactByID(chatID, nil, true) + + _, toJids := c.getMUCJoinedJIDs(chatID, mucState, false) + + for _, member := range members { + senderId, nickname, affiliation, role := c.TgMemberToMUCOccupant(member, chat) + mucState.Occupants.Set(senderId, &MUCOccupant{ + Nickname: nickname, + Affiliation: affiliation, + Role: role, + Status: member.Status, + }) + + if c.me != nil && senderId == c.me.Id { + myNickname = nickname + myAffiliation = affiliation + myRole = role + continue + } + + c.sendPresence( + gateway.SPFrom(sChatId), + gateway.SPResource(nickname), + gateway.SPImmed(true), + gateway.SPMUCAffiliation(affiliation), + gateway.SPMUCRole(role), + gateway.SPMUCJid(gateway.CHATJID(senderId, true)), + gateway.SPToJids(toJids), + ) + } + + // according to the spec, own occupant entry should be sent the last + selfStatusCodes := []uint16{100, 110, 210} + if chat != nil && chat.Type.ChatTypeType() == client.TypeChatTypeSupergroup { + selfStatusCodes = append(selfStatusCodes, 170) + } + c.sendPresence( + gateway.SPFrom(sChatId), + gateway.SPResource(myNickname), + gateway.SPImmed(true), + gateway.SPMUCAffiliation(myAffiliation), + gateway.SPMUCRole(myRole), + gateway.SPMUCJid(myJid), + gateway.SPMUCStatusCodes(selfStatusCodes), + gateway.SPToJids(toJids), ) } -func (c *Client) formatContact(chatID int64) string { +func (c *Client) assureMUCOccupant(chatId, senderId int64, messageSender client.MessageSender, chat *client.Chat) bool { + safeToSend := true + if !c.mucCacheHasOccupant(chatId, senderId) { + chatMember, err := c.client.GetChatMember(&client.GetChatMemberRequest{ + ChatId: chatId, + MemberId: messageSender, + }) + var status client.ChatMemberStatus + if err == nil { + status = chatMember.Status + } + affiliation, role := c.memberStatusToAffiliationAndRole(status, chat) + safeToSend = c.addMUCOccupant(chatId, senderId, affiliation, role, status) + } + return safeToSend +} + +func (c *Client) mucCacheHasOccupant(mucID int64, memberID int64) bool { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + mucState, ok := c.mucCache[mucID] + if !ok || mucState == nil { + return false // no MUC to be added to + } + + occupant, ok := mucState.Occupants.Get(memberID) + if ok { + mucState.Occupants.Bump(occupant) + } + return ok +} + +func (c *Client) addMUCOccupant(mucID int64, memberID int64, affiliation, role string, status client.ChatMemberStatus) bool { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + mucState, ok := c.mucCache[mucID] + if !ok || mucState == nil { + return false + } + + nickname := c.GetMUCNickname(memberID) + + _, toJids := c.getMUCJoinedJIDs(mucID, mucState, false) + + err := c.sendPresence( + gateway.SPFrom(gateway.MUCNODE(mucID)), + gateway.SPResource(nickname), + gateway.SPImmed(true), + gateway.SPMUCAffiliation(affiliation), + gateway.SPMUCRole(role), + gateway.SPMUCJid(gateway.CHATJID(memberID, true)), + gateway.SPToJids(toJids), + ) + + if err == nil { + deleted := mucState.Occupants.Set(memberID, &MUCOccupant{ + Nickname: nickname, + Affiliation: affiliation, + Role: role, + Status: status, + }) + c.kickStaleOccupant(mucID, deleted, mucState) + + return true + } + + return false +} + +func (c *Client) sendMUCSubject(chatID int64, resource string) { + pin, err := c.client.GetChatPinnedMessage(&client.GetChatPinnedMessageRequest{ + ChatId: chatID, + }) + mucJid := gateway.MUCJID(chatID) + toJid := c.jid + "/" + resource + if err == nil { + gateway.SendSubjectMessage( + toJid, + mucJid + "/" + c.GetMUCNickname(c.getMessageSenderId(pin)), + c.messageToText(pin, false), + strconv.FormatInt(pin.Id, 10), + c.xmpp, + int64(pin.Date), + ) + } else { + gateway.SendSubjectMessage(toJid, mucJid, "", "", c.xmpp, 0) + } +} + +// GetMUCNickname generates a unique nickname for a MUC occupant +func (c *Client) GetMUCNickname(chatID int64) string { + if chatID == 0 { + if c.me != nil { + chatID = c.me.Id + } else { + return "me" + } + } + fc := c.FormatContact(chatID) + rp, err := gateway.ResourcePrep(fc) + if err == nil { + // additionally check for non-BMP characters + for _, r := range rp { + if r > bmpCeil { + err = errors.New("Non-BMP character") + break + } + } + } + if err != nil { + log.Warnf("Resourceprep for %v failed, falling back to chat ID", fc) + + var usernames string + _, user, _ := c.GetContactByID(chatID, nil, false) + if user != nil && user.Usernames != nil { + usernames = c.usernamesToString(user.Usernames.ActiveUsernames) + } + + nickname := strconv.FormatInt(chatID, 10) + if usernames != "" { + nickname = fmt.Sprintf("%s (%v)", nickname, usernames) + } else { + nickname = fmt.Sprintf("(%s)", nickname) + } + return nickname + } + if rp != fc { + log.Debugf("Corrected resource: %v -> %v", fc, rp) + } + return rp +} + +func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + realJid := gateway.CHATJID(memberID, true) + for mucId, state := range c.mucCache { + oldOccupant, ok := state.Occupants.Get(memberID) + if ok { + c.DelayedStatusesLock.Lock() + delete(c.DelayedStatuses, mucId) + c.DelayedStatusesLock.Unlock() + + deleted := state.Occupants.Set(memberID, &MUCOccupant{ + Nickname: newNickname, + Affiliation: oldOccupant.Affiliation, + Role: oldOccupant.Role, + Status: oldOccupant.Status, + }) + c.kickStaleOccupant(mucId, deleted, state) + + sMucId := gateway.MUCNODE(mucId) + unavailableStatusCodes := []uint16{303, 210} + availableStatusCodes := []uint16{100, 210} + if c.me != nil && memberID == c.me.Id { + unavailableStatusCodes = append(unavailableStatusCodes, 110) + availableStatusCodes = append(availableStatusCodes, 110) + } + _, toJids := c.getMUCJoinedJIDs(mucId, state, false) + c.sendPresence( + gateway.SPType("unavailable"), + gateway.SPFrom(sMucId), + gateway.SPResource(oldOccupant.Nickname), + gateway.SPImmed(true), + gateway.SPMUCAffiliation(oldOccupant.Affiliation), + gateway.SPMUCRole(oldOccupant.Role), + gateway.SPMUCNick(newNickname), + gateway.SPMUCStatusCodes(unavailableStatusCodes), + gateway.SPMUCJid(realJid), + gateway.SPToJids(toJids), + ) + c.sendPresence( + gateway.SPFrom(sMucId), + gateway.SPResource(newNickname), + gateway.SPImmed(true), + gateway.SPMUCAffiliation(oldOccupant.Affiliation), + gateway.SPMUCRole(oldOccupant.Role), + gateway.SPMUCStatusCodes(availableStatusCodes), + gateway.SPMUCJid(realJid), + gateway.SPToJids(toJids), + ) + } + } +} + +// MUCHasResource checks if a MUC was joined from a given resource +func (c *Client) MUCHasResource(chatID int64, resource string) bool { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + mucState, ok := c.mucCache[chatID] + if !ok || mucState == nil { + return false + } + _, ok = mucState.Resources[resource] + return ok +} + +func (c *Client) getMUCJoinedJIDs(chatId int64, mucState *MUCState, lock bool) (bool, []string) { + if lock { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + } + + groupChatTos := []string{} + + if mucState == nil { + mucState, _ = c.mucCache[chatId] + } + if mucState == nil { + return false, nil + } + + for resource := range mucState.Resources { + groupChatTos = append(groupChatTos, c.jid + "/" + resource) + } + + return true, groupChatTos +} + +// GetMyMUCNickname obtains this account's nickname in a given MUC +func (c *Client) GetMyMUCNickname(chatID int64) (string, bool) { + if c.me == nil { + return "", false + } + + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + mucState, ok := c.mucCache[chatID] + if !ok || mucState == nil { + return "", false + } + occupant, ok := mucState.Occupants.Get(c.me.Id) + if !ok { + return "", false + } + mucState.Occupants.Bump(occupant) + return occupant.Nickname, true +} + +// GetMUCMemberIdByNickname looks up the telegram ID by the MUC nickname +func (c *Client) GetMUCMemberIdByNickname(chatID int64, nickname string) int64 { + c.locks.mucCacheLock.Lock() + defer c.locks.mucCacheLock.Unlock() + + mucState, ok := c.mucCache[chatID] + if !ok || mucState == nil { + return 0 + } + + id, ok := mucState.Occupants.GetIdByNickname(nickname) + if ok { + return id + } + + return 0 +} + +// NewPinnedMessage sends a text message and pins it right away +func (c *Client) NewPinnedMessage(chatID int64, text, returnJid string) bool { + c.locks.pinOutboxLock.Lock() + msg, _ := c.ProcessOutgoingMessage(chatID, text, returnJid, 0, 0, true, true) + if msg == nil { + c.locks.pinOutboxLock.Unlock() + return false + } + ch := make(chan int64) + key := IntPair{chatID, msg.Id} + c.pinOutbox[key] = ch + c.locks.pinOutboxLock.Unlock() + + newId := <-ch + + c.locks.pinOutboxLock.Lock() + delete(c.pinOutbox, key) + c.locks.pinOutboxLock.Unlock() + + if newId == 0 { + return false + } + + ok, err := c.client.PinChatMessage(&client.PinChatMessageRequest{ + ChatId: chatID, + MessageId: newId, + }) + if err != nil { + log.Errorf("failed to pin message: %v", err.Error()) + c.client.DeleteMessages(&client.DeleteMessagesRequest{ + ChatId: chatID, + MessageIds: []int64{msg.Id}, + Revoke: true, + }) + } + + return ok != nil +} + +// FormatContact retrieves a complete "full name (@usernames)" string for display +func (c *Client) FormatContact(chatID int64) string { if chatID == 0 { return "" } - chat, user, err := c.GetContactByID(chatID, nil) + chat, user, err := c.GetContactByID(chatID, nil, false) if err != nil { return "unknown contact: " + err.Error() } @@ -288,12 +1080,15 @@ func (c *Client) formatContact(chatID int64) string { if chat != nil { str = fmt.Sprintf("%s (%v)", chat.Title, chat.Id) } else if user != nil { - username := user.Username - if username == "" { - username = strconv.FormatInt(user.Id, 10) + var usernames string + if user.Usernames != nil { + usernames = c.usernamesToString(user.Usernames.ActiveUsernames) + } + if usernames == "" { + usernames = strconv.FormatInt(user.Id, 10) } - str = fmt.Sprintf("%s %s (%v)", user.FirstName, user.LastName, username) + str = fmt.Sprintf("%s %s (%v)", user.FirstName, user.LastName, usernames) } else { str = strconv.FormatInt(chatID, 10) } @@ -303,47 +1098,106 @@ func (c *Client) formatContact(chatID int64) string { return str } -func (c *Client) getSenderId(message *client.Message) (senderId int64) { - if message.SenderId != nil { - switch message.SenderId.MessageSenderType() { - case client.TypeMessageSenderUser: - senderUser, _ := message.SenderId.(*client.MessageSenderUser) - senderId = senderUser.UserId - case client.TypeMessageSenderChat: - senderChat, _ := message.SenderId.(*client.MessageSenderChat) - senderId = senderChat.ChatId - } +// GetSenderId extracts a sender id from a message +func (c *Client) GetSenderId(sender client.MessageSender) (senderId int64) { + switch sender.MessageSenderType() { + case client.TypeMessageSenderUser: + senderUser, _ := sender.(*client.MessageSenderUser) + senderId = senderUser.UserId + case client.TypeMessageSenderChat: + senderChat, _ := sender.(*client.MessageSenderChat) + senderId = senderChat.ChatId } + return +} +func (c *Client) getMessageSenderId(message *client.Message) (senderId int64) { + if message.SenderId != nil { + senderId = c.GetSenderId(message.SenderId) + } return } func (c *Client) formatSender(message *client.Message) string { - return c.formatContact(c.getSenderId(message)) + return c.FormatContact(c.getMessageSenderId(message)) } -func (c *Client) getMessageReply(message *client.Message) (reply *gateway.Reply, replyMsg *client.Message) { - if message.ReplyToMessageId != 0 { - var err error - replyMsg, err = c.client.GetMessage(&client.GetMessageRequest{ - ChatId: message.ChatId, - MessageId: message.ReplyToMessageId, - }) - if err != nil { - log.Errorf("", err.Error()) - return - } +func (c *Client) messageToStub(message *client.Message, preview bool, text string) *messageStub { + if text == "" { + text = c.messageContentToText(message.Content, message.ChatId, preview) + } + return &messageStub{ + MessageId: message.Id, + ChatId: message.ChatId, + Sender: c.formatSender(message), + Date: message.Date, + Text: text, + } +} - reply = &gateway.Reply{ - Author: fmt.Sprintf("%v@%s", c.getSenderId(replyMsg), gateway.Jid.Full()), - Id: strconv.FormatInt(message.ReplyToMessageId, 10), +func (c *Client) getMessageReply(message *client.Message, preview bool, noContent bool) (gatewayReply *gateway.Reply, tgReply *messageStub) { + if message.ReplyTo != nil && message.ReplyTo.MessageReplyToType() == client.TypeMessageReplyToMessage { + replyTo, _ := message.ReplyTo.(*client.MessageReplyToMessage) + var text string + if replyTo.Quote != nil && replyTo.Quote.Text != nil && !noContent { + text = formatter.Format( + replyTo.Quote.Text.Text, + replyTo.Quote.Text.Entities, + c.getFormatter(), + c, + ) + // make the whole quote fit one line + text = strings.ReplaceAll(text, "\n", " ") + } + if message.ChatId == replyTo.ChatId { + // obtain message from this chat + replyMsg, err := c.client.GetMessage(&client.GetMessageRequest{ + ChatId: message.ChatId, + MessageId: replyTo.MessageId, + }) + if err != nil { + log.Errorf("", err.Error()) + return + } + + if !noContent { + tgReply = c.messageToStub(replyMsg, preview, text) + } + + replyId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, message.ChatId, replyTo.MessageId) + if err != nil { + replyId = strconv.FormatInt(replyTo.MessageId, 10) + } + + gatewayReply = &gateway.Reply{ + Author: fmt.Sprintf("%v@%s", c.getMessageSenderId(replyMsg), gateway.Jid.Full()), + Id: replyId, + } + } else if !noContent { + // it's safe to assume there's no need to pass ChatId here + // as it's needed only for pin messages which are not allowed in replies + if text == "" && replyTo.Content != nil { + text = c.messageContentToText(replyTo.Content, 0, preview) + } + + if text == "" { + log.Error("Empty reply from other/unknown chat") + log.Debugf("replyTo: %#v", replyTo) + return + } + + tgReply = &messageStub{ + Sender: c.formatOrigin(replyTo.Origin) + " @ " + c.FormatContact(replyTo.ChatId), + Date: replyTo.OriginSendDate, + Text: text, + } } } return } -func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, message *client.Message) string { +func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, sender bool, message *client.Message) string { var err error if message == nil { message, err = c.client.GetMessage(&client.GetMessageRequest{ @@ -359,9 +1213,18 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess return "" } + return c.formatMessageContent(preview, c.messageToStub(message, preview, ""), sender) +} + +func (c *Client) formatMessageContent(preview bool, message *messageStub, sender bool) string { var str strings.Builder // add messageid and sender - str.WriteString(fmt.Sprintf("%v | %s | ", message.Id, c.formatSender(message))) + if message.MessageId != 0 { + str.WriteString(fmt.Sprintf("%v | ", message.MessageId)) + } + if sender { + str.WriteString(fmt.Sprintf("%s | ", message.Sender)) + } // add date if !preview { str.WriteString( @@ -372,10 +1235,7 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess } // text message - var text string - if message.Content != nil { - text = c.messageToText(message, preview) - } + text := message.Text if text != "" { if !preview { str.WriteString(text) @@ -392,36 +1252,51 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess return str.String() } -func (c *Client) formatForward(fwd *client.MessageForwardInfo) string { - switch fwd.Origin.MessageForwardOriginType() { - case client.TypeMessageForwardOriginUser: - originUser := fwd.Origin.(*client.MessageForwardOriginUser) - return c.formatContact(originUser.SenderUserId) - case client.TypeMessageForwardOriginChat: - originChat := fwd.Origin.(*client.MessageForwardOriginChat) +func (c *Client) formatOrigin(origin client.MessageOrigin) string { + if origin == nil { + return "" + } + switch origin.MessageOriginType() { + case client.TypeMessageOriginUser: + originUser := origin.(*client.MessageOriginUser) + return c.FormatContact(originUser.SenderUserId) + case client.TypeMessageOriginChat: + originChat := origin.(*client.MessageOriginChat) var signature string if originChat.AuthorSignature != "" { signature = fmt.Sprintf(" (%s)", originChat.AuthorSignature) } - return c.formatContact(originChat.SenderChatId) + signature - case client.TypeMessageForwardOriginHiddenUser: - originUser := fwd.Origin.(*client.MessageForwardOriginHiddenUser) + return c.FormatContact(originChat.SenderChatId) + signature + case client.TypeMessageOriginHiddenUser: + originUser := origin.(*client.MessageOriginHiddenUser) return originUser.SenderName - case client.TypeMessageForwardOriginChannel: - channel := fwd.Origin.(*client.MessageForwardOriginChannel) + case client.TypeMessageOriginChannel: + channel := origin.(*client.MessageOriginChannel) var signature string if channel.AuthorSignature != "" { signature = fmt.Sprintf(" (%s)", channel.AuthorSignature) } - return c.formatContact(channel.ChatId) + signature - case client.TypeMessageForwardOriginMessageImport: - originImport := fwd.Origin.(*client.MessageForwardOriginMessageImport) - return originImport.SenderName + return c.FormatContact(channel.ChatId) + signature } - return "Unknown forward type" + return "Unknown origin type" } func (c *Client) formatFile(file *client.File, compact bool) (string, string) { + if file == nil { + return "", "" + } + log.Debugf("formatFile: %v %#v %#v", c.jid, file.Local, file.Remote) + src, link := c.PermastoreFile(file, false) + + if compact { + return link, link + } else { + return fmt.Sprintf("%s (%v kbytes) | %s", filepath.Base(src), file.Size/1024, link), link + } +} + +// PermastoreFile steals a file out of TDlib control into an independent shared directory +func (c *Client) PermastoreFile(file *client.File, clone bool) (string, string) { log.Debugf("file: %#v", file) if file == nil || file.Local == nil || file.Remote == nil { return "", "" @@ -444,22 +1319,71 @@ func (c *Client) formatFile(file *client.File, compact bool) (string, string) { size64 := uint64(file.Size) c.prepareDiskSpace(size64) + // detect uploading files, there's no remote id for them yet + c.locks.uploadingFilesLock.Lock() + var ok bool + link, ok = c.uploadingFiles[file.Id] + if ok && !file.Local.CanBeDeleted { + defer c.locks.uploadingFilesLock.Unlock() + return src, link + } + c.locks.uploadingFilesLock.Unlock() + basename := file.Remote.UniqueId + filepath.Ext(src) dest := c.content.Path + "/" + basename // destination path link = c.content.Link + "/" + basename // download link - // move - err = os.Rename(src, dest) - if err != nil { - linkErr := err.(*os.LinkError) - if linkErr.Err.Error() == "file exists" { - log.Warn(err.Error()) + if clone { + file, path, err := c.ForceOpenFile(file, 1) + if err == nil { + defer file.Close() + + // mode + mode := os.FileMode(0644) + fi, err := os.Stat(path) + if err == nil { + mode = fi.Mode().Perm() + } + + // create destination + tempFile, err := os.OpenFile(dest, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + pathErr := err.(*os.PathError) + if pathErr.Err.Error() == "file exists" { + log.Warn(err.Error()) + return src, link + } else { + log.Errorf("File creation error: %v", err) + return "", "" + } + } + defer tempFile.Close() + // copy + _, err = io.Copy(tempFile, file) + if err != nil { + log.Errorf("File copying error: %v", err) + return "", "" + } + } else if path != "" { + log.Errorf("Source file does not exist: %v", path) + return "", "" } else { - log.Errorf("File moving error: %v", err) + log.Errorf("PHOTO: %#v", err.Error()) return "", "" } + } else { + // move + err = os.Rename(src, dest) + if err != nil { + linkErr := err.(*os.LinkError) + if linkErr.Err.Error() == "file exists" { + log.Warn(err.Error()) + } else { + log.Errorf("File moving error: %v", err) + return "", "" + } + } } - gateway.CachedStorageSize += size64 // chown if c.content.User != "" { @@ -478,13 +1402,12 @@ func (c *Client) formatFile(file *client.File, compact bool) (string, string) { log.Errorf("Wrong user name for chown: %v", err) } } + + // copy or move should have succeeded at this point + gateway.CachedStorageSize += size64 } - if compact { - return link, link - } else { - return fmt.Sprintf("%s (%v kbytes) | %s", filepath.Base(src), file.Size/1024, link), link - } + return src, link } func (c *Client) formatBantime(hours int64) int32 { @@ -512,41 +1435,45 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { return "" } - markupFunction := formatter.EntityToXEP0393 - switch message.Content.MessageContentType() { + return c.messageContentToText(message.Content, message.ChatId, preview) +} + +func (c *Client) messageContentToText(content client.MessageContent, chatId int64, preview bool) string { + markupMode := c.getFormatter() + switch content.MessageContentType() { case client.TypeMessageSticker: - sticker, _ := message.Content.(*client.MessageSticker) + sticker, _ := content.(*client.MessageSticker) return sticker.Sticker.Emoji case client.TypeMessageAnimatedEmoji: - animatedEmoji, _ := message.Content.(*client.MessageAnimatedEmoji) + animatedEmoji, _ := content.(*client.MessageAnimatedEmoji) return animatedEmoji.Emoji case client.TypeMessageBasicGroupChatCreate, client.TypeMessageSupergroupChatCreate: return "has created chat" case client.TypeMessageChatJoinByLink: return "joined chat via invite link" case client.TypeMessageChatAddMembers: - addMembers, _ := message.Content.(*client.MessageChatAddMembers) + addMembers, _ := content.(*client.MessageChatAddMembers) text := "invited " if len(addMembers.MemberUserIds) > 0 { - text += c.formatContact(addMembers.MemberUserIds[0]) + text += c.FormatContact(addMembers.MemberUserIds[0]) } return text case client.TypeMessageChatDeleteMember: - deleteMember, _ := message.Content.(*client.MessageChatDeleteMember) - return "kicked " + c.formatContact(deleteMember.UserId) + deleteMember, _ := content.(*client.MessageChatDeleteMember) + return "kicked " + c.FormatContact(deleteMember.UserId) case client.TypeMessagePinMessage: - pinMessage, _ := message.Content.(*client.MessagePinMessage) - return "pinned message: " + c.formatMessage(message.ChatId, pinMessage.MessageId, preview, nil) + pinMessage, _ := content.(*client.MessagePinMessage) + return "pinned message: " + c.formatMessage(chatId, pinMessage.MessageId, preview, true, nil) case client.TypeMessageChatChangeTitle: - changeTitle, _ := message.Content.(*client.MessageChatChangeTitle) + changeTitle, _ := content.(*client.MessageChatChangeTitle) return "chat title set to: " + changeTitle.Title case client.TypeMessageLocation: - location, _ := message.Content.(*client.MessageLocation) + location, _ := content.(*client.MessageLocation) return c.formatLocation(location.Location) case client.TypeMessageVenue: - venue, _ := message.Content.(*client.MessageVenue) + venue, _ := content.(*client.MessageVenue) if preview { return venue.Venue.Title } else { @@ -558,86 +1485,93 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { ) } case client.TypeMessagePhoto: - photo, _ := message.Content.(*client.MessagePhoto) + photo, _ := content.(*client.MessagePhoto) if preview { return photo.Caption.Text } else { return formatter.Format( photo.Caption.Text, photo.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageAudio: - audio, _ := message.Content.(*client.MessageAudio) + audio, _ := content.(*client.MessageAudio) if preview { return audio.Caption.Text } else { return formatter.Format( audio.Caption.Text, audio.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageVideo: - video, _ := message.Content.(*client.MessageVideo) + video, _ := content.(*client.MessageVideo) if preview { return video.Caption.Text } else { return formatter.Format( video.Caption.Text, video.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageDocument: - document, _ := message.Content.(*client.MessageDocument) + document, _ := content.(*client.MessageDocument) if preview { return document.Caption.Text } else { return formatter.Format( document.Caption.Text, document.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageText: - text, _ := message.Content.(*client.MessageText) + text, _ := content.(*client.MessageText) if preview { return text.Text.Text } else { return formatter.Format( text.Text.Text, text.Text.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageVoiceNote: - voice, _ := message.Content.(*client.MessageVoiceNote) + voice, _ := content.(*client.MessageVoiceNote) if preview { return voice.Caption.Text } else { return formatter.Format( voice.Caption.Text, voice.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageVideoNote: return "" case client.TypeMessageAnimation: - animation, _ := message.Content.(*client.MessageAnimation) + animation, _ := content.(*client.MessageAnimation) if preview { return animation.Caption.Text } else { return formatter.Format( animation.Caption.Text, animation.Caption.Entities, - markupFunction, + markupMode, + c, ) } case client.TypeMessageContact: - contact, _ := message.Content.(*client.MessageContact) + contact, _ := content.(*client.MessageContact) if preview { return contact.Contact.FirstName + " " + contact.Contact.LastName } else { @@ -655,10 +1589,10 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { ) } case client.TypeMessageDice: - dice, _ := message.Content.(*client.MessageDice) + dice, _ := content.(*client.MessageDice) return fmt.Sprintf("%s 1d6: [%v]", dice.Emoji, dice.Value) case client.TypeMessagePoll: - poll, _ := message.Content.(*client.MessagePoll) + poll, _ := content.(*client.MessagePoll) if preview { return poll.Poll.Question @@ -683,9 +1617,25 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { return strings.Join(rows, "\n") } + case client.TypeMessageChatSetMessageAutoDeleteTime: + ttl, _ := content.(*client.MessageChatSetMessageAutoDeleteTime) + name := c.FormatContact(ttl.FromUserId) + if name == "" { + if ttl.MessageAutoDeleteTime == 0 { + return "The self-destruct timer was disabled" + } else { + return fmt.Sprintf("The self-destruct timer was set to %v seconds", ttl.MessageAutoDeleteTime) + } + } else { + if ttl.MessageAutoDeleteTime == 0 { + return fmt.Sprintf("%s disabled the self-destruct timer", name) + } else { + return fmt.Sprintf("%s set the self-destruct timer to %v seconds", name, ttl.MessageAutoDeleteTime) + } + } } - return fmt.Sprintf("unknown message (%s)", message.Content.MessageContentType()) + return fmt.Sprintf("unknown message (%s)", content.MessageContentType()) } func (c *Client) contentToFile(content client.MessageContent) (*client.File, *client.File) { @@ -697,7 +1647,7 @@ func (c *Client) contentToFile(content client.MessageContent) (*client.File, *cl case client.TypeMessageSticker: sticker, _ := content.(*client.MessageSticker) file := sticker.Sticker.Sticker - if sticker.Sticker.IsAnimated && sticker.Sticker.Thumbnail != nil && sticker.Sticker.Thumbnail.File != nil { + if sticker.Sticker.Format.StickerFormatType() == client.TypeStickerFormatTgs && sticker.Sticker.Thumbnail != nil && sticker.Sticker.Thumbnail.File != nil { file = sticker.Sticker.Thumbnail.File } return file, nil @@ -754,22 +1704,27 @@ func (c *Client) contentToFile(content client.MessageContent) (*client.File, *cl func (c *Client) countCharsInLines(lines *[]string) (count int) { for _, line := range *lines { - count += len(line) + count += utf8.RuneCountInString(line) } return } -func (c *Client) messageToPrefix(message *client.Message, previewString string, fileString string, replyMsg *client.Message) (string, int, int) { - isPM, err := c.IsPM(message.ChatId) - if err != nil { - log.Errorf("Could not determine if chat is PM: %v", err) - } +func (c *Client) isCarbonsEnabled() bool { + return gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons +} + +func (c *Client) messageToPrefix(message *client.Message, previewString string, fileString string, suppressReply bool) (string, *gateway.Reply) { + isPM, chat, err := c.IsPM(message.ChatId, true) + if err != nil { + log.Errorf("Could not determine chat type: %v", err) + } + // with carbons, hide for all messages in PM and only for outgoing in group chats + hideSender := c.isCarbonsEnabled() && (message.IsOutgoing || isPM) || (c.Session.MUC && c.IsGroup(chat)) - var replyStart, replyEnd int prefix := []string{} // message direction var directionChar string - if !isPM || !gateway.MessageOutgoingPermission || !c.Session.Carbons { + if !hideSender { if c.Session.AsciiArrows { if message.IsOutgoing { directionChar = "> " @@ -784,30 +1739,52 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string, } } } - if !isPM || !c.Session.HideIds { + // with hideids options enabled, hide the id for everything but non-carbons in legacy group chats + if (!isPM && !c.Session.MUC && !(c.isCarbonsEnabled() && message.IsOutgoing)) || !c.Session.HideIds { prefix = append(prefix, directionChar+strconv.FormatInt(message.Id, 10)) } // show sender in group chats - if !isPM { + if !hideSender { sender := c.formatSender(message) if sender != "" { prefix = append(prefix, sender) } } + // reply to - if message.ReplyToMessageId != 0 { - if len(prefix) > 0 { - replyStart = c.countCharsInLines(&prefix) + (len(prefix)-1)*len(messageHeaderSeparator) - } - replyLine := "reply: " + c.formatMessage(message.ChatId, message.ReplyToMessageId, true, replyMsg) - prefix = append(prefix, replyLine) - replyEnd = replyStart + len(replyLine) - if len(prefix) > 0 { - replyEnd += len(messageHeaderSeparator) + var reply *gateway.Reply + if !suppressReply { + preview := true + gwReply, tgReply := c.getMessageReply(message, preview, false) + + if tgReply != nil { + reply = gwReply + + var replyStart, replyEnd int + var hadPrefix bool + + if len(prefix) > 0 { + hadPrefix = true + replyStart = c.countCharsInLines(&prefix) + (len(prefix)-1)*len(messageHeaderSeparator) + } + + replyLine := "reply: " + c.formatMessageContent(preview, tgReply, true) + prefix = append(prefix, replyLine) + + replyEnd = replyStart + utf8.RuneCountInString(replyLine) + if hadPrefix { + replyEnd += len(messageHeaderSeparator) + } + + if reply != nil { + reply.Start = uint64(replyStart) + reply.End = uint64(replyEnd) + } } } + if message.ForwardInfo != nil { - prefix = append(prefix, "fwd: "+c.formatForward(message.ForwardInfo)) + prefix = append(prefix, "fwd: "+c.formatOrigin(message.ForwardInfo.Origin)) } // preview if previewString != "" { @@ -818,7 +1795,7 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string, prefix = append(prefix, "file: "+fileString) } - return strings.Join(prefix, messageHeaderSeparator), replyStart, replyEnd + return strings.Join(prefix, messageHeaderSeparator), reply } func (c *Client) ensureDownloadFile(file *client.File) *client.File { @@ -831,18 +1808,107 @@ func (c *Client) ensureDownloadFile(file *client.File) *client.File { newFile, err := c.DownloadFile(file.Id, 1, true) if err == nil { return newFile + } else { + log.Errorf("Couldn't force-download file: %v", err.Error()) } } return file } -// ProcessIncomingMessage transfers a message to XMPP side and marks it as read on Telegram side -func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { - var text, oob, auxText string - var err error +// \n if it is groupchat and message is not empty +func (c *Client) getPrefixSeparator(chatId int64) string { + var separator string + if chatId < 0 { + separator = "\n" + } else if chatId > 0 { + separator = " | " + } + return separator +} - reply, replyMsg := c.getMessageReply(message) +// ProcessIncomingMessage is a legacy wrapper for SendMessageToGateway aiming only PM messages +func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { + chat, _, _ := c.GetContactByID(chatId, nil, true) + safeToSend := true + groupChatFrom := "" + groupChatTos := []string{} + if c.Session.MUC && c.IsGroup(chat) { + senderId := c.getMessageSenderId(message) + if senderId == 0 { + log.Errorf("Invalid sender id for message %#v", message) + return + } + + switch message.Content.MessageContentType() { + case client.TypeMessageChatJoinByLink: + c.mucOccupantRolePresence(chatId, senderId, ChatMemberStatusUnmuted, c.GetMUCNickname(senderId)) + case client.TypeMessageChatAddMembers: + addMembers, _ := message.Content.(*client.MessageChatAddMembers) + for _, memberId := range addMembers.MemberUserIds { + if c.me != nil && c.me.Id == memberId { + for _, jid := range c.GetCarbonFullJids(true, "", false) { + gateway.InviteToMUC(chatId, jid, c.xmpp) + } + } + c.mucOccupantRolePresence(chatId, memberId, ChatMemberStatusUnmuted, c.GetMUCNickname(memberId)) + } + case client.TypeMessageChatDeleteMember: + deleteMember, _ := message.Content.(*client.MessageChatDeleteMember) + c.mucOccupantRolePresence(chatId, deleteMember.UserId, ChatMemberStatusKicked, c.GetMUCNickname(deleteMember.UserId)) + case client.TypeMessageBasicGroupChatCreate, client.TypeMessageSupergroupChatCreate, client.TypeMessageChatUpgradeTo: + inviteChatId := chatId + if message.Content.MessageContentType() == client.TypeMessageChatUpgradeTo { + c.deleteMUC(chatId, nil, true) + + upgradeTo, _ := message.Content.(*client.MessageChatUpgradeTo) + inviteChatId = -1000000000000 - upgradeTo.SupergroupId // 🫃 + } + for _, jid := range c.GetCarbonFullJids(true, "", false) { + gateway.InviteToMUC(inviteChatId, jid, c.xmpp) + } + } + + safeToSend = c.assureMUCOccupant(chatId, senderId, message.SenderId, chat) + + groupChatFrom = gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(senderId) + var ok bool + ok, groupChatTos = c.getMUCJoinedJIDs(chatId, nil, true) + if !ok { + safeToSend = false + } + } + log.Debugf("groupChatFrom: %v groupChatTos: %#v, safeToSend: %v", groupChatFrom, groupChatTos, safeToSend) + if safeToSend { + c.SendMessageToGateway(chatId, message, "", false, groupChatFrom, groupChatTos, "") + } else { + mucJID := gateway.MUCJID(chatId) + gateway.SendErrorMessage(c.jid, mucJID, "Cannot show a message", 500, true, c.xmpp) + } +} + +// SendMessageToGateway transfers a message to XMPP side and marks it as read on Telegram side +func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id string, delay bool, groupChatFrom string, groupChatTos []string, mamQueryId string) { + var isCarbon bool + var jids []string + var isGroupchat bool + var originalFrom string + if len(groupChatTos) == 0 { + isCarbon = c.isCarbonsEnabled() && message.IsOutgoing + jids = c.GetCarbonFullJids(isCarbon, "", true) + } else { + isGroupchat = true + jids = groupChatTos + + senderId := c.getMessageSenderId(message) + if senderId != 0 { + originalFrom = gateway.CHATJID(senderId, true) + } + } + + var text, oob, auxText string + var reply *gateway.Reply + var replyObtained bool content := message.Content if content != nil && content.MessageContentType() == client.TypeMessageChatChangePhoto { @@ -850,9 +1916,40 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { ChatId: chatId, }) if err == nil { - c.cache.SetChat(chatId, chat) - go c.ProcessStatusUpdate(chatId, "", "", gateway.SPImmed(true)) + c.cache.SetChat(chatId, chat, true) + go c.ProcessStatusUpdate(chatId, "", "", true, gateway.SPImmed(true)) text = "" + + if chat.Photo == nil { + c.SetEmptyAvatarHash(chatId) + } else { + sha1 := c.GetPhotoSha1(chat.Photo.Small, chatId) + size := c.GetPhotoSize(chat.Photo.Small) + + for resource := range c.resourcesRange() { + features, ok := c.XmppClientFeatures[resource] + if ok && features != nil { + for _, feature := range *features { + if feature == gateway.NodeAvatarMetadataNotify { + var chatJid string + if isGroupchat { + chatJid = gateway.MUCJID(chatId) + } else { + chatJid = gateway.CHATJID(chatId, false) + } + go gateway.SendPubSubAvatarNotification(c.xmpp, c.jid+"/"+resource, chatJid, sha1, size) + break + } + } + } + } + + if isGroupchat { + for _, jid := range jids { + gateway.SendMUCStatusCode(jid, gateway.MUCJID(chatId), c.xmpp, 104) + } + } + } } } else { text = c.messageToText(message, false) @@ -869,91 +1966,153 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { fileName, link := c.formatFile(file, false) oob = link - if c.Session.OOBMode && oob != "" { - typ := message.Content.MessageContentType() - if typ != client.TypeMessageSticker { - auxText = text + oobSwap := c.Session.OOBMode && oob != "" + + var ignorePrefix bool + if oobSwap { + if text == "" || message.Content.MessageContentType() == client.TypeMessageSticker { + chatType, _, err := c.GetChatType(chatId, true) + ignorePrefix = err == nil && (chatType != gateway.ChatTypeBasicGroup && chatType != gateway.ChatTypeSupergroup) && c.isCarbonsEnabled() } - text = oob - } else if !c.Session.RawMessages { + } + + if !c.Session.RawMessages && !ignorePrefix { var newText strings.Builder - prefix, replyStart, replyEnd := c.messageToPrefix(message, previewName, fileName, replyMsg) + prefix, prefixReply := c.messageToPrefix(message, previewName, fileName, false) + reply = prefixReply + replyObtained = true newText.WriteString(prefix) - if reply != nil { - reply.Start = uint64(replyStart) - reply.End = uint64(replyEnd) - } if text != "" { - // \n if it is groupchat and message is not empty if prefix != "" { - if chatId < 0 { - newText.WriteString("\n") - } else if chatId > 0 { - newText.WriteString(" | ") - } + newText.WriteString(c.getPrefixSeparator(chatId)) } - newText.WriteString(text) } text = newText.String() } + + if oobSwap { + if !ignorePrefix { + auxText = text + } + text = oob + } } } + if !replyObtained { + reply, _ = c.getMessageReply(message, false, true) + } // mark message as read - c.client.ViewMessages(&client.ViewMessagesRequest{ - ChatId: chatId, - MessageIds: []int64{message.Id}, - ForceRead: true, - }) + if !c.Session.Receipts { + c.MarkAsRead(chatId, message.Id) + } // forward message to XMPP - sId := strconv.FormatInt(message.Id, 10) - sChatId := strconv.FormatInt(chatId, 10) - - var jids []string - var isPM bool - if gateway.MessageOutgoingPermission && c.Session.Carbons { - isPM, err = c.IsPM(chatId) - if err != nil { - log.Errorf("Could not determine if chat is PM: %v", err) - } - } - isOutgoing := isPM && message.IsOutgoing - if isOutgoing { - for resource := range c.resourcesRange() { - jids = append(jids, c.jid+"/"+resource) - } + var sId string + var stanzaId string + var auxId string + strId := strconv.FormatInt(message.Id, 10) + if id == "" { + sId = strId + stanzaId = strId + auxId = "c"+strId } else { - jids = []string{c.jid} + sId = id + stanzaId = strId + auxId = "" + } + + var from string + if groupChatFrom == "" { + from = gateway.CHATNODE(chatId) + } else { + from = groupChatFrom + } + + var timestamp int64 + if delay { + timestamp = int64(message.Date) + } + + var mucUserItem *gateway.MUCUserItem + var mucJID string + if mamQueryId != "" { + chatMember, err := c.client.GetChatMember(&client.GetChatMemberRequest{ + ChatId: chatId, + MemberId: message.SenderId, + }) + var status client.ChatMemberStatus + if err == nil { + status = chatMember.Status + } + chat, err := c.GetChatByID(chatId, nil, true) + if err == nil { + affiliation, role := c.memberStatusToAffiliationAndRole(status, chat) + mucUserItem = &gateway.MUCUserItem{ + Affiliation: affiliation, + Jid: gateway.CHATJID(chatId, false), + Role: role, + } + } + mucJID = gateway.MUCJID(chatId) } for _, jid := range jids { - gateway.SendMessageWithOOB(jid, sChatId, text, sId, c.xmpp, reply, oob, isOutgoing) + gateway.SendMessageWithOOB(jid, from, text, sId, c.xmpp, reply, timestamp, oob, "", isCarbon, isGroupchat, c.Session.Receipts, originalFrom, stanzaId, mamQueryId, mucJID, mucUserItem) if auxText != "" { - gateway.SendMessage(jid, sChatId, auxText, sId, c.xmpp, reply, isOutgoing) + gateway.SendMessage(jid, from, auxText, auxId, c.xmpp, reply, timestamp, "", isCarbon, isGroupchat, c.Session.Receipts, originalFrom, auxId, mamQueryId, mucJID, mucUserItem) } } + c.UpdateLastChatMessageId(chatId, sId) } -// ProcessOutgoingMessage executes commands or sends messages to mapped chats -func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid string, replyId int64) client.InputMessageContent { +// SendDelayedMUCMessage is used to send MUC history via the legacy method or MAM +func (c *Client) SendDelayedMUCMessage(chatId int64, message *client.Message, toJid string, mamQueryId string) { + msgId, _ := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, chatId, message.Id) + c.SendMessageToGateway( + chatId, + message, + msgId, + true, + gateway.MUCJID(chatId) + "/" + c.GetMUCNickname(c.getMessageSenderId(message)), + []string{toJid}, + mamQueryId, + ) +} + +// MarkAsRead marks a message as read +func (c *Client) MarkAsRead(chatId, messageId int64) { + c.client.ViewMessages(&client.ViewMessagesRequest{ + ChatId: chatId, + MessageIds: []int64{messageId}, + ForceRead: true, + }) +} + +// PrepareMessageContent creates a simple text message +func (c *Client) PrepareOutgoingMessageContent(text string) client.InputMessageContent { + return c.prepareOutgoingMessageContent(text, nil) +} + +// ProcessOutgoingMessage executes commands or sends messages to mapped chats, returns message id and isCommand +func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid string, replyId int64, replaceId int64, isGroupchat, raw bool) (*client.Message, bool) { if !c.Online() { // we're offline - return nil + return nil, false } - if returnJid != "" && (strings.HasPrefix(text, "/") || strings.HasPrefix(text, "!")) { + if replaceId == 0 && !raw && (strings.HasPrefix(text, "/") || strings.HasPrefix(text, "!")) { // try to execute commands - response, isCommand := c.ProcessChatCommand(chatID, text) + response, isCommand, _ := c.ProcessChatCommand(chatID, text) if response != "" { - gateway.SendTextMessage(returnJid, strconv.FormatInt(chatID, 10), response, c.xmpp) + c.returnMessage(returnJid, chatID, response, 0, isGroupchat) } // do not send on success if isCommand { - return nil + return nil, true } } @@ -961,7 +2120,7 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str // quotations var reply int64 - if replyId == 0 { + if replaceId == 0 && replyId == 0 { replySlice := replyRegex.FindStringSubmatch(text) if len(replySlice) > 1 { reply, _ = strconv.ParseInt(replySlice[1], 10, 64) @@ -972,60 +2131,35 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str // attach a file var file *client.InputFileLocal - if chatID != 0 && c.content.Upload != "" && strings.HasPrefix(text, c.content.Upload) { + link := text + if c.content.Upload != "" && strings.HasPrefix(text, c.content.Upload) { response, err := http.Get(text) if err != nil { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Failed to fetch the uploaded file: %s", err.Error()), - c.xmpp, - ) - return nil + c.returnError(returnJid, chatID, "Failed to fetch the uploaded file", err, 500, isGroupchat) } if response != nil && response.Body != nil { defer response.Body.Close() if response.StatusCode != 200 { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Received status code %v", response.StatusCode), - c.xmpp, - ) - return nil + c.returnMessage(returnJid, chatID, fmt.Sprintf("Received status code %v", response.StatusCode), response.StatusCode, isGroupchat) + return nil, false } tempDir, err := ioutil.TempDir("", "telegabber-*") if err != nil { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Failed to create a temporary directory: %s", err.Error()), - c.xmpp, - ) - return nil + c.returnError(returnJid, chatID, "Failed to create a temporary directory", err, 500, isGroupchat) + return nil, false } tempFile, err := os.Create(filepath.Join(tempDir, filepath.Base(text))) if err != nil { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Failed to create a temporary file: %s", err.Error()), - c.xmpp, - ) - return nil + c.returnError(returnJid, chatID, "Failed to create a temporary file", err, 500, isGroupchat) + return nil, false } _, err = io.Copy(tempFile, response.Body) if err != nil { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Failed to write a temporary file: %s", err.Error()), - c.xmpp, - ) - return nil + c.returnError(returnJid, chatID, "Failed to write a temporary file", err, 500, isGroupchat) + return nil, false } file = &client.InputFileLocal{ @@ -1044,42 +2178,95 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str } } + content := c.prepareOutgoingMessageContent(text, file) + + if replaceId != 0 { + tgMessage, err := c.client.EditMessageText(&client.EditMessageTextRequest{ + ChatId: chatID, + MessageId: replaceId, + InputMessageContent: content, + }) + if err != nil { + c.returnError(returnJid, chatID, "Not edited", err, 400, isGroupchat) + return nil, false + } + return tgMessage, false + } + + tgMessage, err := c.client.SendMessage(&client.SendMessageRequest{ + ChatId: chatID, + ReplyTo: &client.InputMessageReplyToMessage{MessageId: reply}, + InputMessageContent: content, + }) + if err != nil { + c.returnError(returnJid, chatID, "Not sent", err, 400, isGroupchat) + return nil, false + } + + if file != nil { + document, ok := tgMessage.Content.(*client.MessageDocument) + if ok && document.Document != nil && document.Document.Document != nil { + c.locks.uploadingFilesLock.Lock() + c.uploadingFiles[document.Document.Document.Id] = link + c.locks.uploadingFilesLock.Unlock() + } + } + + return tgMessage, false +} + +func (c *Client) returnMessage(returnJid string, chatID int64, text string, code int, isGroupchat bool) { + if isGroupchat { + if code != 0 { + gateway.SendErrorMessage(returnJid, gateway.MUCJID(chatID), text, code, isGroupchat, c.xmpp) + } else { + var nickname string + chat, err := c.GetChatByID(chatID, nil, true) + if err == nil { + nickname = chat.Title + } + gateway.SendMUCAnnouncement(returnJid, gateway.MUCJID(chatID), text, nickname, "", c.xmpp) + } + } else { + gateway.SendTextMessage(returnJid, gateway.CHATNODE(chatID), text, c.xmpp, isGroupchat) + } +} + +func (c *Client) returnError(returnJid string, chatID int64, msg string, err error, code int, isGroupchat bool) { + responseError, ok := err.(client.ResponseError) + log.Debugf("responseError: %#v", responseError) + if ok && responseError.Err != nil { + if responseError.Err.Message == "Have no write access to the chat" { + code = 403 + } + } + c.returnMessage(returnJid, chatID, fmt.Sprintf("%s: %s", msg, err.Error()), code, isGroupchat) +} + +func (c *Client) prepareOutgoingMessageContent(text string, file *client.InputFileLocal) client.InputMessageContent { formattedText := &client.FormattedText{ Text: text, } - var message client.InputMessageContent + var content client.InputMessageContent if file != nil { // we can try to send a document - message = &client.InputMessageDocument{ + content = &client.InputMessageDocument{ Document: file, Caption: formattedText, } } else { // compile our message - message = &client.InputMessageText{ + content = &client.InputMessageText{ Text: formattedText, } } + return content +} - if chatID != 0 { - _, err := c.client.SendMessage(&client.SendMessageRequest{ - ChatId: chatID, - ReplyToMessageId: reply, - InputMessageContent: message, - }) - if err != nil { - gateway.SendTextMessage( - returnJid, - strconv.FormatInt(chatID, 10), - fmt.Sprintf("Not sent: %s", err.Error()), - c.xmpp, - ) - } - return nil - } else { - return message - } +// OwnChatsKeys proxies the following function from unexported cache +func (c *Client) OwnChatsKeys() []int64 { + return c.cache.OwnChatsKeys() } // StatusesRange proxies the following function from unexported cache @@ -1087,6 +2274,11 @@ func (c *Client) StatusesRange() chan *cache.Status { return c.cache.StatusesRange() } +// GetVerDisco proxies the following function from unexported cache +func (c *Client) GetVerDisco(ver string) (*stanza.DiscoInfo, bool) { + return c.cache.GetVerDisco(ver) +} + func (c *Client) addResource(resource string) { if resource == "" { return @@ -1127,23 +2319,26 @@ func (c *Client) resourcesRange() chan string { // resend statuses to (to another resource, for example) func (c *Client) roster(resource string) { + c.locks.resourcesLock.Lock() if _, ok := c.resources[resource]; ok { + c.locks.resourcesLock.Unlock() return // we know it } + c.locks.resourcesLock.Unlock() log.Warnf("Sending roster for %v", resource) - for _, chat := range c.cache.ChatsKeys() { - c.ProcessStatusUpdate(chat, "", "") + for _, chat := range c.cache.OwnChatsKeys() { + c.ProcessStatusUpdate(chat, "", "", true) } - gateway.SendPresence(c.xmpp, c.jid, gateway.SPStatus("Logged in as: "+c.Session.Login)) + c.sendPresence(gateway.SPStatus("Logged in as: " + c.Session.Login)) c.addResource(resource) } // get last messages from specified chat -func (c *Client) getLastMessages(id int64, query string, from int64, count int32) (*client.Messages, error) { +func (c *Client) getLastMessages(id int64, query string, from int64, count int32) (*client.FoundChatMessages, error) { return c.client.SearchChatMessages(&client.SearchChatMessagesRequest{ ChatId: id, Query: query, @@ -1153,6 +2348,215 @@ func (c *Client) getLastMessages(id int64, query string, from int64, count int32 }) } +func (c *Client) getNLastMessages(chatID int64, limit *MessageLimit) ([]*client.Message, error) { + var newMessages *client.Messages + var messages []*client.Message + var err error + var fromId int64 + var safetyLimit int32 + var charsCount int + + if limit == nil { + return nil, nil + } + switch limit.Type { + case MessageLimitMessages: + safetyLimit = limit.Messages + case MessageLimitChars: + safetyLimit = int32(limit.Chars) + if safetyLimit > 1000 { + safetyLimit = 1000 + } + case MessageLimitSince: + safetyLimit = 1000 + } + + safetyLoop: + for _ = range make([]struct{}, safetyLimit) { + if len(messages) > 0 { + fromId = messages[len(messages)-1].Id + } + + newMessages, err = c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatID, + FromMessageId: fromId, + Limit: safetyLimit, + }) + if err != nil { + return nil, err + } + + if len(newMessages.Messages) == 0 { + break + } + + for _, message := range newMessages.Messages { + if limit.Type == MessageLimitSince && limit.Since > int64(message.Date) { // durov… + break safetyLoop + } + + messages = append(messages, message) + + switch limit.Type { + case MessageLimitMessages: + if len(messages) >= int(limit.Messages) { + break safetyLoop + } + case MessageLimitChars: + // rough but why care + if message.Content != nil && message.Content.MessageContentType() == client.TypeMessageText { + textContent, _ := message.Content.(*client.MessageText) + + if textContent.Text != nil { + charsCount += len(textContent.Text.Text) + + if charsCount >= limit.Chars { + break safetyLoop + } + } + } + } + } + } + + return messages, nil +} + +// GetMessagesBetween lazily fetches message history between given ids (from exclusive, last inclusive), also calculating completeness flag; negative limit means messages from the end +func (c *Client) GetMessagesBetween(chatID, fromMessageId, lastMessageId int64, limit int32, reverse bool) (messages []*client.Message, complete bool, err error) { + log.WithFields(log.Fields{ + "chat_id": chatID, + "from": fromMessageId, + "last": lastMessageId, + "limit": limit, + }).Debug("messages between") + + var newMessages *client.Messages + if limit == 0 { + complete = true + return + } + + var reqFromMessageId int64 + var reqOffset, reqLimit int32 + if limit < 0 { + reqFromMessageId = lastMessageId + reqOffset = -1 + reqLimit = -limit + 1 + } else { + reqFromMessageId = fromMessageId + reqOffset = -limit - 1 + reqLimit = limit + 1 + } + log.WithFields(log.Fields{ + "from": reqFromMessageId, + "offset": reqOffset, + "limit": reqLimit, + }).Debug("calculated history request") + + newMessages, err = c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatID, + FromMessageId: reqFromMessageId, + Offset: reqOffset, + Limit: reqLimit, + }) + if err == nil { + fetchedMessages := newMessages.Messages + log.Debugf("pre-fetched %v messages, cutting", len(fetchedMessages)) + + // it's not quite good to yield just one last message with the complete flag, + // as clients won't fetch more, give it one more chance + if limit < -1 && len(fetchedMessages) == 1 { + additionalMessages, err := c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatID, + FromMessageId: fetchedMessages[0].Id, + Limit: reqLimit-1, + }) + if err == nil { + log.Debugf("fetched %v more messages", len(additionalMessages.Messages)) + fetchedMessages = append(fetchedMessages, additionalMessages.Messages...) + } + } + + if limit > 0 { + complete = true + fromPos := -1 + if lastMessageId != 0 { + for i, message := range fetchedMessages { + if message.Id < lastMessageId { + complete = false + break + } else if message.Id == lastMessageId { + fromPos = i + break + } + } + } else { + if len(fetchedMessages) > 0 { + lastMsg, lastMsgErr := c.GetPreviousMessage(chatID, 0) + if lastMsgErr == nil && lastMsg != nil { + if lastMsg.Id != fetchedMessages[0].Id { + complete = false + } + } + } + } + if fromPos > -1 { + messages = fetchedMessages[fromPos:] + } else { + messages = fetchedMessages + } + if complete && len(messages) > 0 { + firstMessage := messages[len(messages)-1] + if fromMessageId > 1 { + if firstMessage.Id > fromMessageId { + complete = false + } + } else if fromMessageId == 1 { + // try to fetch one more message to check if there are any other before + previousMessage, previousMessageErr := c.GetPreviousMessage(chatID, firstMessage.Id) + if previousMessageErr == nil && previousMessage != nil { + complete = false + } + } + // for 0 one last message is already fetched as there is at least one, right? + } + } else { + for _, message := range fetchedMessages { + if message.Id <= fromMessageId { + complete = true + break + } + messages = append(messages, message) + } + if len(messages) > 0 { + firstMessage := messages[len(messages)-1] + // try to fetch one more message to check if there are any other before + previousMessage, previousMessageErr := c.GetPreviousMessage(chatID, firstMessage.Id) + if previousMessageErr == nil && previousMessage != nil { + if previousMessage.Id == fromMessageId { + complete = true + } + } else { + complete = true + } + } + } + + if !reverse { + ReverseMessagesSlice(messages) + } + } + return +} + +// GetFile retrieves a file object by id given by TDlib +func (c *Client) GetFile(id int32) (*client.File, error) { + return c.client.GetFile(&client.GetFileRequest{ + FileId: id, + }) +} + // DownloadFile actually obtains a file by id given by TDlib func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*client.File, error) { return c.client.DownloadFile(&client.DownloadFileRequest{ @@ -1162,20 +2566,20 @@ func (c *Client) DownloadFile(id int32, priority int32, synchronous bool) (*clie }) } -// OpenPhotoFile reliably obtains a photo if possible -func (c *Client) OpenPhotoFile(photoFile *client.File, priority int32) (*os.File, string, error) { - if photoFile == nil { - return nil, "", errors.New("Photo file not found") +// ForceOpenFile reliably obtains a file if possible +func (c *Client) ForceOpenFile(tgFile *client.File, priority int32) (*os.File, string, error) { + if tgFile == nil { + return nil, "", errors.New("File not found") } - path := photoFile.Local.Path + path := tgFile.Local.Path file, err := os.Open(path) if err == nil { return file, path, nil } else // obtain the photo right now if still not downloaded - if !photoFile.Local.IsDownloadingCompleted { - tdFile, tdErr := c.DownloadFile(photoFile.Id, priority, true) + if !tgFile.Local.IsDownloadingCompleted { + tdFile, tdErr := c.DownloadFile(tgFile.Id, priority, true) if tdErr == nil { path = tdFile.Local.Path file, err = os.Open(path) @@ -1196,13 +2600,22 @@ func (c *Client) GetChatDescription(chat *client.Chat) string { UserId: privateType.UserId, }) if err == nil { - if fullInfo.Bio != "" { - return fullInfo.Bio - } else if fullInfo.Description != "" { - return fullInfo.Description + if fullInfo.Bio != nil && fullInfo.Bio.Text != "" { + return formatter.Format( + fullInfo.Bio.Text, + fullInfo.Bio.Entities, + c.getFormatter(), + c, + ) + } else if fullInfo.BotInfo != nil { + if fullInfo.BotInfo.ShortDescription != "" { + return fullInfo.BotInfo.ShortDescription + } else { + return fullInfo.BotInfo.Description + } } } else { - log.Warnf("Coudln't retrieve private chat info: %v", err.Error()) + log.Warnf("Couldn't retrieve private chat info: %v", err.Error()) } } else if chatType == client.TypeChatTypeBasicGroup { basicGroupType, _ := chat.Type.(*client.ChatTypeBasicGroup) @@ -1212,7 +2625,7 @@ func (c *Client) GetChatDescription(chat *client.Chat) string { if err == nil { return fullInfo.Description } else { - log.Warnf("Coudln't retrieve basic group info: %v", err.Error()) + log.Warnf("Couldn't retrieve basic group info: %v", err.Error()) } } else if chatType == client.TypeChatTypeSupergroup { supergroupType, _ := chat.Type.(*client.ChatTypeSupergroup) @@ -1222,32 +2635,98 @@ func (c *Client) GetChatDescription(chat *client.Chat) string { if err == nil { return fullInfo.Description } else { - log.Warnf("Coudln't retrieve supergroup info: %v", err.Error()) + log.Warnf("Couldn't retrieve supergroup info: %v", err.Error()) } } return "" } +// GetChatMemberCount obtains the member count depending on the chat type +func (c *Client) GetChatMemberCount(chat *client.Chat) int32 { + chatType := chat.Type.ChatTypeType() + if chatType == client.TypeChatTypePrivate { + return 2 + } else if chatType == client.TypeChatTypeBasicGroup { + basicGroupType, _ := chat.Type.(*client.ChatTypeBasicGroup) + basicGroup, err := c.client.GetBasicGroup(&client.GetBasicGroupRequest{ + BasicGroupId: basicGroupType.BasicGroupId, + }) + if err == nil { + return basicGroup.MemberCount + } else { + log.Warnf("Couldn't retrieve basic group: %v", err.Error()) + } + } else if chatType == client.TypeChatTypeSupergroup { + supergroupType, _ := chat.Type.(*client.ChatTypeSupergroup) + supergroup, err := c.client.GetSupergroup(&client.GetSupergroupRequest{ + SupergroupId: supergroupType.SupergroupId, + }) + if err == nil { + return supergroup.MemberCount + } else { + log.Warnf("Couldn't retrieve supergroup: %v", err.Error()) + } + } + return 0 +} + +// GetGroupChats obtains all group chats +func (c *Client) GetGroupChats() []*client.Chat { + var groupChats []*client.Chat + + chats, err := c.client.GetChats(&client.GetChatsRequest{ + Limit: chatsLimit, + }) + if err == nil { + for _, id := range chats.ChatIds { + chat, _, _ := c.GetContactByID(id, nil, true) + if chat != nil && c.IsGroup(chat) { + groupChats = append(groupChats, chat) + } + } + } else { + log.Errorf("Could not retrieve chats: %v", err) + } + + return groupChats +} + +// IsGroup determines if a chat is eligible to be represented as MUC +func (c *Client) IsGroup(chat *client.Chat) bool { + if chat == nil { + return false + } + typ := chat.Type.ChatTypeType() + return typ == client.TypeChatTypeBasicGroup || typ == client.TypeChatTypeSupergroup +} + // subscribe to a Telegram ID -func (c *Client) subscribeToID(id int64, chat *client.Chat) { - var args []args.V - args = append(args, gateway.SPFrom(strconv.FormatInt(id, 10))) - args = append(args, gateway.SPType("subscribe")) +func (c *Client) subscribeToID(id int64, chat *client.Chat, firstTime bool) { + args := gateway.SimplePresence(id, "subscribe") if chat == nil { - chat, _, _ = c.GetContactByID(id, nil) + chat, _, _ = c.GetContactByID(id, nil, true) } if chat != nil { + if c.Session.MUC && c.IsGroup(chat) { + if firstTime { + for resource := range c.resourcesRange() { + gateway.InviteToMUC(id, c.jid+"/"+resource, c.xmpp) + } + } + return + } + args = append(args, gateway.SPNickname(chat.Title)) - gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), chat.Title, c.xmpp) + gateway.SetNickname(c.jid, gateway.CHATNODE(id), chat.Title, c.xmpp) } - gateway.SendPresence( - c.xmpp, - c.jid, - args..., - ) + c.sendPresence(args...) +} + +func (c *Client) sendPresence(args ...args.V) error { + return gateway.SendPresence(c.xmpp, c.jid, args...) } func (c *Client) prepareDiskSpace(size uint64) { @@ -1263,8 +2742,9 @@ func (c *Client) prepareDiskSpace(size uint64) { } } -func (c *Client) isCallable(chat *client.Chat, user, *client.User) bool { - if chat == nil || user == nil { +func (c *Client) CanBeCalled(chatID int64) bool { + chat, user, err := c.GetContactByID(chatID, nil, false) + if err != nil || chat == nil || user == nil { return false } chatType := chat.Type.ChatTypeType() @@ -1274,7 +2754,7 @@ func (c *Client) isCallable(chat *client.Chat, user, *client.User) bool { UserId: privateType.UserId, }) if err == nil { - return fullInfo.CanBeCalled && (user.Username != "" || user.PhoneNumber != "") + return fullInfo.CanBeCalled && (user.Usernames != nil || user.PhoneNumber != "") } else { log.Warnf("Coudln't retrieve private chat info: %v", err.Error()) } @@ -1282,3 +2762,800 @@ func (c *Client) isCallable(chat *client.Chat, user, *client.User) bool { return false } + +func (c *Client) GetVcardInfo(toID int64) (VCardInfo, error) { + var info VCardInfo + chat, user, err := c.GetContactByID(toID, nil, false) + if err != nil { + return info, err + } + + if chat != nil { + info.Fn = chat.Title + + if chat.Photo != nil { + info.Photo = chat.Photo.Small + } + info.Info = c.GetChatDescription(chat) + } + if user != nil { + if user.Usernames != nil { + info.Nicknames = make([]string, len(user.Usernames.ActiveUsernames)) + copy(info.Nicknames, user.Usernames.ActiveUsernames) + } + info.Given = user.FirstName + info.Family = user.LastName + info.Tel = user.PhoneNumber + } + + return info, nil +} + +func (c *Client) UpdateChatNicknames() { + for _, id := range c.cache.OwnChatsKeys() { + chat, ok := c.cache.GetChat(id) + if ok { + if c.Session.MUC && c.IsGroup(chat) { + continue + } + + newArgs := []args.V{ + gateway.SPNickname(chat.Title), + } + newArgs = gateway.SPAppendFrom(newArgs, id) + + cachedStatus, ok := c.cache.GetStatus(id) + if ok { + show, status, typ := cachedStatus.Destruct() + newArgs = append(newArgs, gateway.SPShow(show), gateway.SPStatus(status)) + if typ != "" { + newArgs = append(newArgs, gateway.SPType(typ)) + } + } + + c.sendPresence(newArgs...) + + gateway.SetNickname(c.jid, gateway.CHATNODE(id), chat.Title, c.xmpp) + } + } +} + +// AddToEditOutbox temporarily store the resource from which a replace message with given ID was sent +func (c *Client) AddToEditOutbox(xmppId, resource string) { + c.locks.editOutboxLock.Lock() + defer c.locks.editOutboxLock.Unlock() + + c.editOutbox[xmppId] = resource +} + +func (c *Client) popFromEditOutbox(xmppId string) string { + c.locks.editOutboxLock.Lock() + defer c.locks.editOutboxLock.Unlock() + + resource, ok := c.editOutbox[xmppId] + if ok { + delete(c.editOutbox, xmppId) + } else { + log.Warnf("No %v xmppId in edit outbox", xmppId) + } + return resource +} + +// AddToOutbox remembers the resource from which a message with given ID was sent +func (c *Client) AddToOutbox(xmppId, resource string) { + c.locks.outboxLock.Lock() + defer c.locks.outboxLock.Unlock() + + c.outbox[xmppId] = resource +} + +func (c *Client) getFromOutbox(xmppId string) string { + c.locks.outboxLock.Lock() + defer c.locks.outboxLock.Unlock() + + resource, ok := c.outbox[xmppId] + if !ok { + log.Warnf("No %v xmppId in outbox", xmppId) + } + return resource +} + +// GetCarbonFullJids builds a set of full jids or of one bare jid for outgoing stanzas +func (c *Client) GetCarbonFullJids(isOutgoing bool, ignoredResource string, forceFull bool) []string { + var jids []string + if isOutgoing { + for resource := range c.resourcesRange() { + if ignoredResource == "" || resource != ignoredResource { + jids = append(jids, c.jid+"/"+resource) + } + } + if len(jids) == 0 && !forceFull { + jids = []string{c.jid} + } + } else { + jids = []string{c.jid} + } + return jids +} + +func (c *Client) UpdateLastChatMessageId(chatId int64, messageId string) { + c.locks.lastMsgIdsLock.Lock() + defer c.locks.lastMsgIdsLock.Unlock() + + c.lastMsgIds[chatId] = messageId +} + +func (c *Client) getLastChatMessageId(chatId int64) (string, bool) { + c.locks.lastMsgIdsLock.RLock() + defer c.locks.lastMsgIdsLock.RUnlock() + + xmppId, ok := c.lastMsgIds[chatId] + return xmppId, ok +} + +func (c *Client) getFormatter() formatter.MarkupModeType { + return formatter.MarkupModeXEP0393 +} + +func (c *Client) usernamesToString(usernames []string) string { + var atUsernames []string + for _, username := range usernames { + atUsernames = append(atUsernames, "@"+username) + } + return strings.Join(atUsernames, ", ") +} + +func (c *Client) memberStatusToAffiliationAndRole(memberStatus client.ChatMemberStatus, chat *client.Chat) (string, string) { + if memberStatus != nil { + switch memberStatus.ChatMemberStatusType() { + case client.TypeChatMemberStatusCreator: + return "owner", "moderator" + case client.TypeChatMemberStatusAdministrator: + return "admin", "moderator" + case client.TypeChatMemberStatusMember: + if chat != nil && !c.IsMessageSendingPermitted(chat.Permissions) { + return "member", "visitor" + } + return "member", "participant" + case client.TypeChatMemberStatusRestricted: + restricted, _ := memberStatus.(*client.ChatMemberStatusRestricted) + if !c.IsMessageSendingPermitted(restricted.Permissions) { + return "member", "visitor" + } + return "member", "participant" + case client.TypeChatMemberStatusLeft: + return "none", "none" + case client.TypeChatMemberStatusBanned: + return "outcast", "none" + } + } + return "member", "participant" +} + +// TgMemberToMUCMember resolves useful data to generate a MUC occupant +func (c *Client) TgMemberToMUCOccupant(member *client.ChatMember, chat *client.Chat) (senderId int64, nickname, affiliation, role string) { + senderId = c.GetSenderId(member.MemberId) + nickname = c.GetMUCNickname(senderId) + affiliation, role = c.memberStatusToAffiliationAndRole(member.Status, chat) + return +} + +func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, plain bool, toJid string) { + sChatId := gateway.CHATNODE(chatID) + mucJid := gateway.MUCJID(chatID) + var plainTos []string + + var isMUC bool + if plain { + chat, err := c.GetChatByID(chatID, nil, true) + if err == nil { + isMUC = c.Session.MUC && c.IsGroup(chat) + } + } + if isMUC { + _, plainTos = c.getMUCJoinedJIDs(chatID, nil, true) + } else { + plainTos = []string{c.jid} + } + + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + + if plain { + reply, _ := c.getMessageReply(message, false, true) + sId := strconv.FormatInt(message.Id, 10) + + var originalFrom string + var from string + if isMUC { + senderId := c.getMessageSenderId(message) + if senderId != 0 { + originalFrom = gateway.CHATJID(senderId, true) + } + from = mucJid + "/" + c.GetMUCNickname(senderId) + } else { + from = sChatId + } + + for _, to := range plainTos { + gateway.SendMessage( + to, + from, + c.formatMessage(0, 0, false, !isMUC, message), + sId, + c.xmpp, + reply, + 0, + "", + false, + isMUC, + false, + originalFrom, + "", + "", + "", + nil, + ) + } + } else { + c.SendDelayedMUCMessage(chatID, message, toJid, "") + } + } +} + +func (c *Client) GetTZD() string { + if c.Session.Timezone == "" { + return "Z" + } + return c.Session.Timezone +} + +// GetChatMembers retrieves a list of chat members. "Limited" mode works only if there are no more than 20 members at all +func (c *Client) GetChatMembers(chatID int64, limited bool, query string, membersList MembersList) ([]*client.ChatMember, error) { + if membersList == MembersListCreators { + chat, err := c.GetChatByID(chatID, nil, true) + if err != nil { + return nil, err + } + + chatType := chat.Type.ChatTypeType() + if chatType == client.TypeChatTypeBasicGroup { + basicGroupType, _ := chat.Type.(*client.ChatTypeBasicGroup) + fullInfo, err := c.client.GetBasicGroupFullInfo(&client.GetBasicGroupFullInfoRequest{ + BasicGroupId: basicGroupType.BasicGroupId, + }) + if err != nil { + return nil, err + } + + if fullInfo.CreatorUserId != 0 { + chatMember, err := c.client.GetChatMember(&client.GetChatMemberRequest{ + ChatId: chatID, + MemberId: &client.MessageSenderUser{UserId: fullInfo.CreatorUserId}, + }) + if err != nil { + return nil, err + } + + return []*client.ChatMember{chatMember}, nil + } + } else if chatType == client.TypeChatTypeSupergroup { + // noop, use complex filtering strategy later + } else { + return nil, errors.New("Creator not found") + } + } + + var administratorsRequest bool + var filters []client.ChatMembersFilter + switch membersList { + case MembersListMembers: + filters = []client.ChatMembersFilter{&client.ChatMembersFilterMembers{}} + case MembersListRestricted: + filters = []client.ChatMembersFilter{&client.ChatMembersFilterRestricted{}} + case MembersListBanned: + filters = []client.ChatMembersFilter{&client.ChatMembersFilterBanned{}} + case MembersListBannedAndAdministrators: + filters = []client.ChatMembersFilter{&client.ChatMembersFilterBanned{}, &client.ChatMembersFilterAdministrators{}} + case MembersListAdministrators, MembersListCreators: + administratorsRequest = true + filters = []client.ChatMembersFilter{&client.ChatMembersFilterAdministrators{}} + } + + limit := int32(9999) + if limited { + limit = 20 + + chat, err := c.GetChatByID(chatID, nil, true) + if err != nil { + return nil, err + } + + chatType := chat.Type.ChatTypeType() + if chatType == client.TypeChatTypeBasicGroup { + basicGroupType, _ := chat.Type.(*client.ChatTypeBasicGroup) + fullInfo, err := c.client.GetBasicGroupFullInfo(&client.GetBasicGroupFullInfoRequest{ + BasicGroupId: basicGroupType.BasicGroupId, + }) + if err != nil { + return nil, err + } + + if len(fullInfo.Members) > int(limit) { + return nil, errOverLimit + } + + return fullInfo.Members, nil + } else if chatType == client.TypeChatTypeSupergroup { + supergroupType, _ := chat.Type.(*client.ChatTypeSupergroup) + fullInfo, err := c.client.GetSupergroupFullInfo(&client.GetSupergroupFullInfoRequest{ + SupergroupId: supergroupType.SupergroupId, + }) + if err != nil { + return nil, err + } + + if fullInfo.MemberCount > limit { + return nil, errOverLimit + } + } else { + return nil, errors.New("Inapplicable chat type") + } + } + + administrators := make(map[int64]*client.ChatAdministrator) + if administratorsRequest { + chatAdministrators, err := c.client.GetChatAdministrators(&client.GetChatAdministratorsRequest{ + ChatId: chatID, + }) + if err == nil { + for _, administrator := range chatAdministrators.Administrators { + administrators[administrator.UserId] = administrator + } + } + } + + var members []*client.ChatMember + for _, filter := range filters { + chatMembers, err := c.client.SearchChatMembers(&client.SearchChatMembersRequest{ + ChatId: chatID, + Limit: limit, + Query: query, + Filter: filter, + }) + if err != nil { + return nil, err + } + for _, member := range chatMembers.Members { + switch membersList { + case MembersListAdministrators, MembersListCreators: + senderId := c.GetSenderId(member.MemberId) + administrator, ok := administrators[senderId] + if !ok || (administrator.IsOwner == (membersList == MembersListAdministrators)) { + continue + } + } + members = append(members, member) + } + } + return members, nil +} + +func (c *Client) unsubscribe(chatID int64) error { + args := gateway.SimplePresence(chatID, "unsubscribed") + return c.sendPresence(args...) +} + +func (c *Client) unsubscribeFromAll() { + for _, id := range c.cache.ChatsKeys() { + c.leaveChat(id) + } +} + +func (c *Client) leaveChat(chatID int64) error { + chat, err := c.GetChatByID(chatID, nil, true) + + if err == nil && c.Session.MUC && c.IsGroup(chat) { + return c.kickMeFromMUC(chatID, []uint16{110, 307}, false, nil) + } + return c.unsubscribe(chatID) +} + +// achtung: assuming a locked mucState context +func (c *Client) kickMeFromMUC(chatID int64, statusCodes []uint16, destroy bool, mucState *MUCState) error { + var myJid string + if c.me != nil { + myJid = gateway.CHATJID(c.me.Id, true) + } + _, toJids := c.getMUCJoinedJIDs(chatID, mucState, mucState == nil) + args := []args.V{ + gateway.SPFrom(gateway.MUCNODE(chatID)), + gateway.SPResource(c.GetMUCNickname(0)), + gateway.SPMUCAffiliation("none"), + gateway.SPMUCRole("none"), + gateway.SPMUCJid(myJid), + gateway.SPMUCStatusCodes(statusCodes), + gateway.SPToJids(toJids), + } + if destroy { + args = append(args, gateway.SPMUCDestroy("")) + } else { + args = append(args, gateway.SPType("unavailable")) + } + return c.sendPresence(args...) +} + +// achtung: assuming a locked mucState context +func (c *Client) kickStaleOccupant(chatID int64, deleted *MUCOccupant, mucState *MUCState) { + if deleted == nil { + return + } + + // u mad? put me back! + if c.me != nil && c.me.Id == deleted.id { + deleted = mucState.Occupants.Set(deleted.id, deleted) + if deleted == nil { + // WTF but okay + return + } + } + + c.sendPresence( + gateway.SPFrom(gateway.MUCNODE(chatID)), + gateway.SPResource(deleted.Nickname), + gateway.SPType("unavailable"), + gateway.SPMUCAffiliation(deleted.Affiliation), + gateway.SPMUCRole(deleted.Role), + gateway.SPMUCStatusCodes([]uint16{307, 333}), + ) +} + +// MigrateToMUCs unsubscribes from legacy group chats and invites to MUCs +func (c *Client) MigrateToMUCs() { + var chatIDs []int64 + for _, chat := range c.GetGroupChats() { + chatIDs = append(chatIDs, chat.Id) + } + + c.DelayedStatusesLock.Lock() + for _, chatID := range chatIDs { + delete(c.DelayedStatuses, chatID) + } + c.DelayedStatusesLock.Unlock() + + for _, chatID := range chatIDs { + c.unsubscribe(chatID) + for resource := range c.resourcesRange() { + gateway.InviteToMUC(chatID, c.jid+"/"+resource, c.xmpp) + } + } +} + +// MigrateFromMUCs kicks from MUCs and subscribes back to legacy group chats +func (c *Client) MigrateFromMUCs() { + var chatIDs []int64 + for _, chat := range c.GetGroupChats() { + chatIDs = append(chatIDs, chat.Id) + } + + for _, chatID := range chatIDs { + c.deleteMUC(chatID, []uint16{110, 332}, false) + c.subscribeToID(chatID, nil, false) + } +} + +// SetChatMemberStatus is a handy wrapper for the following TDLib method +func (c *Client) SetChatMemberStatus(chatID, userID int64, status ChatMemberStatus, numericPayload int64, stringPayload, nickname string) error { + var chatMemberStatus client.ChatMemberStatus + switch status { + case ChatMemberStatusKicked: + chatMemberStatus = &client.ChatMemberStatusLeft{} + case ChatMemberStatusMuted: + chatMemberStatus = &client.ChatMemberStatusRestricted{ + IsMember: true, + RestrictedUntilDate: c.formatBantime(numericPayload), + Permissions: &permissionsReadonly, + } + case ChatMemberStatusUnmuted: + chatMemberStatus = &client.ChatMemberStatusRestricted{ + IsMember: true, + RestrictedUntilDate: 0, + Permissions: &permissionsMember, + } + case ChatMemberStatusBanned: + chatMemberStatus = &client.ChatMemberStatusBanned{ + BannedUntilDate: c.formatBantime(numericPayload), + } + case ChatMemberStatusUnbanned: + chatMemberStatus = &client.ChatMemberStatusMember{} + case ChatMemberStatusPromoted: + chatMemberStatus = &client.ChatMemberStatusAdministrator{ + CanBeEdited: true, + Rights: &permissionsAdmin, + CustomTitle: stringPayload, + } + } + _, err := c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ + ChatId: chatID, + MemberId: &client.MessageSenderUser{UserId: userID}, + Status: chatMemberStatus, + }) + if err == nil && nickname != "" { + c.mucOccupantRolePresence(chatID, userID, status, nickname) + } + return err +} + +// SetChatTitle is a handy wrapper for the following TDLib method +func (c *Client) SetChatTitle(chatID int64, title string) error { + _, err := c.client.SetChatTitle(&client.SetChatTitleRequest{ + ChatId: chatID, + Title: title, + }) + return err +} + +// SetChatDescription is a handy wrapper for the following TDLib method +func (c *Client) SetChatDescription(chatID int64, description string) error { + _, err := c.client.SetChatDescription(&client.SetChatDescriptionRequest{ + ChatId: chatID, + Description: description, + }) + return err +} + +// SetChatPermissions is a handy wrapper for the following TDLib method +func (c *Client) SetChatPermissions(chatID int64, permissions *client.ChatPermissions) error { + _, err := c.client.SetChatPermissions(&client.SetChatPermissionsRequest{ + ChatId: chatID, + Permissions: permissions, + }) + return err +} + +// DeleteChat is a handy wrapper for the following TDLib method +func (c *Client) DeleteChat(chatID int64) error { + _, err := c.client.DeleteChat(&client.DeleteChatRequest{ + ChatId: chatID, + }) + return err +} + +// GetMessage is a handy wrapper for the following TDLib method +func (c *Client) GetMessage(chatID, messageId int64) (*client.Message, error) { + return c.client.GetMessage(&client.GetMessageRequest{ + ChatId: chatID, + MessageId: messageId, + }) +} + +// GetChatMessagePosition is a handy wrapper for the following TDLib method +func (c *Client) GetChatMessagePosition(chatID, messageId int64) (*client.Count, error) { + return c.client.GetChatMessagePosition(&client.GetChatMessagePositionRequest{ + ChatId: chatID, + MessageId: messageId, + }) +} + +// CloneChatPermissions makes a copy of ChatPermissions structure +func CloneChatPermissions(permissions *client.ChatPermissions) *client.ChatPermissions { + return &client.ChatPermissions{ + CanSendBasicMessages: permissions.CanSendBasicMessages, + CanSendAudios: permissions.CanSendAudios, + CanSendDocuments: permissions.CanSendDocuments, + CanSendPhotos: permissions.CanSendPhotos, + CanSendVideos: permissions.CanSendVideos, + CanSendVideoNotes: permissions.CanSendVideoNotes, + CanSendVoiceNotes: permissions.CanSendVoiceNotes, + CanSendPolls: permissions.CanSendPolls, + CanSendOtherMessages: permissions.CanSendOtherMessages, + CanAddWebPagePreviews: permissions.CanAddWebPagePreviews, + CanChangeInfo: permissions.CanChangeInfo, + CanInviteUsers: permissions.CanInviteUsers, + CanPinMessages: permissions.CanPinMessages, + CanManageTopics: permissions.CanManageTopics, + } +} + +func (c *Client) mucOccupantRolePresence(chatID, userID int64, status ChatMemberStatus, nickname string) { + _, toJids := c.getMUCJoinedJIDs(chatID, nil, true) + args := []args.V{ + gateway.SPFrom(gateway.MUCNODE(chatID)), + gateway.SPResource(nickname), + gateway.SPImmed(true), + gateway.SPMUCJid(gateway.CHATJID(userID, true)), + gateway.SPToJids(toJids), + } + + var statusCodes []uint16 + var newAffiliation, newRole string + + switch status { + case ChatMemberStatusKicked: + args = append(args, gateway.SPType("unavailable")) + newAffiliation = "none" + newRole = "none" + + statusCodes = append(statusCodes, 307) + if c.me != nil && userID == c.me.Id { + statusCodes = append(statusCodes, 110) + } + case ChatMemberStatusMuted: + newAffiliation = "member" + newRole = "visitor" + case ChatMemberStatusUnmuted, ChatMemberStatusUnbanned: + newAffiliation = "member" + newRole = "participant" + case ChatMemberStatusBanned: + args = append(args, gateway.SPType("unavailable")) + newAffiliation = "outcast" + newRole = "none" + statusCodes = append(statusCodes, 301) + case ChatMemberStatusPromoted: + newAffiliation = "admin" + newRole = "moderator" + } + + args = append( + args, + gateway.SPMUCAffiliation(newAffiliation), + gateway.SPMUCRole(newRole), + gateway.SPMUCStatusCodes(statusCodes), + ) + + c.sendPresence(args...) + + c.locks.mucCacheLock.Lock() + mucState, ok := c.mucCache[chatID] + if ok && mucState != nil { + if status == ChatMemberStatusKicked || status == ChatMemberStatusBanned { + mucState.Occupants.Delete(userID) + } else { + occupant, ok := mucState.Occupants.Get(userID) + if ok { + mucState.Occupants.Bump(occupant) + occupant.Affiliation = newAffiliation + occupant.Role = newRole + } + } + } + c.locks.mucCacheLock.Unlock() +} + +// IsMessageSendingPermitted evaluates if permissions of the chat allow message sending +func (c *Client) IsMessageSendingPermitted(permissions *client.ChatPermissions) bool { + if permissions == nil { + return true + } + + return permissions.CanSendBasicMessages || + permissions.CanSendAudios || + permissions.CanSendDocuments || + permissions.CanSendPhotos || + permissions.CanSendVideos || + permissions.CanSendVideoNotes || + permissions.CanSendVoiceNotes || + permissions.CanSendPolls || + permissions.CanSendOtherMessages +} + +// GetMyStatusInChat checks the membership status of current account in the given chat +func (c *Client) GetMyStatusInChat(chatID int64) ChatMemberStatus { + if c.me == nil { + return ChatMemberStatusIllegal + } + + member, err := c.client.GetChatMember(&client.GetChatMemberRequest{ + ChatId: chatID, + MemberId: &client.MessageSenderUser{UserId: c.me.Id}, + }) + if err != nil { + return ChatMemberStatusIllegal + } + return c.getChatMemberStatus(member.Status) +} + +func (c *Client) getChatMemberStatus(status client.ChatMemberStatus) ChatMemberStatus { + switch status.ChatMemberStatusType() { + case client.TypeChatMemberStatusCreator, client.TypeChatMemberStatusAdministrator: + return ChatMemberStatusPromoted + case client.TypeChatMemberStatusMember: + return ChatMemberStatusUnbanned + case client.TypeChatMemberStatusRestricted: + return ChatMemberStatusMuted + case client.TypeChatMemberStatusLeft: + return ChatMemberStatusKicked + case client.TypeChatMemberStatusBanned: + return ChatMemberStatusBanned + } + + return ChatMemberStatusIllegal +} + +// GetMUCNicknameByUsername implement the MentionRetriever interface for message formatters +func (c *Client) GetMUCNicknameByUsername(username string) (string, error) { + chat, err := c.client.SearchPublicChat(&client.SearchPublicChatRequest{ + Username: username, + }) + if err != nil { + return "", err + } + + return c.GetMUCNickname(chat.Id), nil +} + +// FindMessageByTime retrieves the closest message before the given timestamp +func (c *Client) FindMessageByTime(chatID int64, ts time.Time) (*client.Message, error) { + if ts.IsZero() { + return nil, nil + } + + return c.client.GetChatMessageByDate(&client.GetChatMessageByDateRequest{ + ChatId: chatID, + Date: int32(ts.Unix()), // DUROV!!!!!!!!!!!!!!!1111111 + }) +} + +// GetNextMessage attempts to obtain the next message in the history +func (c *Client) GetNextMessage(chatID, messageId int64) (*client.Message, error) { + messages, err := c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatID, + FromMessageId: messageId, + Limit: 1, + Offset: -1, + }) + if err == nil && len(messages.Messages) == 1 && messages.Messages[0] != nil && messages.Messages[0].Id != messageId { + return messages.Messages[0], nil + } + return nil, err +} + +// GetPreviousMessage attempts to obtain the previous message in the history +func (c *Client) GetPreviousMessage(chatID, messageId int64) (*client.Message, error) { + messages, err := c.client.GetChatHistory(&client.GetChatHistoryRequest{ + ChatId: chatID, + FromMessageId: messageId, + Limit: 1, + Offset: 0, + }) + if err == nil && len(messages.Messages) == 1 && messages.Messages[0] != nil && messages.Messages[0].Id != messageId { + return messages.Messages[0], nil + } + return nil, err +} + +// GetErrorCode obtains an error code from a Telegram response error +func GetErrorCode(err error) (int32, bool) { + responseError, ok := err.(client.ResponseError) + if !ok || responseError.Err == nil { + return 0, false + } + return responseError.Err.Code, true +} + +// ChronologicallySortMessages is… self-explanatory (Achtung: destructive) +func ChronologicallySortMessages(messages []*client.Message, reverse bool) []*client.Message { + var sortFunc func(int, int) bool + if reverse { + sortFunc = func(i int, j int) bool { + msg1 := messages[i] + msg2 := messages[j] + return msg1.Date > msg2.Date + } + } else { + sortFunc = func(i int, j int) bool { + msg1 := messages[i] + msg2 := messages[j] + return msg1.Date < msg2.Date + } + } + sort.Slice(messages, sortFunc) + return messages +} + +// ReverseMessagesSlice efficiently reverses a messages slice in-place +func ReverseMessagesSlice(s []*client.Message) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} diff --git a/telegram/utils_test.go b/telegram/utils_test.go index 91002ee..3fb9dc9 100644 --- a/telegram/utils_test.go +++ b/telegram/utils_test.go @@ -68,12 +68,28 @@ func TestFormatMessageOneline(t *testing.T) { }, } - text := (&Client{}).formatMessage(0, 0, true, &message) + text := (&Client{}).formatMessage(0, 0, true, true, &message) if text != "42 | | tist" { t.Errorf("Wrong oneline message formatting: %v", text) } } +func TestFormatMessageNoSender(t *testing.T) { + message := client.Message{ + Id: 42, + Content: &client.MessageText{ + Text: &client.FormattedText{ + Text: "tist", + }, + }, + } + + text := (&Client{}).formatMessage(0, 0, true, false, &message) + if text != "42 | tist" { + t.Errorf("Wrong nosender message formatting: %v", text) + } +} + func TestFormatMessageMultiline(t *testing.T) { message := client.Message{ Id: 42, @@ -84,7 +100,7 @@ func TestFormatMessageMultiline(t *testing.T) { }, } - text := (&Client{}).formatMessage(0, 0, true, &message) + text := (&Client{}).formatMessage(0, 0, true, true, &message) if text != "42 | | tist" { t.Errorf("Wrong multiline message formatting: %v", text) } @@ -104,7 +120,7 @@ func TestFormatMessageOnelinePreview(t *testing.T) { c := &Client{ Session: &persistence.Session{}, } - text := c.formatMessage(0, 0, false, &message) + text := c.formatMessage(0, 0, false, true, &message) if text != "42 | | 10 Jan 2008 21:20:00 | tist" { t.Errorf("Wrong oneline preview message formatting: %v", text) } @@ -124,7 +140,7 @@ func TestFormatMessageMultilinePreview(t *testing.T) { c := &Client{ Session: &persistence.Session{}, } - text := c.formatMessage(0, 0, false, &message) + text := c.formatMessage(0, 0, false, true, &message) if text != "42 | | 10 Jan 2008 21:20:00 | tist\nziz" { t.Errorf("Wrong multiline preview message formatting: %v", text) } @@ -369,6 +385,53 @@ func TestMessageAnimation(t *testing.T) { } } +func TestMessageTtl1(t *testing.T) { + ttl := client.Message{ + Content: &client.MessageChatSetMessageAutoDeleteTime{}, + } + text := (&Client{}).messageToText(&ttl, false) + if text != "The self-destruct timer was disabled" { + t.Errorf("Wrong anonymous off ttl label: %v", text) + } +} + +func TestMessageTtl2(t *testing.T) { + ttl := client.Message{ + Content: &client.MessageChatSetMessageAutoDeleteTime{ + MessageAutoDeleteTime: 3, + }, + } + text := (&Client{}).messageToText(&ttl, false) + if text != "The self-destruct timer was set to 3 seconds" { + t.Errorf("Wrong anonymous ttl label: %v", text) + } +} + +func TestMessageTtl3(t *testing.T) { + ttl := client.Message{ + Content: &client.MessageChatSetMessageAutoDeleteTime{ + FromUserId: 3, + }, + } + text := (&Client{}).messageToText(&ttl, false) + if text != "unknown contact: TDlib instance is offline disabled the self-destruct timer" { + t.Errorf("Wrong off ttl label: %v", text) + } +} + +func TestMessageTtl4(t *testing.T) { + ttl := client.Message{ + Content: &client.MessageChatSetMessageAutoDeleteTime{ + FromUserId: 3, + MessageAutoDeleteTime: 3, + }, + } + text := (&Client{}).messageToText(&ttl, false) + if text != "unknown contact: TDlib instance is offline set the self-destruct timer to 3 seconds" { + t.Errorf("Wrong ttl label: %v", text) + } +} + func TestMessageUnknown(t *testing.T) { unknown := client.Message{ Content: &client.MessageExpiredPhoto{}, @@ -384,20 +447,17 @@ func TestMessageToPrefix1(t *testing.T) { Id: 42, IsOutgoing: true, ForwardInfo: &client.MessageForwardInfo{ - Origin: &client.MessageForwardOriginHiddenUser{ + Origin: &client.MessageOriginHiddenUser{ SenderName: "ziz", }, }, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "", "", nil) + prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "", "", false) if prefix != "➡ 42 | fwd: ziz" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 0 { - t.Errorf("Wrong replyStart: %v", replyStart) - } - if replyEnd != 0 { - t.Errorf("Wrong replyEnd: %v", replyEnd) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } @@ -405,20 +465,17 @@ func TestMessageToPrefix2(t *testing.T) { message := client.Message{ Id: 56, ForwardInfo: &client.MessageForwardInfo{ - Origin: &client.MessageForwardOriginChannel{ + Origin: &client.MessageOriginChannel{ AuthorSignature: "zaz", }, }, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "y.jpg", "", nil) + prefix, gatewayReply := (&Client{Session: &persistence.Session{}}).messageToPrefix(&message, "y.jpg", "", false) if prefix != "⬅ 56 | fwd: (zaz) | preview: y.jpg" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 0 { - t.Errorf("Wrong replyStart: %v", replyStart) - } - if replyEnd != 0 { - t.Errorf("Wrong replyEnd: %v", replyEnd) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } @@ -426,20 +483,17 @@ func TestMessageToPrefix3(t *testing.T) { message := client.Message{ Id: 56, ForwardInfo: &client.MessageForwardInfo{ - Origin: &client.MessageForwardOriginChannel{ + Origin: &client.MessageOriginChannel{ AuthorSignature: "zuz", }, }, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "a.jpg", nil) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "a.jpg", false) if prefix != "< 56 | fwd: (zuz) | file: a.jpg" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 0 { - t.Errorf("Wrong replyStart: %v", replyStart) - } - if replyEnd != 0 { - t.Errorf("Wrong replyEnd: %v", replyEnd) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } @@ -448,15 +502,12 @@ func TestMessageToPrefix4(t *testing.T) { Id: 23, IsOutgoing: true, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", nil) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) if prefix != "> 23" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 0 { - t.Errorf("Wrong replyStart: %v", replyStart) - } - if replyEnd != 0 { - t.Errorf("Wrong replyEnd: %v", replyEnd) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } @@ -464,52 +515,101 @@ func TestMessageToPrefix5(t *testing.T) { message := client.Message{ Id: 560, ForwardInfo: &client.MessageForwardInfo{ - Origin: &client.MessageForwardOriginChat{ + Origin: &client.MessageOriginChat{ AuthorSignature: "zyz", }, }, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "h.jpg", "a.jpg", nil) + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "h.jpg", "a.jpg", false) if prefix != "< 560 | fwd: (zyz) | preview: h.jpg | file: a.jpg" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 0 { - t.Errorf("Wrong replyStart: %v", replyStart) - } - if replyEnd != 0 { - t.Errorf("Wrong replyEnd: %v", replyEnd) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } func TestMessageToPrefix6(t *testing.T) { message := client.Message{ - Id: 23, - IsOutgoing: true, - ReplyToMessageId: 42, - } - reply := client.Message{ - Id: 42, - Content: &client.MessageText{ - Text: &client.FormattedText{ - Text: "tist", + Id: 23, + ChatId: 25, + IsOutgoing: true, + ReplyTo: &client.MessageReplyToMessage{ + ChatId: 41, + Quote: &client.TextQuote{ + Text: &client.FormattedText{ + Text: "tist\nuz\niz", + }, + }, + Origin: &client.MessageOriginHiddenUser{ + SenderName: "ziz", }, }, } - prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", &reply) - if prefix != "> 23 | reply: 42 | | tist" { + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) + if prefix != "> 23 | reply: ziz @ unknown contact: TDlib instance is offline | tist uz iz" { t.Errorf("Wrong prefix: %v", prefix) } - if replyStart != 4 { - t.Errorf("Wrong replyStart: %v", replyStart) + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } - if replyEnd != 26 { - t.Errorf("Wrong replyEnd: %v", replyEnd) +} + +func TestMessageToPrefix7(t *testing.T) { + message := client.Message{ + Id: 23, + ChatId: 42, + IsOutgoing: true, + ReplyTo: &client.MessageReplyToMessage{ + ChatId: 41, + Content: &client.MessageText{ + Text: &client.FormattedText{ + Text: "tist", + }, + }, + Origin: &client.MessageOriginChannel{ + AuthorSignature: "zaz", + }, + }, + } + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false) + if prefix != "> 23 | reply: (zaz) @ unknown contact: TDlib instance is offline | tist" { + t.Errorf("Wrong prefix: %v", prefix) + } + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) + } +} + +func TestMessageToPrefix8(t *testing.T) { + message := client.Message{ + Id: 23, + ChatId: 42, + IsOutgoing: true, + ReplyTo: &client.MessageReplyToMessage{ + ChatId: 41, + Content: &client.MessageText{ + Text: &client.FormattedText{ + Text: "tist", + }, + }, + Origin: &client.MessageOriginChannel{ + AuthorSignature: "zuz", + }, + }, + } + prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", true) + if prefix != "> 23" { + t.Errorf("Wrong prefix: %v", prefix) + } + if gatewayReply != nil { + t.Errorf("Reply is not nil: %v", gatewayReply) } } func GetSenderIdEmpty(t *testing.T) { message := client.Message{} - senderId := (&Client{}).getSenderId(&message) + senderId := (&Client{}).getMessageSenderId(&message) if senderId != 0 { t.Errorf("Wrong sender id: %v", senderId) } @@ -521,7 +621,7 @@ func GetSenderIdUser(t *testing.T) { UserId: 42, }, } - senderId := (&Client{}).getSenderId(&message) + senderId := (&Client{}).getMessageSenderId(&message) if senderId != 42 { t.Errorf("Wrong sender id: %v", senderId) } @@ -533,7 +633,7 @@ func GetSenderIdChat(t *testing.T) { ChatId: -42, }, } - senderId := (&Client{}).getSenderId(&message) + senderId := (&Client{}).getMessageSenderId(&message) if senderId != -42 { t.Errorf("Wrong sender id: %v", senderId) } diff --git a/test/bad_config.yml b/test/bad_config.yml index 094faf4..0b554df 100644 --- a/test/bad_config.yml +++ b/test/bad_config.yml @@ -5,6 +5,7 @@ :link: 'http://tlgrm.localhost/content' # webserver public address :upload: 'https:///xmppfiles.localhost' # xmpp http upload address :tdlib_verbosity: 1 + :mam_threshold: 7 # in days :tdlib: :client: :api_id: '17349' diff --git a/test/good_config.yml b/test/good_config.yml index ac68438..547eb7e 100644 --- a/test/good_config.yml +++ b/test/good_config.yml @@ -5,6 +5,7 @@ :link: '' # webserver public address :upload: '' # xmpp http upload address :tdlib_verbosity: 1 + :tdlib_verbosity: 7 # in days :tdlib: :client: :api_id: '17349' diff --git a/xmpp/component.go b/xmpp/component.go index 0f23d50..1f0ce06 100644 --- a/xmpp/component.go +++ b/xmpp/component.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "dev.narayana.im/narayana/telegabber/badger" "dev.narayana.im/narayana/telegabber/config" "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/telegram" @@ -38,10 +39,11 @@ var sizeRegex = regexp.MustCompile("\\A([0-9]+) ?([KMGTPE]?B?)\\z") // NewComponent starts a new component and wraps it in // a stream manager that you should start yourself -func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig) (*xmpp.StreamManager, *xmpp.Component, error) { +func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig, idsPath string, version string) (*xmpp.StreamManager, *xmpp.Component, error) { var err error gateway.Jid, err = stanza.NewJid(conf.Jid) + gateway.Version = version if err != nil { return nil, nil, err } @@ -53,6 +55,8 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig) (*xmpp.Strea } } + gateway.IdsDB = badger.IdsDBOpen(idsPath) + tgConf = tc if tc.Content.Quota != "" { @@ -62,6 +66,8 @@ func NewComponent(conf config.XMPPConfig, tc config.TelegramConfig) (*xmpp.Strea } } + gateway.MAMThreshold = tc.MAMThreshold + options := xmpp.ComponentOptions{ TransportConfiguration: xmpp.TransportConfiguration{ Address: conf.Host + ":" + conf.Port, @@ -138,11 +144,24 @@ func heartbeat(component *xmpp.Component) { chatID, session.LastSeenStatus(delayedStatus.TimestampOnline), "away", + true, ) delete(session.DelayedStatuses, chatID) } } session.DelayedStatusesLock.Unlock() + + // shrink message id maps + session.MessageIdChangesLock.Lock() + for _, idsMap := range session.MessageIdChanges { + for oldMessageId, newId := range idsMap { + if newId.Ts < now - 60 { + newId.Unlock() + delete(idsMap, oldMessageId) + } + } + } + session.MessageIdChangesLock.Unlock() } sessionLock.Unlock() @@ -163,6 +182,8 @@ func heartbeat(component *xmpp.Component) { // it would be resolved on the next iteration SaveSessions() } + + gateway.IdsDB.Gc() } } @@ -200,7 +221,7 @@ func getTelegramInstance(jid string, savedSession *persistence.Session, componen return session, false } if savedSession.KeepOnline { - if err = session.Connect(""); err != nil { + if err = session.Connect("", false); err != nil { log.Error(err) return session, false } @@ -240,6 +261,9 @@ func Close(component *xmpp.Component) { // save sessions SaveSessions() + // flush the ids database + gateway.IdsDB.Close() + // close stream component.Disconnect() } diff --git a/xmpp/extensions/extensions.go b/xmpp/extensions/extensions.go index 78de47d..9d8914a 100644 --- a/xmpp/extensions/extensions.go +++ b/xmpp/extensions/extensions.go @@ -3,6 +3,7 @@ package extensions import ( "encoding/xml" "strconv" + "time" "gosrc.io/xmpp/stanza" ) @@ -154,12 +155,19 @@ type CarbonSent struct { } // ComponentPrivilege is from XEP-0356 -type ComponentPrivilege struct { +type ComponentPrivilege1 struct { XMLName xml.Name `xml:"urn:xmpp:privilege:1 privilege"` Perms []ComponentPerm `xml:"perm"` Forwarded stanza.Forwarded `xml:"urn:xmpp:forward:0 forwarded"` } +// ComponentPrivilege is from XEP-0356 +type ComponentPrivilege2 struct { + XMLName xml.Name `xml:"urn:xmpp:privilege:2 privilege"` + Perms []ComponentPerm `xml:"perm"` + Forwarded stanza.Forwarded `xml:"urn:xmpp:forward:0 forwarded"` +} + // ComponentPerm is from XEP-0356 type ComponentPerm struct { XMLName xml.Name `xml:"perm"` @@ -180,6 +188,308 @@ type ClientMessage struct { Extensions []stanza.MsgExtension `xml:",omitempty"` } +// Replace is from XEP-0308 +type Replace struct { + XMLName xml.Name `xml:"urn:xmpp:message-correct:0 replace"` + Id string `xml:"id,attr"` +} + +// QueryRegister is from XEP-0077 +type QueryRegister struct { + XMLName xml.Name `xml:"jabber:iq:register query"` + Instructions string `xml:"instructions"` + Username string `xml:"username"` + Registered *QueryRegisterRegistered `xml:"registered"` + Remove *QueryRegisterRemove `xml:"remove"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +// QueryRegisterRegistered is a child element from XEP-0077 +type QueryRegisterRegistered struct { + XMLName xml.Name `xml:"registered"` +} + +// QueryRegisterRemove is a child element from XEP-0077 +type QueryRegisterRemove struct { + XMLName xml.Name `xml:"remove"` +} + +// MessageXLegacyInviteExtension is from JEP-0045 +type MessageXLegacyInviteExtension struct { + XMLName xml.Name `xml:"jabber:x:conference x"` + Jid string `xml:"jid,attr"` +} + +// MessageXMucUserExtension is from XEP-0045 +type MessageXMucUserExtension struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/muc#user x"` + Invite *MessageXMucUserInvite `xml:"invite,omitempty"` + Status *MessageXMucUserStatus `xml:"status,omitempty"` + Item PresenceXMucUserItem `xml:"item,omitempty"` + Password string `xml:"password,omitempty"` +} + +// MessageXMucUserInvite is from XEP-0045 +type MessageXMucUserInvite struct { + XMLName xml.Name `xml:"invite"` + From string `xml:"from,attr"` + Reason string `xml:"reason,omitempty"` + Continue MessageXMucUserInviteContinue +} + +// MessageXMucUserInviteContinue is from XEP-0045 +type MessageXMucUserInviteContinue struct { + XMLName xml.Name `xml:"continue"` + Thread string `xml:"thread,attr,omitempty"` +} + +// MessageXMucUserStatus is from XEP-0486 +type MessageXMucUserStatus struct { + XMLName xml.Name `xml:"status"` + Code string `xml:"code,attr"` +} + +// PresenceXMucUserExtension is from XEP-0045 +type PresenceXMucUserExtension struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/muc#user x"` + Item PresenceXMucUserItem + Destroy *MucDestroy + Statuses []PresenceXMucUserStatus +} + +// PresenceXMucUserItem is from XEP-0045 +type PresenceXMucUserItem struct { + XMLName xml.Name `xml:"item"` + Affiliation string `xml:"affiliation,attr"` + Jid *string `xml:"jid,attr"` + Nick string `xml:"nick,attr,omitempty"` + Role string `xml:"role,attr"` +} + +// PresenceXMucUserStatus is from XEP-0045 +type PresenceXMucUserStatus struct { + XMLName xml.Name `xml:"status"` + Code uint16 `xml:"code,attr"` +} + +// MessageDelay is from XEP-0203 +type MessageDelay struct { + XMLName xml.Name `xml:"urn:xmpp:delay delay"` + From string `xml:"from,attr,omitempty"` + Stamp string `xml:"stamp,attr"` +} + +func NewMessageDelay(timestamp int64, from string) MessageDelay { + return MessageDelay{ + From: from, + Stamp: TimestampToRFC3339(timestamp), + } +} + +func TimestampToRFC3339(timestamp int64) string { + return time.Unix(timestamp, 0).UTC().Format(time.RFC3339) +} + +// MessageDelayLegacy is from XEP-0203 +type MessageDelayLegacy struct { + XMLName xml.Name `xml:"jabber:x:delay x"` + From string `xml:"from,attr"` + Stamp string `xml:"stamp,attr"` +} + +func NewMessageDelayLegacy(timestamp int64, from string) MessageDelayLegacy { + return MessageDelayLegacy{ + From: from, + Stamp: time.Unix(timestamp, 0).UTC().Format("20060102T15:04:05"), + } +} + +// MessageAddresses is from XEP-0033 +type MessageAddresses struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/address addresses"` + Addresses []MessageAddress +} + +// MessageAddress is from XEP-0033 +type MessageAddress struct { + XMLName xml.Name `xml:"address"` + Type string `xml:"type,attr"` + Jid string `xml:"jid,attr"` +} + +// MessageStanzaId is from XEP-0359 +type MessageStanzaId struct { + XMLName xml.Name `xml:"urn:xmpp:sid:0 stanza-id"` + Id string `xml:"id,attr"` + By string `xml:"by,attr"` +} + +// MessageOriginId is from XEP-0359 +type MessageOriginId struct { + XMLName xml.Name `xml:"urn:xmpp:sid:0 origin-id"` + Id string `xml:"id,attr"` +} + +// EmptySubject is a dummy for MUCs to circumvent omitempty. Not registered as it would conflict with Subject field +type EmptySubject struct { + XMLName xml.Name `xml:"subject"` +} + +// QueryMucAdmin is from XEP-0045 +type QueryMucAdmin struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/muc#admin query"` + Items []*QueryMucAdminItem `xml:"item"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +// QueryMucAdminItem is a child element from XEP-0045 +type QueryMucAdminItem struct { + XMLName xml.Name `xml:"item"` + Jid string `xml:"jid,attr,omitempty"` + Nick string `xml:"nick,attr,omitempty"` + Role string `xml:"role,attr,omitempty"` + Affiliation string `xml:"affiliation,attr,omitempty"` + Reason string `xml:"reason,omitempty"` +} + +// QueryMucOwner is from XEP-0045 +type QueryMucOwner struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/muc#owner query"` + Form *stanza.Form `xml:"jabber:x:data x,omitempty"` + Destroy *MucDestroy `xml:"destroy,omitempty"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +// MucDestroy is a child element from XEP-0045 +type MucDestroy struct { + XMLName xml.Name `xml:"destroy"` + Jid string `xml:"jid,attr,omitempty"` + Reason string `xml:"reason,omitempty"` +} + +// MAM2Query is from XEP-0313 +type MAM2Query struct { + XMLName xml.Name `xml:"urn:xmpp:mam:2 query"` + Form *stanza.Form `xml:"jabber:x:data x"` + QueryId string `xml:"queryid,attr,omitempty"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` + FlipPage *FlipPage `xml:"flip-page"` +} + +// MAM1Query is from XEP-0313 +type MAM1Query struct { + XMLName xml.Name `xml:"urn:xmpp:mam:1 query"` + Form *stanza.Form `xml:"jabber:x:data x"` + QueryId string `xml:"queryid,attr,omitempty"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +// MAM0Query is from XEP-0313 +type MAM0Query struct { + XMLName xml.Name `xml:"urn:xmpp:mam:0 query"` + Form *stanza.Form `xml:"jabber:x:data x"` + QueryId string `xml:"queryid,attr,omitempty"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +type MAMQuery interface { + Namespace() string + GetForm() *stanza.Form + GetQueryId() string + GetSet() *stanza.ResultSet + GetFlipPage() *FlipPage +} + +// FlipPage is an extended element from XEP-0313 +type FlipPage struct { + XMLName xml.Name `xml:"flip-page"` +} + +// ForwardedMessage is from XEP-0297 (go-xmpp lacks Delay) +type ForwardedMessage struct { + XMLName xml.Name `xml:"urn:xmpp:forward:0 forwarded"` + Message *ClientMessage `xml:"jabber:client message"` + Delay *MessageDelay `xml:"urn:xmpp:delay delay,omitempty"` +} + +// MAM2MessageResult is from XEP-0313 +type MAM2MessageResult struct { + XMLName xml.Name `xml:"urn:xmpp:mam:2 result"` + Forwarded *ForwardedMessage `xml:"urn:xmpp:forward:0 forwarded,omitempty"` + QueryId string `xml:"queryid,attr,omitempty"` + Id string `xml:"id,attr,omitempty"` +} + +// MAM1MessageResult is from XEP-0313 +type MAM1MessageResult struct { + XMLName xml.Name `xml:"urn:xmpp:mam:1 result"` + Forwarded *ForwardedMessage `xml:"urn:xmpp:forward:0 forwarded,omitempty"` + QueryId string `xml:"queryid,attr,omitempty"` + Id string `xml:"id,attr,omitempty"` +} + +// MAM0MessageResult is from XEP-0313 +type MAM0MessageResult struct { + XMLName xml.Name `xml:"urn:xmpp:mam:0 result"` + Forwarded *ForwardedMessage `xml:"urn:xmpp:forward:0 forwarded,omitempty"` + QueryId string `xml:"queryid,attr,omitempty"` + Id string `xml:"id,attr,omitempty"` +} + +// MAM2Fin is from XEP-0313 +type MAM2Fin struct { + XMLName xml.Name `xml:"urn:xmpp:mam:2 fin"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` + Complete bool `xml:"complete,attr,omitempty"` + Stable bool `xml:"stable,attr"` +} + +// MAM1Fin is from XEP-0313 +type MAM1Fin struct { + XMLName xml.Name `xml:"urn:xmpp:mam:1 fin"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` + Complete bool `xml:"complete,attr,omitempty"` + Stable bool `xml:"stable,attr"` +} + +// MAM0Fin is from XEP-0313 +type MAM0Fin struct { + XMLName xml.Name `xml:"urn:xmpp:mam:0 fin"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` + Complete bool `xml:"complete,attr,omitempty"` + Stable bool `xml:"stable,attr"` +} + +// MAM2Metadata is from XEP-0313 +type MAM2Metadata struct { + XMLName xml.Name `xml:"urn:xmpp:mam:2 metadata"` + Start *MAM2MetadataStart `xml:"start"` + End *MAM2MetadataEnd `xml:"end"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + +// MAM2MetadataStart is from XEP-0313 +type MAM2MetadataStart struct { + XMLName xml.Name `xml:"start"` + Id string `xml:"id,attr,omitempty"` + Timestamp string `xml:"timestamp,attr,omitempty"` +} + +// MAM2MetadataEnd is from XEP-0313 +type MAM2MetadataEnd struct { + XMLName xml.Name `xml:"end"` + Id string `xml:"id,attr,omitempty"` + Timestamp string `xml:"timestamp,attr,omitempty"` +} + +// EntityTime is from XEP-0202 +type EntityTime struct { + XMLName xml.Name `xml:"urn:xmpp:time time"` + Tzo string `xml:"tzo"` + Utc string `xml:"utc"` + ResultSet *stanza.ResultSet `xml:"set,omitempty"` +} + // Namespace is a namespace! func (c PresenceNickExtension) Namespace() string { return c.XMLName.Space @@ -221,15 +531,195 @@ func (c CarbonSent) Namespace() string { } // Namespace is a namespace! -func (c ComponentPrivilege) Namespace() string { +func (c ComponentPrivilege1) Namespace() string { return c.XMLName.Space } +// Namespace is a namespace! +func (c ComponentPrivilege2) Namespace() string { + return c.XMLName.Space +} + +// Namespace is a namespace! +func (c Replace) Namespace() string { + return c.XMLName.Space +} + +// Namespace is a namespace! +func (c QueryRegister) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c QueryRegister) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c PresenceXMucUserExtension) Namespace() string { + return c.XMLName.Space +} + +// Namespace is a namespace! +func (c MessageDelay) Namespace() string { + return c.XMLName.Space +} + +// Namespace is a namespace! +func (c MessageDelayLegacy) Namespace() string { + return c.XMLName.Space +} + +// Namespace is a namespace! +func (c EntityTime) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c EntityTime) GetSet() *stanza.ResultSet { + return c.ResultSet +} + // Name is a packet name func (ClientMessage) Name() string { return "message" } +// Namespace is a namespace! +func (c QueryMucAdmin) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c QueryMucAdmin) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c QueryMucOwner) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c QueryMucOwner) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c MAM2Query) Namespace() string { + return c.XMLName.Space +} + +// GetForm obtains the query form +func (c MAM2Query) GetForm() *stanza.Form { + return c.Form +} + +// GetQueryId obtains the query id +func (c MAM2Query) GetQueryId() string { + return c.QueryId +} + +// GetSet getsets! +func (c MAM2Query) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// GetFlipPage obtains the flip-page element +func (c MAM2Query) GetFlipPage() *FlipPage { + return c.FlipPage +} + +// Namespace is a namespace! +func (c MAM1Query) Namespace() string { + return c.XMLName.Space +} + +// GetForm obtains the query form +func (c MAM1Query) GetForm() *stanza.Form { + return c.Form +} + +// GetQueryId obtains the query id +func (c MAM1Query) GetQueryId() string { + return c.QueryId +} + +// GetSet getsets! +func (c MAM1Query) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// GetFlipPage is a stub as it's not supported in this MAM version +func (c MAM1Query) GetFlipPage() *FlipPage { + return nil +} + +// Namespace is a namespace! +func (c MAM0Query) Namespace() string { + return c.XMLName.Space +} + +// GetForm obtains the query form +func (c MAM0Query) GetForm() *stanza.Form { + return c.Form +} + +// GetQueryId obtains the query id +func (c MAM0Query) GetQueryId() string { + return c.QueryId +} + +// GetSet getsets! +func (c MAM0Query) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// GetFlipPage is a stub as it's not supported in this MAM version +func (c MAM0Query) GetFlipPage() *FlipPage { + return nil +} + +// Namespace is a namespace! +func (c MAM2Fin) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c MAM2Fin) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c MAM1Fin) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c MAM1Fin) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c MAM0Fin) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c MAM0Fin) GetSet() *stanza.ResultSet { + return c.ResultSet +} + +// Namespace is a namespace! +func (c MAM2Metadata) Namespace() string { + return c.XMLName.Space +} + +// GetSet getsets! +func (c MAM2Metadata) GetSet() *stanza.ResultSet { + return c.ResultSet +} + // NewReplyFallback initializes a fallback range func NewReplyFallback(start uint64, end uint64) Fallback { return Fallback{ @@ -286,9 +776,147 @@ func init() { "sent", }, CarbonSent{}) - // component privilege + // component privilege v1 stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ "urn:xmpp:privilege:1", "privilege", - }, ComponentPrivilege{}) + }, ComponentPrivilege1{}) + + // component privilege v2 + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:privilege:2", + "privilege", + }, ComponentPrivilege2{}) + + // message edit + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:message-correct:0", + "replace", + }, Replace{}) + + // register query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "jabber:iq:register", + "query", + }, QueryRegister{}) + + // message muc user + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "http://jabber.org/protocol/muc#user", + "x", + }, MessageXMucUserExtension{}) + + // presence muc user + stanza.TypeRegistry.MapExtension(stanza.PKTPresence, xml.Name{ + "http://jabber.org/protocol/muc#user", + "x", + }, PresenceXMucUserExtension{}) + + // message delay + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:delay", + "delay", + }, MessageDelay{}) + + // legacy message delay + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "jabber:x:delay", + "x", + }, MessageDelayLegacy{}) + + // message addresses + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "http://jabber.org/protocol/address", + "addresses", + }, MessageAddresses{}) + + // stable stanza id + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:sid:0", + "stanza-id", + }, MessageStanzaId{}) + + // message addresses + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:sid:0", + "origin-id", + }, MessageOriginId{}) + + // muc admin query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "http://jabber.org/protocol/muc#admin", + "query", + }, QueryMucAdmin{}) + + // muc owner query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "http://jabber.org/protocol/muc#owner", + "query", + }, QueryMucOwner{}) + + // MAM2 query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:2", + "query", + }, MAM2Query{}) + + // MAM1 query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:1", + "query", + }, MAM1Query{}) + + // MAM0 query + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:0", + "query", + }, MAM0Query{}) + + // MAM2 message result + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:mam:2", + "result", + }, MAM2MessageResult{}) + + // MAM1 message result + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:mam:1", + "result", + }, MAM1MessageResult{}) + + // MAM0 message result + stanza.TypeRegistry.MapExtension(stanza.PKTMessage, xml.Name{ + "urn:xmpp:mam:0", + "result", + }, MAM0MessageResult{}) + + // MAM2 fin + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:2", + "fin", + }, MAM2Fin{}) + + // MAM1 fin + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:1", + "fin", + }, MAM1Fin{}) + + // MAM0 fin + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:0", + "fin", + }, MAM0Fin{}) + + // MAM2 metadata + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:mam:2", + "metadata", + }, MAM2Metadata{}) + + // entity time + stanza.TypeRegistry.MapExtension(stanza.PKTIQ, xml.Name{ + "urn:xmpp:time", + "time", + }, EntityTime{}) } diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 94fb13d..ac80698 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -1,20 +1,24 @@ package gateway import ( - "bytes" + "crypto/sha1" "encoding/base64" "encoding/xml" "github.com/pkg/errors" "fmt" "io" "sort" + "strconv" "strings" "sync" + "dev.narayana.im/narayana/telegabber/badger" "dev.narayana.im/narayana/telegabber/xmpp/extensions" + "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/soheilhy/args" + "github.com/xdg-go/stringprep" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" ) @@ -26,7 +30,35 @@ type Reply struct { End uint64 } +type MarkerType byte + +const ( + MarkerTypeReceived MarkerType = iota + MarkerTypeDisplayed +) + +type marker struct { + Type MarkerType + Id string +} + +type MUCUserItem struct { + Affiliation string + Jid string + Role string +} + const NSNick string = "http://jabber.org/protocol/nick" +const NodeVCard4 string = "urn:xmpp:vcard4" +const NodeAvatarMetadata string = "urn:xmpp:avatar:metadata" +const NodeAvatarMetadataNotify string = NodeAvatarMetadata + "+notify" +const NodeAvatarData string = "urn:xmpp:avatar:data" +const NSCommand string = "http://jabber.org/protocol/commands" +const capsNode string = "https://dev.narayana.im/narayana/telegabber/" + +const NS_MAM2 = "urn:xmpp:mam:2" +const NS_MAM1 = "urn:xmpp:mam:1" +const NS_MAM0 = "urn:xmpp:mam:0" // Queue stores presences to send later var Queue = make(map[string]*stanza.Presence) @@ -35,47 +67,170 @@ var QueueLock = sync.Mutex{} // Jid stores the component's JID object var Jid *stanza.Jid +// Version stores this software's version +var Version string + +// IdsDB provides a disk-backed bidirectional dictionary of Telegram and XMPP ids +var IdsDB badger.IdsDB + // DirtySessions denotes that some Telegram session configurations // were changed and need to be re-flushed to the YamlDB var DirtySessions = false -// MessageOutgoingPermission allows to fake outgoing messages by foreign JIDs -var MessageOutgoingPermission = false +// MessageOutgoingPermissionVersion contains a XEP-0356 version to fake outgoing messages by foreign JIDs +var MessageOutgoingPermissionVersion = 0 -// CapsType is a capability category -type CapsType int -const ( - CapsAudio CapsType = iota -) +// MAMThreshold specifies a day limit behind which history should not be requested to avoid abuse detection and storage overload +var MAMThreshold uint32 -// ContactType is a disco JID category -type ContactType int -const ( - ContactTransport CapsType = iota - ContactPM -) +// CHATNODE converts numeric id to node part of 1-1 chat JID +func CHATNODE(chatId int64) string { + return strconv.FormatInt(chatId, 10) +} + +// CHATJID converts numeric id to 1-1 chat JID +func CHATJID(chatId int64, full bool) string { + var suffix string + if full { + suffix = Jid.Full() + } else { + suffix = Jid.Bare() + } + return CHATNODE(chatId) + "@" + suffix +} + +// MUCNODE converts numeric id to node part of MUC JID +func MUCNODE(chatId int64) string { + return "c" + CHATNODE(chatId) +} + +// MUCJID converts numeric id to MUC JID +func MUCJID(chatId int64) string { + return "c" + CHATJID(chatId, false) +} + +var resourcePrepProfile = stringprep.Profile{ + Mappings: []stringprep.Mapping{ + stringprep.TableB1, + }, + Normalize: true, + Prohibits: []stringprep.Set{ + stringprep.TableC1_2, + stringprep.TableC2_1, + stringprep.TableC2_2, + stringprep.TableC3, + stringprep.TableC4, + stringprep.TableC5, + stringprep.TableC6, + stringprep.TableC7, + stringprep.TableC8, + stringprep.TableC9, + }, + CheckBiDi: true, +} + +// ResourcePrep normalizes a resource according to RFC 6122 +func ResourcePrep(resource string) (string, error) { + return resourcePrepProfile.Prepare(resource) +} // SendMessage creates and sends a message stanza -func SendMessage(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, isOutgoing bool) { - sendMessageWrapper(to, from, body, id, component, reply, "", isOutgoing) +func SendMessage(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, replaceId string, isCarbon, isGroupchat, requestReceipt bool, originalFrom, stanzaId, mamQueryId, mucJID string, mucUserItem *MUCUserItem) { + sendMessageWrapper(to, from, body, "", "", id, component, reply, nil, timestamp, "", replaceId, isCarbon, isGroupchat, false, requestReceipt, originalFrom, 0, "", stanzaId, 0, mamQueryId, mucJID, mucUserItem) } // SendServiceMessage creates and sends a simple message stanza from transport -func SendServiceMessage(to string, body string, component *xmpp.Component) { - sendMessageWrapper(to, "", body, "", component, nil, "", false) +func SendServiceMessage(to, body string, component *xmpp.Component) { + var id string + if uuid, err := uuid.NewRandom(); err == nil { + id = uuid.String() + } + sendMessageWrapper(to, "", body, "", "", id, component, nil, nil, 0, "", "", false, false, false, false, "", 0, "", "", 0, "", "", nil) } // SendTextMessage creates and sends a simple message stanza -func SendTextMessage(to string, from string, body string, component *xmpp.Component) { - sendMessageWrapper(to, from, body, "", component, nil, "", false) +func SendTextMessage(to, from, body string, component *xmpp.Component, isGroupchat bool) { + var id string + if uuid, err := uuid.NewRandom(); err == nil { + id = uuid.String() + } + sendMessageWrapper(to, from, body, "", "", id, component, nil, nil, 0, "", "", false, isGroupchat, false, false, "", 0, "", "", 0, "", "", nil) +} + +// SendMUCAnnouncement creates and sends a message by a temporary occupant +func SendMUCAnnouncement(to, from, body, nickname, id string, component *xmpp.Component) { + if nickname == "" { + nickname = "announcement" + } + + fullFrom := from + "/" + nickname + + SendPresence( + component, + to, + SPFullFrom(fullFrom), + SPMUCAffiliation("admin"), + SPMUCRole("moderator"), + SPMUCJid(from), + ) + + if id == "" { + if uuid, err := uuid.NewRandom(); err == nil { + id = uuid.String() + } + } + sendMessageWrapper(to, fullFrom, body, "", "", id, component, nil, nil, 0, "", "", false, true, false, false, "", 0, "", "", 0, "", "", nil) + + SendPresence( + component, + to, + SPType("unavailable"), + SPFullFrom(fullFrom), + SPMUCAffiliation("none"), + SPMUCRole("none"), + SPMUCJid(from), + ) +} + +// SendErrorMessage creates and sends an error message stanza +func SendErrorMessage(to, from, text string, code int, isGroupchat bool, component *xmpp.Component) { + sendMessageWrapper(to, from, "", "", text, "", component, nil, nil, 0, "", "", false, isGroupchat, false, false, "", code, "", "", 0, "", "", nil) +} + +// SendErrorMessageWithBody creates and sends an error message stanza with body payload +func SendErrorMessageWithBody(to, from, body, errorText, id string, code int, isGroupchat bool, component *xmpp.Component) { + sendMessageWrapper(to, from, body, "", errorText, id, component, nil, nil, 0, "", "", false, isGroupchat, false, false, "", code, "", "", 0, "", "", nil) } // SendMessageWithOOB creates and sends a message stanza with OOB URL -func SendMessageWithOOB(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, oob string, isOutgoing bool) { - sendMessageWrapper(to, from, body, id, component, reply, oob, isOutgoing) +func SendMessageWithOOB(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, oob, replaceId string, isCarbon, isGroupchat, requestReceipt bool, originalFrom, stanzaId, mamQueryId, mucJID string, mucUserItem *MUCUserItem) { + sendMessageWrapper(to, from, body, "", "", id, component, reply, nil, timestamp, oob, replaceId, isCarbon, isGroupchat, false, requestReceipt, originalFrom, 0, "", stanzaId, 0, mamQueryId, mucJID, mucUserItem) } -func sendMessageWrapper(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, oob string, isOutgoing bool) { +// SendSubjectMessage creates and sends a MUC subject +func SendSubjectMessage(to, from, subject, id string, component *xmpp.Component, timestamp int64) { + sendMessageWrapper(to, from, "", subject, "", id, component, nil, nil, timestamp, "", "", false, true, true, false, "", 0, "", "", 0, "", "", nil) +} + +// SendMessageMarker creates and sends a message stanza with a XEP-0333 marker +func SendMessageMarker(to string, from string, component *xmpp.Component, markerType MarkerType, markerId string) { + sendMessageWrapper(to, from, "", "", "", "", component, nil, &marker{ + Type: markerType, + Id: markerId, + }, 0, "", "", false, false, false, false, "", 0, "", "", 0, "", "", nil) +} + +// SendMUCInvite creates and send a MUC invitation message +func SendMUCInvite(to string, from string, component *xmpp.Component, inviteFrom string) { + sendMessageWrapper(to, from, "", "", "", "", component, nil, nil, 0, "", "", false, false, false, false, "", 0, inviteFrom, "", 0, "", "", nil) +} + +// SendMUCStatusCode creates a groupchat message with a muc#user status code +func SendMUCStatusCode(to string, from string, component *xmpp.Component, statusCode int64) { + sendMessageWrapper(to, from, "", "", "", "", component, nil, nil, 0, "", "", false, true, false, false, "", 0, "", "", statusCode, "", "", nil) +} + +func sendMessageWrapper(to, from, body, subject, errorText, id string, component *xmpp.Component, reply *Reply, marker *marker, timestamp int64, oob, replaceId string, isCarbon, isGroupchat, forceSubject, requestReceipt bool, originalFrom string, errorCode int, inviteFrom, stanzaId string, statusCode int64, mamQueryId, mucJID string, mucUserItem *MUCUserItem) { toJid, err := stanza.NewJid(to) if err != nil { log.WithFields(log.Fields{ @@ -90,16 +245,31 @@ func sendMessageWrapper(to string, from string, body string, id string, componen var logFrom string var messageFrom string var messageTo string - if from == "" { - logFrom = componentJid - messageFrom = componentJid - } else { + var bareFrom string + if isGroupchat { logFrom = from - messageFrom = from + "@" + componentJid + messageFrom = from + bareFrom, _, _ = SplitJID(from) + } else { + if from == "" { + logFrom = componentJid + messageFrom = componentJid + bareFrom = componentJid + } else if inviteFrom != "" { + logFrom = from + messageFrom = from + "@" + Jid.Bare() + bareFrom = messageFrom + } else { + logFrom = from + messageFrom = from + "@" + componentJid + bareFrom = from + "@" + Jid.Bare() + } } - if isOutgoing { + if isCarbon { messageTo = messageFrom messageFrom = bareTo + "/" + Jid.Resource + } else if mucJID != "" { + messageTo = mucJID } else { messageTo = to } @@ -109,14 +279,53 @@ func sendMessageWrapper(to string, from string, body string, id string, componen "to": to, }).Warn("Got message") + var messageType stanza.StanzaType + if errorCode != 0 { + messageType = stanza.MessageTypeError + } else if isGroupchat { + messageType = stanza.MessageTypeGroupchat + } else if inviteFrom != "" { + messageType = stanza.MessageTypeNormal + } else { + messageType = stanza.MessageTypeChat + } + message := stanza.Message{ Attrs: stanza.Attrs{ From: messageFrom, To: messageTo, - Type: "chat", + Type: messageType, Id: id, }, - Body: body, + Subject: subject, + Body: body, + } + if errorCode != 0 { + message.Error = stanza.Err{ + Code: errorCode, + Text: errorText, + } + switch errorCode { + case 400: + message.Error.Type = stanza.ErrorTypeModify + message.Error.Reason = "bad-request" + case 403: + message.Error.Type = stanza.ErrorTypeAuth + message.Error.Reason = "forbidden" + case 404: + message.Error.Type = stanza.ErrorTypeCancel + message.Error.Reason = "item-not-found" + case 406: + message.Error.Type = stanza.ErrorTypeModify + message.Error.Reason = "not-acceptable" + case 500: + message.Error.Type = stanza.ErrorTypeWait + message.Error.Reason = "internal-server-error" + default: + log.Error("Unknown error code, falling back with empty reason") + message.Error.Type = stanza.ErrorTypeCancel + message.Error.Reason = "undefined-condition" + } } if oob != "" { @@ -133,13 +342,86 @@ func sendMessageWrapper(to string, from string, body string, id string, componen message.Extensions = append(message.Extensions, extensions.NewReplyFallback(reply.Start, reply.End)) } } + if !isGroupchat && !isCarbon && toJid.Resource != "" && inviteFrom == "" { + message.Extensions = append(message.Extensions, stanza.HintNoCopy{}) + } + if timestamp != 0 && mamQueryId == "" { + var delayFrom string + if isGroupchat { + delayFrom = bareFrom + } + message.Extensions = append(message.Extensions, extensions.NewMessageDelay(timestamp, delayFrom)) + message.Extensions = append(message.Extensions, extensions.NewMessageDelayLegacy(timestamp, delayFrom)) + } + if originalFrom != "" { + message.Extensions = append(message.Extensions, extensions.MessageAddresses{ + Addresses: []extensions.MessageAddress{ + extensions.MessageAddress{ + Type: "ofrom", + Jid: originalFrom, + }, + }, + }) + } + if subject == "" && forceSubject { + message.Extensions = append(message.Extensions, extensions.EmptySubject{}) + } + if marker != nil { + if marker.Type == MarkerTypeReceived { + message.Extensions = append(message.Extensions, stanza.MarkReceived{ID: marker.Id}) + } else if marker.Type == MarkerTypeDisplayed { + message.Extensions = append(message.Extensions, stanza.MarkDisplayed{ID: marker.Id}) + message.Extensions = append(message.Extensions, stanza.ReceiptReceived{ID: marker.Id}) + } + } + if requestReceipt { + message.Extensions = append(message.Extensions, stanza.Markable{}) + } + if replaceId != "" { + message.Extensions = append(message.Extensions, extensions.Replace{Id: replaceId}) + } + var userExt extensions.MessageXMucUserExtension + if inviteFrom != "" { + userExt.Invite = &extensions.MessageXMucUserInvite{ + From: inviteFrom, + } + message.Extensions = append(message.Extensions, extensions.MessageXLegacyInviteExtension{ + Jid: messageFrom, + }) + } + if statusCode != 0 { + userExt.Status = &extensions.MessageXMucUserStatus{ + Code: strconv.FormatInt(statusCode, 10), + } + } + if mucUserItem != nil { + userExt.Item = extensions.PresenceXMucUserItem{ + Affiliation: mucUserItem.Affiliation, + Jid: &mucUserItem.Jid, + Role: mucUserItem.Role, + } + } + if inviteFrom != "" || statusCode != 0 || mucUserItem != nil { + message.Extensions = append(message.Extensions, userExt) + } + if stanzaId != "" { + message.Extensions = append(message.Extensions, extensions.MessageStanzaId{ + Id: stanzaId, + By: bareFrom, + }) + if stanzaId != id { + message.Extensions = append(message.Extensions, extensions.MessageOriginId{ + Id: id, + }) + } + } - if isOutgoing { + if isCarbon { carbonMessage := extensions.ClientMessage{ Attrs: stanza.Attrs{ From: bareTo, To: to, - Type: "chat", + Type: messageType, }, } carbonMessage.Extensions = append(carbonMessage.Extensions, extensions.CarbonSent{ @@ -153,12 +435,72 @@ func sendMessageWrapper(to string, from string, body string, id string, componen To: toJid.Domain, }, } - privilegeMessage.Extensions = append(privilegeMessage.Extensions, extensions.ComponentPrivilege{ - Forwarded: stanza.Forwarded{ - Stanza: carbonMessage, - }, - }) + if MessageOutgoingPermissionVersion == 2 { + privilegeMessage.Extensions = append(privilegeMessage.Extensions, extensions.ComponentPrivilege2{ + Forwarded: stanza.Forwarded{ + Stanza: carbonMessage, + }, + }) + } else { + privilegeMessage.Extensions = append(privilegeMessage.Extensions, extensions.ComponentPrivilege1{ + Forwarded: stanza.Forwarded{ + Stanza: carbonMessage, + }, + }) + } sendMessage(&privilegeMessage, component) + } else if mamQueryId != "" { + mneVpadluProbrasyvatJoshParametryPoraRefaktorit := strings.Split(mamQueryId, " ") + ns := mneVpadluProbrasyvatJoshParametryPoraRefaktorit[0] + mamQueryId = mneVpadluProbrasyvatJoshParametryPoraRefaktorit[1] + delay := extensions.NewMessageDelay(timestamp, "") + + clientMessage := extensions.ClientMessage{ + Attrs: message.Attrs, + Subject: message.Subject, + Body: message.Body, + Thread: message.Thread, + Error: message.Error, + Extensions: message.Extensions, + } + + forwarded := extensions.ForwardedMessage{ + Delay: &delay, + Message: &clientMessage, + } + + var ext stanza.MsgExtension + + switch ns { + case NS_MAM2: + ext = extensions.MAM2MessageResult{ + Id: stanzaId, + QueryId: mamQueryId, + Forwarded: &forwarded, + } + case NS_MAM1: + ext = extensions.MAM1MessageResult{ + Id: stanzaId, + QueryId: mamQueryId, + Forwarded: &forwarded, + } + case NS_MAM0: + ext = extensions.MAM0MessageResult{ + Id: stanzaId, + QueryId: mamQueryId, + Forwarded: &forwarded, + } + } + + mamMessage := stanza.Message{ + Attrs: stanza.Attrs{ + From: mucJID, + To: to, + Type: messageType, + }, + Extensions: []stanza.MsgExtension{ext}, + } + sendMessage(&mamMessage, component) } else { sendMessage(&message, component) } @@ -222,6 +564,9 @@ func LogBadPresence(presence *stanza.Presence) { // SPFrom is a Telegram user id var SPFrom = args.NewString() +// SPFullFrom is for specifying a full from when desired +var SPFullFrom = args.NewString() + // SPType is a presence type var SPType = args.NewString() @@ -246,9 +591,32 @@ var SPImmed = args.NewBool(args.Default(true)) // SPCaps is a XEP-0115 verification string var SPCaps = args.NewString() +// SPMUCAffiliation is a XEP-0045 MUC affiliation +var SPMUCAffiliation = args.NewString() + +// SPMUCRole is a XEP-0045 MUC role +var SPMUCRole = args.NewString() + +// SPMUCNick is a XEP-0045 MUC user nick +var SPMUCNick = args.NewString() + +// SPMUCJid is a real jid of a MUC member +var SPMUCJid = args.NewString() + +// SPMUCStatusCodes is a set of XEP-0045 MUC status codes +var SPMUCStatusCodes = args.New() + +// SPMUCDestroy is a XEP-0045 room destruction element +var SPMUCDestroy = args.NewString() + +// SPToJids achieves to send the presence to certain full jids only +var SPToJids = args.New() + func newPresence(bareJid string, to string, args ...args.V) stanza.Presence { var presenceFrom string - if SPFrom.IsSet(args) { + if SPFullFrom.IsSet(args) { + presenceFrom = SPFullFrom.Get(args) + } else if SPFrom.IsSet(args) { presenceFrom = SPFrom.Get(args) + "@" + bareJid if SPResource.IsSet(args) { resource := SPResource.Get(args) @@ -301,17 +669,58 @@ func newPresence(bareJid string, to string, args ...args.V) stanza.Presence { }) } } + if SPCaps.IsSet(args) { ver := SPCaps.Get(args) if ver != "" { - presence.Extensions = append(presence.Extensions, extensions.CapsExtension{ + presence.Extensions = append(presence.Extensions, stanza.Caps{ Hash: "sha-1", - Node: "https://dev.narayana.im/narayana/telegabber/", + Node: capsNode, Ver: ver, }) } } + if SPMUCAffiliation.IsSet(args) { + affiliation := SPMUCAffiliation.Get(args) + if affiliation != "" { + var role string + if SPMUCRole.IsSet(args) { + role = SPMUCRole.Get(args) + } else { + role = affiliationToRole(affiliation) + } + userExt := extensions.PresenceXMucUserExtension{ + Item: extensions.PresenceXMucUserItem{ + Affiliation: affiliation, + Role: role, + }, + } + if SPMUCNick.IsSet(args) { + userExt.Item.Nick = SPMUCNick.Get(args) + } + if SPMUCJid.IsSet(args) { + mucJid := SPMUCJid.Get(args) + userExt.Item.Jid = &mucJid + } + if SPMUCStatusCodes.IsSet(args) { + statusCodes := SPMUCStatusCodes.Get(args).([]uint16) + for _, statusCode := range statusCodes { + userExt.Statuses = append(userExt.Statuses, extensions.PresenceXMucUserStatus{ + Code: statusCode, + }) + } + } + if SPMUCDestroy.IsSet(args) { + userExt.Destroy = &extensions.MucDestroy{ + Jid: SPMUCDestroy.Get(args), + Reason: "Group was deleted", + } + } + presence.Extensions = append(presence.Extensions, userExt) + } + } + return presence } @@ -331,34 +740,56 @@ func SendPresence(component *xmpp.Component, to string, args ...args.V) error { "to": to, }).Info("Got presence") - presence := newPresence(bareJid, to, args...) - - // explicit check, as marshalling is expensive - if log.GetLevel() == log.DebugLevel { - xmlPresence, err := xml.Marshal(presence) - if err == nil { - log.Debug(string(xmlPresence)) - } else { - log.Debugf("%#v", presence) - } - } - - immed := SPImmed.Get(args) - if immed { - err := ResumableSend(component, presence) - if err != nil { - LogBadPresence(&presence) - return err - } + var tos []string + if SPToJids.IsSet(args) { + tos = SPToJids.Get(args).([]string) } else { - QueueLock.Lock() - Queue[presence.From+presence.To] = &presence - QueueLock.Unlock() + tos = []string{to} + } + for _, to := range tos { + presence := newPresence(bareJid, to, args...) + + // explicit check, as marshalling is expensive + if log.GetLevel() == log.DebugLevel { + xmlPresence, err := xml.Marshal(presence) + if err == nil { + log.Debug(string(xmlPresence)) + } else { + log.Debugf("%#v", presence) + } + } + + immed := SPImmed.Get(args) + if immed { + err := ResumableSend(component, presence) + if err != nil { + LogBadPresence(&presence) + return err + } + } else { + QueueLock.Lock() + Queue[presence.From+presence.To] = &presence + QueueLock.Unlock() + } } return nil } +// SPAppendFrom appends numeric from and resource to varargs +func SPAppendFrom(oldArgs []args.V, id int64) []args.V { + newArgs := append(oldArgs, SPFrom(CHATNODE(id))) + newArgs = append(newArgs, SPResource(Jid.Resource)) + return newArgs +} + +// SimplePresence crafts simple presence varargs +func SimplePresence(from int64, typ string) []args.V { + args := []args.V{SPType(typ)} + args = SPAppendFrom(args, from) + return args +} + // ResumableSend tries to resume the connection once and sends the packet again func ResumableSend(component *xmpp.Component, packet stanza.Packet) error { err := component.Send(packet) @@ -376,6 +807,12 @@ func ResumableSend(component *xmpp.Component, packet stanza.Packet) error { return err } +// SubscribeToTransport ensures a two-way subscription to the transport +func SubscribeToTransport(component *xmpp.Component, jid string) { + SendPresence(component, jid, SPType("subscribe")) + SendPresence(component, jid, SPType("subscribed")) +} + // SplitJID tokenizes a JID string to bare JID and resource func SplitJID(from string) (string, string, bool) { fromJid, err := stanza.NewJid(from) @@ -388,63 +825,183 @@ func SplitJID(from string) (string, string, bool) { return fromJid.Bare(), fromJid.Resource, true } -func getDiscoFeatures(caps []CapsType) []string { - features := []string{ - "http://jabber.org/protocol/caps", - "http://jabber.org/protocol/disco#info", - } - for typ := range features { - switch typ { - case CapsAudio: - features = append( - features, - "urn:xmpp:jingle-message:0", - "urn:xmpp:jingle:1", - "urn:xmpp:jingle:apps:dtls:0", - "urn:xmpp:jingle:apps:rtp:1", - "urn:xmpp:jingle:apps:rtp:audio", - "urn:xmpp:jingle:transports:ice-udp:1", - ) +// GetDiscoInfo generates a disco info IQ query response and possibly returns error code +func GetDiscoInfo(session TelegramSession, node string, to string) (*stanza.DiscoInfo, int) { + disco := stanza.DiscoInfo{} + toID, toOk, toIsGroup := ToToID(to) + + if toIsGroup { + toJid, err := stanza.NewJid(to) + if err == nil && toJid.Resource != "" { + return nil, 400 } } - return features -} -// GetDiscoInfo generates a disco info IQ query response -func GetDiscoInfo(typ ContactType, features []string) *stanza.DiscoInfo { - disco := stanza.DiscoInfo{} - if typ == ContactPM { - disco.AddIdentity("", "account", "registered") + if node == "" { + var isMuc bool + if session != nil { + conf := session.GetPersistenceSession() + if conf.MUC { + if toOk && toIsGroup { + chat, _, err := session.GetContactByID(toID, nil, true) + if err == nil && session.IsGroup(chat) { + isMuc = true + disco.AddIdentity(chat.Title, "conference", "text") + + disco.AddFeatures( + "http://jabber.org/protocol/muc", + "muc_persistent", + "muc_hidden", + "muc_moderated", + "muc_nonanonymous", + "muc_unsecured", + "http://jabber.org/protocol/muc#stable_id", + "jabber:iq:register", + NS_MAM0, + NS_MAM1, + NS_MAM2, + "urn:xmpp:mam:2#extended", + "urn:xmpp:sid:0", + "vcard-temp", + ) + fields := []*stanza.Field{ + &stanza.Field{ + Var: "FORM_TYPE", + Type: "hidden", + ValuesList: []string{"http://jabber.org/protocol/muc#roominfo"}, + }, + &stanza.Field{ + Var: "muc#roominfo_description", + Label: "Description", + ValuesList: []string{session.GetChatDescription(chat)}, + }, + &stanza.Field{ + Var: "muc#roominfo_occupants", + Label: "Number of occupants", + ValuesList: []string{strconv.FormatInt(int64(session.GetChatMemberCount(chat)), 10)}, + }, + } + + hashedAvatar := session.GetHashedAvatar(toID) + if hashedAvatar != nil { + fields = append(fields, &stanza.Field{ + Var: "muc#roominfo_avatarhash", + Label: "Avatar hash", + ValuesList: []string{hashedAvatar.Hash}, + }) + } + + disco.Form = stanza.NewForm(fields, "result") + } + } else if !toOk { + disco.AddFeatures( + stanza.NSDiscoItems, + "http://jabber.org/protocol/muc#stable_id", + ) + disco.AddIdentity("Telegram group chats", "conference", "text") + } + } + + if toOk && !toIsGroup && session.CanBeCalled(toID) { + disco.AddFeatures( + "urn:xmpp:jingle-message:0", + "urn:xmpp:jingle:1", + "urn:xmpp:jingle:apps:dtls:0", + "urn:xmpp:jingle:apps:rtp:1", + "urn:xmpp:jingle:apps:rtp:audio", + "urn:xmpp:jingle:transports:ice-udp:1", + ) + } + } + + if toOk { + if toIsGroup { + if !isMuc { + return nil, 404 + } + } else { + disco.AddIdentity("", "account", "registered") + } + disco.AddFeatures(stanza.NSMsgChatMarkers) + disco.AddFeatures(stanza.NSMsgReceipts) + } else { + disco.AddIdentity("Telegram Gateway", "gateway", "telegram") + disco.AddFeatures("jabber:iq:register") + } + disco.AddFeatures("http://jabber.org/protocol/disco#info") + disco.AddFeatures("http://jabber.org/protocol/caps") + disco.AddFeatures(NSCommand) + disco.AddFeatures("jabber:iq:version") + disco.AddFeatures("urn:xmpp:time") + } else if node == "x-roomuser-item" { + if session != nil { + conf := session.GetPersistenceSession() + if conf.MUC { + if toOk && toIsGroup { + chat, _, err := session.GetContactByID(toID, nil, true) + if err == nil && session.IsGroup(chat) { + disco.SetNode(node) + disco.AddIdentity(session.GetMUCNickname(0), "conference", "text") + } + } + } + } + } else if node == "http://jabber.org/protocol/muc#traffic" { + // noop yet, empty result as intended, TODO: add XHTML whenever supported + } else if strings.HasPrefix(node, capsNode) { + nodeParts := strings.Split(node, "#") + if session != nil && len(nodeParts) == 2 && nodeParts[0] == capsNode { + di, ok := session.GetVerDisco(nodeParts[1]) + if ok && di != nil { + return di, 0 + } + } } else { - disco.AddIdentity("Telegram Gateway", "gateway", "telegram") + var chatType ChatType + var chatTypeErr error + if session != nil { + chatType, _, chatTypeErr = session.GetChatType(toID, true) + } + + var cmdType CommandType + if toOk { + cmdType = CommandTypeChat + } else { + cmdType = CommandTypeTransport + } + + for name, command := range GetCommands(cmdType) { + if node == name { + if chatTypeErr == nil && !IsCommandForChatType(command, chatType) { + break + } + disco.AddIdentity(CommandToHelpString(name, command), "automation", "command-node") + disco.AddFeatures(NSCommand, "jabber:x:data") + break + } + } } - disco.AddFeatures(features...) - return &disco + + return &disco, 0 } // GetCapsVer hashes a capabilities set into a verification string -func GetCapsVer(caps []CapsType) (string, error) { - features := getDiscoFeatures(caps) - disco := GetDiscoInfo(features) - discoToCapsHash(disco) - buf := new(bytes.Buffer) - binval := base64.NewEncoder(base64.StdEncoding, buf) - _, err = io.Copy(binval, file) - binval.Close() - if err != nil { - return "", errors.Wrap(err, "Error calculating caps base64") +func GetCapsVer(session TelegramSession, to string) (string, *stanza.DiscoInfo, int) { + disco, errorCode := GetDiscoInfo(session, "", to) + if errorCode != 0 { + return "", nil, errorCode } - return buf.String(), nil + return discoToCapsHash(disco), disco, 0 } func iOctetComparator(a, b string) bool { return a < b } -func discoToCaps(disco *stanza.DiscoInfo) string { - var s strings.Builder - var identities, vars, capsForms []string +func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) { + var identities, vars []string + capsSeparator := []byte{'<'} for _, identity := range disco.Identity { identities = append(identities, fmt.Sprintf( @@ -454,25 +1011,27 @@ func discoToCaps(disco *stanza.DiscoInfo) string { identity.Name, )) } - sort.Slice(identities, iOctetComparator) + sort.Slice(identities, func(i, j int) bool { return identities[i] < identities[j] }) for _, identity := range identities { - s.WriteString(identity) - s.WriteString(">") + w.Write([]byte(identity)) + w.Write(capsSeparator) } for _, feature := range disco.Features { vars = append(vars, feature.Var) } - sort.Slice(vars, iOctetComparator) - for _, var := range vars { - s.WriteString(var) - s.WriteString(">") + sort.Slice(vars, func(i, j int) bool { return vars[i] < vars[j] }) + for _, v := range vars { + w.Write([]byte(v)) + w.Write(capsSeparator) } if disco.Form != nil { fields := make([]*stanza.Field, len(disco.Form.Fields)) copy(fields, disco.Form.Fields) - sort.Slice(fields, func(a, b *stanza.Field) bool { + sort.Slice(fields, func(i, j int) bool { + a := fields[i] + b := fields[j] if a.Var == "FORM_TYPE" { return true } @@ -482,9 +1041,117 @@ func discoToCaps(disco *stanza.DiscoInfo) string { return a.Var < b.Var }) for _, field := range fields { - + if field.Var == "FORM_TYPE" { + if len(field.ValuesList) > 0 { + w.Write([]byte(field.ValuesList[0])) + w.Write(capsSeparator) + } + } else { + w.Write([]byte(field.Var)) + w.Write(capsSeparator) + + var values []string + // useless optimisation? + if len(field.ValuesList) < 2 { + values = field.ValuesList + } else { + values = make([]string, len(field.ValuesList)) + copy(values, field.ValuesList) + sort.Slice(values, func(i, j int) bool { return values[i] < values[j] }) + } + + for _, value := range values { + w.Write([]byte(value)) + w.Write(capsSeparator) + } + } } } - - return s.String() +} + +func discoToCapsHash(disco *stanza.DiscoInfo) string { + hash := sha1.New() + discoToCaps(disco, hash) + sha1Hash := hash.Sum(nil) + return base64.StdEncoding.EncodeToString(sha1Hash) +} + +func affiliationToRole(affilation string) string { + switch affilation { + case "owner", "admin": + return "moderator" + case "member": + return "participant" + } + return "none" +} + +// SendPubSubAvatarNotification encourages clients to fetch an avatar +func SendPubSubAvatarNotification(component *xmpp.Component, jid string, chatJid string, sha1 string, size int64) { + info := stanza.Node{ + XMLName: xml.Name{Local: "info"}, + Attrs: []xml.Attr{ + xml.Attr{Name: xml.Name{Local: "bytes"}, Value: strconv.FormatInt(size, 10)}, + xml.Attr{Name: xml.Name{Local: "height"}, Value: "160"}, + xml.Attr{Name: xml.Name{Local: "id"}, Value: sha1}, + xml.Attr{Name: xml.Name{Local: "type"}, Value: "image/jpeg"}, + xml.Attr{Name: xml.Name{Local: "width"}, Value: "160"}, + }, + } + log.WithFields(log.Fields{ + "chatJid": chatJid, + }).Debugf("%#v", info) + + event := &stanza.PubSubEvent{ + EventElement: &stanza.ItemsEvent{ + Node: NodeAvatarMetadata, + Items: []stanza.ItemEvent{ + stanza.ItemEvent{ + Id: sha1, + Any: &stanza.Node{ + XMLName: xml.Name{Local: "metadata", Space: NodeAvatarMetadata}, + Nodes: []stanza.Node{info}, + }, + }, + }, + }, + } + + message := stanza.Message{ + Attrs: stanza.Attrs{ + From: chatJid, + To: jid, + Type: stanza.MessageTypeHeadline, + }, + Extensions: []stanza.MsgExtension{event}, + } + + _ = ResumableSend(component, message) +} + +// InviteToMUC is a MUC invite wrapper +func InviteToMUC(chatID int64, jid string, component *xmpp.Component) { + SendMUCInvite(jid, MUCNODE(chatID), component, Jid.Full()) +} + +// ToToID parses a JID to components +func ToToID(to string) (int64, bool, bool) { + var isGroup bool + toParts := strings.Split(to, "@") + if len(toParts) < 2 { + return 0, false, isGroup + } + node := toParts[0] + if strings.HasPrefix(node, "c") { + isGroup = true + node = node[1:] + } + toID, err := strconv.ParseInt(node, 10, 64) + if err != nil { + log.WithFields(log.Fields{ + "to": to, + }).Error(errors.Wrap(err, "Invalid to JID!")) + return 0, false, isGroup + } + return toID, true, isGroup } diff --git a/xmpp/gateway/gateway_test.go b/xmpp/gateway/gateway_test.go index b75db4d..430aa49 100644 --- a/xmpp/gateway/gateway_test.go +++ b/xmpp/gateway/gateway_test.go @@ -1,7 +1,9 @@ package gateway import ( + "crypto/sha1" "encoding/xml" + "strings" "testing" "gosrc.io/xmpp/stanza" @@ -54,6 +56,124 @@ func TestPresencePhoto(t *testing.T) { } func TestPresenceCaps(t *testing.T) { - caps := newPresence("from@test", "to@test", SPCaps("QgayPKawpkPSDYmwT/WM94uAlu0=")) - testPresence(t, presence, "") + presence := newPresence("from@test", "to@test", SPCaps("QgayPKawpkPSDYmwT/WM94uAlu0=")) + testPresence(t, presence, "") +} + +func testCapsHash(t *testing.T, disco *stanza.DiscoInfo, reference string) { + b64 := discoToCapsHash(disco) + if b64 != reference { + hash := sha1.New() + discoToCaps(disco, hash) + sha1Hash := hash.Sum(nil) + + var sb strings.Builder + discoToCaps(disco, &sb) + + t.Errorf("%v does not match %v\nRaw hash: %v\nRaw string: %v", b64, reference, sha1Hash, sb.String()) + } +} + +func TestDiscoCapsHash1(t *testing.T) { + disco := stanza.DiscoInfo{ + Identity: []stanza.Identity{ + stanza.Identity{ + Name: "Exodus 0.9.1", + Category: "client", + Type: "pc", + }, + }, + Features: []stanza.Feature{ + stanza.Feature{ Var: "http://jabber.org/protocol/disco#info" }, + stanza.Feature{ Var: "http://jabber.org/protocol/disco#items" }, + stanza.Feature{ Var: "http://jabber.org/protocol/muc" }, + stanza.Feature{ Var: "http://jabber.org/protocol/caps" }, + }, + } + testCapsHash(t, &disco, "QgayPKawpkPSDYmwT/WM94uAlu0=") +} + +func TestDiscoCapsHash2(t *testing.T) { + disco := stanza.DiscoInfo{ + Identity: []stanza.Identity{ + stanza.Identity{ + Name: "Psi 0.11", + Category: "client", + Type: "pc", + }, + stanza.Identity{ + Name: "Ψ 0.11", + Category: "client", + Type: "pc", + }, + }, + Features: []stanza.Feature{ + stanza.Feature{ Var: "http://jabber.org/protocol/disco#info" }, + stanza.Feature{ Var: "http://jabber.org/protocol/disco#items" }, + stanza.Feature{ Var: "http://jabber.org/protocol/muc" }, + stanza.Feature{ Var: "http://jabber.org/protocol/caps" }, + }, + Form: &stanza.Form{ + Type: stanza.FormTypeResult, + Fields: []*stanza.Field{ + &stanza.Field{ + Var: "FORM_TYPE", + Type: stanza.FieldTypeHidden, + ValuesList: []string{"urn:xmpp:dataforms:softwareinfo"}, + }, + &stanza.Field{ + Var: "ip_version", + Type: stanza.FieldTypeTextMulti, + ValuesList: []string{"ipv4", "ipv6"}, + }, + &stanza.Field{ + Var: "os", + ValuesList: []string{"Mac"}, + }, + &stanza.Field{ + Var: "os_version", + ValuesList: []string{"10.5.1"}, + }, + &stanza.Field{ + Var: "software", + ValuesList: []string{"Psi"}, + }, + &stanza.Field{ + Var: "software_version", + ValuesList: []string{"0.11"}, + }, + }, + }, + } + testCapsHash(t, &disco, "MxdZjNKNku1+SiM9N92yqIK2HTQ=") +} + +func TestDiscoCapsHash3(t *testing.T) { + disco := stanza.DiscoInfo{ + Identity: []stanza.Identity{ + stanza.Identity{ + Name: "BombusMod", + Category: "client", + Type: "mobile", + }, + }, + Features: []stanza.Feature{ + stanza.Feature{ Var: "http://jabber.org/protocol/activity" }, + stanza.Feature{ Var: "http://jabber.org/protocol/activity+notify" }, + stanza.Feature{ Var: "http://jabber.org/protocol/caps" }, + stanza.Feature{ Var: "http://jabber.org/protocol/commands" }, + stanza.Feature{ Var: "http://jabber.org/protocol/disco#info" }, + stanza.Feature{ Var: "http://jabber.org/protocol/disco#items" }, + stanza.Feature{ Var: "http://jabber.org/protocol/rosterx" }, + stanza.Feature{ Var: "jabber:iq:last" }, + stanza.Feature{ Var: "jabber:iq:privacy" }, + stanza.Feature{ Var: "jabber:iq:roster" }, + stanza.Feature{ Var: "jabber:iq:time" }, + stanza.Feature{ Var: "jabber:iq:version" }, + stanza.Feature{ Var: "jabber:x:oob" }, + stanza.Feature{ Var: "urn:xmpp:ping" }, + stanza.Feature{ Var: "urn:xmpp:time" }, + }, + } + testCapsHash(t, &disco, "7Awj7pIUiI5UW/L2fdtzXZFQHsw=") } diff --git a/xmpp/gateway/telegram_session.go b/xmpp/gateway/telegram_session.go new file mode 100644 index 0000000..251ae96 --- /dev/null +++ b/xmpp/gateway/telegram_session.go @@ -0,0 +1,222 @@ +package gateway + +import ( + "sort" + "strings" + + "dev.narayana.im/narayana/telegabber/persistence" + + "github.com/zelenin/go-tdlib/client" + "gosrc.io/xmpp/stanza" +) + +// HashedAvatar stores a SHA-1 hash and a Telegram file ID +type HashedAvatar struct { + Hash string + File int32 +} + +type command struct { + RequiredArgs int + Arguments []string + Description string + LoginOnly bool + NotFor *[]ChatType + OnlineOnly bool +} + +// ChatType is an enum of chat types, roughly corresponding to TDLib's one but better +type ChatType int + +const ( + ChatTypeUnknown ChatType = iota + ChatTypePrivate + ChatTypeBasicGroup + ChatTypeSupergroup + ChatTypeSecret + ChatTypeChannel +) + +var TransportCommands = map[string]command{ + "help": command{0, []string{}, "help", false, nil, false}, + "login": command{1, []string{"phone"}, "sign in", false, nil, false}, + "logout": command{0, []string{}, "sign out", true, nil, true}, + "cleanup": command{0, []string{}, "unsubscribe from all known chats", false, nil, false}, + "cancelauth": command{0, []string{}, "quit the signin wizard", false, nil, false}, + "code": command{1, []string{"xxxxx"}, "check one-time code", false, nil, false}, + "password": command{1, []string{"********"}, "check 2fa password", false, nil, false}, + "setusername": command{0, []string{"@username"}, "update @username", true, nil, true}, + "setname": command{1, []string{"first", "last"}, "update name", true, nil, false}, + "setbio": command{0, []string{"Lorem ipsum"}, "update about", true, nil, true}, + "setpassword": command{0, []string{"old", "new"}, "set or remove password", true, nil, true}, + "config": command{0, []string{"param", "value"}, "view or update configuration options", false, nil, false}, + "status": command{0, []string{}, "display current login stage", false, nil, false}, + "report": command{2, []string{"chat", "comment"}, "report a chat by id or @username", true, nil, true}, + "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil, true}, + "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil, true}, + "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil, true}, + "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil, true}, + "preset": command{1, []string{"modern|classic|pass"}, "apply a config preset", false, nil, false}, + "pass": command{0, []string{}, "proceed to next login stage", false, nil, false}, + "finish": command{0, []string{}, "skip post-login configuration", false, nil, false}, +} + +var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} +var notForPM = []ChatType{ChatTypePrivate, ChatTypeSecret} +var notForPMAndBasic = []ChatType{ChatTypePrivate, ChatTypeSecret, ChatTypeBasicGroup} +var onlyForSecret = []ChatType{ChatTypePrivate, ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} + +var ChatCommands = map[string]command{ + "help": command{0, []string{}, "help", false, nil, false}, + "d": command{0, []string{"n"}, "delete your last message(s)", true, nil, true}, + "s": command{1, []string{"edited message"}, "edit your last message", true, nil, true}, + "silent": command{1, []string{"message"}, "send a message without sound", true, nil, true}, + "schedule": command{2, []string{"{online | 2006-01-02T15:04:05 | 15:04:05}", "message"}, "schedules a message either to timestamp or to whenever the user goes online", true, nil, true}, + "raw": command{1, []string{"message"}, "send a raw message not interpeted as a transport command (e.g. a bot command)", true, nil, true}, + "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil, true}, + "vcard": command{0, []string{}, "print vCard as text", true, nil, true}, + "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil, true}, + "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil, true}, + "group": command{1, []string{"title"}, "create groupchat «title» with current user", true, ¬ForGroups, true}, + "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil, true}, + "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil, true}, + "secret": command{0, []string{}, "create secretchat with current user", true, ¬ForGroups, true}, + "search": command{0, []string{"string", "[limit]"}, "search in current chat", true, nil, true}, + "history": command{0, []string{"limit"}, "get last [limit] messages from current chat", true, nil, true}, + "block": command{0, []string{}, "blacklist current user", true, ¬ForGroups, true}, + "unblock": command{0, []string{}, "unblacklist current user", true, ¬ForGroups, true}, + "invite": command{1, []string{"id or @username"}, "add user to current chat", true, ¬ForPM, true}, + "link": command{0, []string{}, "get invite link for current chat", true, ¬ForPM, true}, + "kick": command{1, []string{"id or @username"}, "remove user from current chat", true, ¬ForPM, true}, + "mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", true, ¬ForPMAndBasic, true}, + "unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", true, ¬ForPMAndBasic, true}, + "ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", true, ¬ForPM, true}, + "unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", true, ¬ForPM, true}, + "promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", true, ¬ForPM, true}, + "leave": command{0, []string{}, "leave current chat", true, ¬ForPM, true}, + "leave!": command{0, []string{}, "leave current chat (for owners)", true, ¬ForPM, true}, + "ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", true, &onlyForSecret, true}, + "close": command{0, []string{}, "close current secret chat", true, &onlyForSecret, true}, + "delete": command{0, []string{}, "delete current chat from chat list", true, nil, true}, + "members": command{0, []string{"query"}, "search members [by optional query] in current chat (requires admin rights)", true, nil, true}, +} + +// CommandType distinguishes command sets by chat +type CommandType int + +const ( + CommandTypeTransport CommandType = iota + CommandTypeChat +) + +// OnlineFilter is a tri-state condition for commands selection +type OnlineFilter int + +const ( + OnlineFilterOnline OnlineFilter = iota + OnlineFilterNotOnline + OnlineFilterAny +) + +// SortedCommandKeys sorts a slice with command keys +func SortedCommandKeys(commandMap map[string]command, onlineFilter OnlineFilter) []string { + keys := make([]string, len(commandMap)) + + i := 0 + for k := range commandMap { + command := commandMap[k] + if (onlineFilter == OnlineFilterOnline && !command.OnlineOnly) || (onlineFilter == OnlineFilterNotOnline && command.OnlineOnly) { + continue + } + + keys[i] = k + i++ + } + keys = keys[:i] + + sort.Strings(keys) + + return keys +} + +func CommandsToHelpString(str *strings.Builder, chatType ChatType, onlineFilter OnlineFilter, commandMap map[string]command) { + for _, name := range SortedCommandKeys(commandMap, onlineFilter) { + command := commandMap[name] + if !IsCommandForChatType(command, chatType) { + continue + } + str.WriteString(CommandToHelpString(name, command)) + str.WriteString("\n") + } +} + +// GetCommands exposes the set of commands +func GetCommands(typ CommandType) map[string]command { + var commandMap map[string]command + + switch typ { + case CommandTypeTransport: + commandMap = TransportCommands + case CommandTypeChat: + commandMap = ChatCommands + } + + return commandMap +} + +// GetCommand obtains one command +func GetCommand(typ CommandType, cmd string) (command, bool) { + commands := GetCommands(typ) + command, ok := commands[cmd] + return command, ok +} + +// CommandToHelpString builds a text description of a command +func CommandToHelpString(name string, cmd command) string { + var str strings.Builder + + str.WriteString("/") + str.WriteString(name) + for i, arg := range cmd.Arguments { + optional := i >= cmd.RequiredArgs + str.WriteString(" ") + if optional { + str.WriteString("[") + } + str.WriteString(arg) + if optional { + str.WriteString("]") + } + } + str.WriteString(" — ") + str.WriteString(cmd.Description) + + return str.String() +} + +// IsCommandFor checks the suitability of a command for a chat type +func IsCommandForChatType(cmd command, chatType ChatType) bool { + if cmd.NotFor != nil { + for _, typ := range *cmd.NotFor { + if chatType == typ { + return false + } + } + } + + return true +} + +// TelegramSession exists merely to circumvent a circular dependency +type TelegramSession interface { + GetPersistenceSession() *persistence.Session + GetContactByID(int64, *client.Chat, bool) (*client.Chat, *client.User, error) + IsGroup(*client.Chat) bool + GetChatDescription(*client.Chat) string + GetChatMemberCount(*client.Chat) int32 + GetHashedAvatar(int64) *HashedAvatar + CanBeCalled(int64) bool + GetMUCNickname(int64) string + GetChatType(int64, bool) (ChatType, *client.Chat, error) + GetVerDisco(string) (*stanza.DiscoInfo, bool) +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index 4e1aea4..373877b 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -1,19 +1,23 @@ package xmpp import ( - "bytes" - "encoding/base64" "encoding/xml" + "fmt" "github.com/pkg/errors" - "io" + "sort" "strconv" "strings" + "sync" + "time" "dev.narayana.im/narayana/telegabber/persistence" + "dev.narayana.im/narayana/telegabber/telegram" "dev.narayana.im/narayana/telegabber/xmpp/extensions" "dev.narayana.im/narayana/telegabber/xmpp/gateway" + "github.com/google/uuid" log "github.com/sirupsen/logrus" + "github.com/soheilhy/args" "gosrc.io/xmpp" "gosrc.io/xmpp/stanza" ) @@ -22,7 +26,12 @@ const ( TypeVCardTemp byte = iota TypeVCard4 ) -const NodeVCard4 string = "urn:xmpp:vcard4" + +const MUC_DEFAULT_HISTORY_LIMIT int32 = 20 + +// kludge for client resuming after the transport restart (by full jids) +var probeFired = make(map[string]bool) +var probeFiredLock = sync.Mutex{} func logPacketType(p stanza.Packet) { log.Warnf("Ignoring packet: %T\n", p) @@ -37,22 +46,118 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) { } log.Debugf("%#v", iq) - if iq.Type == "get" { + if iq.Type == stanza.IQTypeGet { _, ok := iq.Payload.(*extensions.IqVcardTemp) if ok { go handleGetVcardIq(s, iq, TypeVCardTemp) return } pubsub, ok := iq.Payload.(*stanza.PubSubGeneric) - if ok { - if pubsub.Items != nil && pubsub.Items.Node == NodeVCard4 { + if ok && pubsub.Items != nil { + if pubsub.Items.Node == gateway.NodeVCard4 { go handleGetVcardIq(s, iq, TypeVCard4) return } + if pubsub.Items.Node == gateway.NodeAvatarData { + go handleGetAvatarDataIq(s, iq, pubsub) + return + } } - _, ok = iq.Payload.(*stanza.DiscoInfo) + discoInfo, ok := iq.Payload.(*stanza.DiscoInfo) if ok { - go handleGetDiscoInfo(s, iq) + go handleGetDiscoInfo(s, iq, discoInfo) + return + } + discoItems, ok := iq.Payload.(*stanza.DiscoItems) + if ok { + go handleGetDiscoItems(s, iq, discoItems) + return + } + _, ok = iq.Payload.(*extensions.QueryRegister) + if ok { + go handleGetQueryRegister(s, iq) + return + } + queryMucAdmin, ok := iq.Payload.(*extensions.QueryMucAdmin) + if ok { + go handleGetQueryMucAdmin(s, iq, queryMucAdmin) + return + } + _, ok = iq.Payload.(*extensions.QueryMucOwner) + if ok { + go handleGetQueryMucOwner(s, iq) + return + } + queryMAM2, ok := iq.Payload.(*extensions.MAM2Query) + if ok { + go handleGetQueryMAM(s, iq, queryMAM2) + return + } + queryMAM1, ok := iq.Payload.(*extensions.MAM1Query) + if ok { + go handleGetQueryMAM(s, iq, queryMAM1) + return + } + queryMAM0, ok := iq.Payload.(*extensions.MAM0Query) + if ok { + go handleGetQueryMAM(s, iq, queryMAM0) + return + } + _, ok = iq.Payload.(*extensions.MAM2Metadata) + if ok { + go handleGetMetadataMAM2(s, iq) + return + } + _, ok = iq.Payload.(*stanza.Version) + if ok { + go handleGetVersion(s, iq) + return + } + _, ok = iq.Payload.(*extensions.EntityTime) + if ok { + go handleGetEntityTime(s, iq) + return + } + } else if iq.Type == stanza.IQTypeSet { + queryRegister, ok := iq.Payload.(*extensions.QueryRegister) + if ok { + go handleSetQueryRegister(s, iq, queryRegister) + return + } + command, ok := iq.Payload.(*stanza.Command) + if ok { + go handleSetQueryCommand(s, iq, command) + return + } + queryMucAdmin, ok := iq.Payload.(*extensions.QueryMucAdmin) + if ok { + go handleSetQueryMucAdmin(s, iq, queryMucAdmin) + return + } + queryMucOwner, ok := iq.Payload.(*extensions.QueryMucOwner) + if ok { + go handleSetQueryMucOwner(s, iq, queryMucOwner) + return + } + queryMAM2, ok := iq.Payload.(*extensions.MAM2Query) + if ok { + go handleSetQueryMAM(s, iq, queryMAM2) + return + } + queryMAM1, ok := iq.Payload.(*extensions.MAM1Query) + if ok { + go handleSetQueryMAM(s, iq, queryMAM1) + return + } + queryMAM0, ok := iq.Payload.(*extensions.MAM0Query) + if ok { + go handleSetQueryMAM(s, iq, queryMAM0) + return + } + } else if iq.Type == stanza.IQTypeResult { + discoInfo, ok := iq.Payload.(*stanza.DiscoInfo) + if ok { + go handleClientFeatures(s, iq, discoInfo) return } } @@ -89,34 +194,65 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { session, ok := sessions[bare] if !ok { if msg.To == gatewayJid { - gateway.SendPresence(component, msg.From, gateway.SPType("subscribe")) - gateway.SendPresence(component, msg.From, gateway.SPType("subscribed")) + gateway.SubscribeToTransport(component, msg.From) } else { log.Error("Message from stranger") } return } - toID, ok := toToID(msg.To) + toID, ok, toIsGroup := gateway.ToToID(msg.To) if ok { + toJid, err := stanza.NewJid(msg.To) + if err != nil { + log.Error("Invalid to JID!") + return + } + + isGroupchat := msg.Type == "groupchat" + + if session.Session.MUC { + chat, _, err := session.GetContactByID(toID, nil, true) + if err == nil && session.IsGroup(chat) { + if !toIsGroup { + gateway.SendErrorMessage(msg.From, toJid.Node, "KHVATIT SYUDA ZVONITb", 403, false, component) + return + } + + if toJid.Resource != "" { + if isGroupchat { + gateway.SendErrorMessageWithBody(msg.From, msg.To, msg.Body, "", msg.Id, 400, true, component) + } else { + gateway.SendErrorMessage(msg.From, msg.To, "PMing room occupants is not supported, use the real JID", 406, true, component) + } + return + } + } + } + var reply extensions.Reply var fallback extensions.Fallback + var replace extensions.Replace msg.Get(&reply) msg.Get(&fallback) + msg.Get(&replace) log.Debugf("reply: %#v", reply) log.Debugf("fallback: %#v", fallback) + log.Debugf("replace: %#v", replace) var replyId int64 - var err error text := msg.Body if len(reply.Id) > 0 { - id := reply.Id - if id[0] == 'e' { - id = id[1:] - } - replyId, err = strconv.ParseInt(id, 10, 64) - if err != nil { - log.Warn(errors.Wrap(err, "Failed to parse message ID!")) + chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, reply.Id) + if err == nil { + if chatId != toID { + log.Warnf("Chat mismatch: %v ≠ %v", chatId, toID) + } else { + replyId = msgId + log.Debugf("replace tg: %#v %#v", chatId, msgId) + } + } else { + replyId, _ = parseMessageId(reply.Id) } if replyId != 0 && fallback.For == "urn:xmpp:reply:0" && len(fallback.Body) > 0 { @@ -134,16 +270,89 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { "end": body.End, }).Warn(errors.Wrap(err, "Failed to parse fallback end!")) } - text = text[:start] + text[end:] + + fullRunes := []rune(text) + cutRunes := make([]rune, 0, len(text)-int(end-start)) + cutRunes = append(cutRunes, fullRunes[:start]...) + cutRunes = append(cutRunes, fullRunes[end:]...) + text = string(cutRunes) + } + } + var replaceId int64 + if replace.Id != "" { + chatId, msgId, err := gateway.IdsDB.GetByXmppId(session.Session.Login, bare, replace.Id) + if err == nil { + if chatId != toID { + if isGroupchat { + gateway.SendErrorMessage(msg.From, gateway.MUCJID(toID), text, 400, isGroupchat, component) + } else { + gateway.SendTextMessage(msg.From, gateway.CHATNODE(toID), "", component, isGroupchat) + } + return + } + replaceId = msgId + log.Debugf("replace tg: %#v %#v", chatId, msgId) + } else { + if isGroupchat { + gateway.SendErrorMessage(msg.From, gateway.MUCJID(toID), text, 400, isGroupchat, component) + } else { + gateway.SendTextMessage(msg.From, gateway.CHATNODE(toID), "", component, isGroupchat) + } + return } } - session.ProcessOutgoingMessage(toID, text, msg.From, replyId) + session.SendMessageLock.Lock() + defer session.SendMessageLock.Unlock() + tgMessage, isCommand := session.ProcessOutgoingMessage(toID, text, msg.From, replyId, replaceId, isGroupchat, false) + if tgMessage != nil { + if replaceId != 0 { + // not needed (is it persistent among clients though?) + /* err = gateway.IdsDB.ReplaceIdPair(session.Session.Login, bare, replace.Id, msg.Id, tgMessageId) + if err != nil { + log.Errorf("Failed to replace id %v with %v %v", replace.Id, msg.Id, tgMessageId) + } */ + session.AddToEditOutbox(replace.Id, resource) + } else { + err = gateway.IdsDB.Set(session.Session.Login, bare, toID, tgMessage.Id, msg.Id) + if err == nil { + // session.AddToOutbox(msg.Id, resource) + session.UpdateLastChatMessageId(toID, msg.Id) + } else { + log.Errorf("Failed to save ids %v/%v %v", toID, tgMessage.Id, msg.Id) + } + } + + // pong groupchat messages back + if isGroupchat && toJid.Resource == "" && tgMessage.SenderId != nil { + session.SendMessageToGateway( + toID, + tgMessage, + msg.Id, + false, + msg.To + "/" + session.GetMUCNickname(session.GetSenderId(tgMessage.SenderId)), + []string{msg.From}, + "", + ) + } + } else if isCommand && isGroupchat && session.Session.MUC { + // pong outgoing commands back to groupchats + gateway.SendMessage(msg.From, msg.To + "/" + session.GetMUCNickname(0), text, "", component, nil, 0, "", false, isGroupchat, false, "", "", "", "", nil) + } else { + /* + // if a message failed to edit on Telegram side, match new XMPP ID with old Telegram ID anyway + if replaceId != 0 { + err = gateway.IdsDB.ReplaceXmppId(session.Session.Login, bare, replace.Id, msg.Id) + if err != nil { + log.Errorf("Failed to replace id %v with %v", replace.Id, msg.Id) + } + } */ + } return } else { toJid, err := stanza.NewJid(msg.To) if err == nil && toJid.Bare() == gatewayJid && (strings.HasPrefix(msg.Body, "/") || strings.HasPrefix(msg.Body, "!")) { - response := session.ProcessTransportCommand(msg.Body, resource) + response, _ := session.ProcessTransportCommand(msg.Body, resource) if response != "" { gateway.SendServiceMessage(msg.From, response, component) } @@ -154,16 +363,79 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { } if msg.Body == "" { - var privilege extensions.ComponentPrivilege - if ok := msg.Get(&privilege); ok { - log.Debugf("privilege: %#v", privilege) + var privilege1 extensions.ComponentPrivilege1 + if ok := msg.Get(&privilege1); ok { + log.Debugf("privilege1: %#v", privilege1) } - for _, perm := range privilege.Perms { + for _, perm := range privilege1.Perms { if perm.Access == "message" && perm.Type == "outgoing" { - gateway.MessageOutgoingPermission = true + gateway.MessageOutgoingPermissionVersion = 1 } } + + var privilege2 extensions.ComponentPrivilege2 + if ok := msg.Get(&privilege2); ok { + log.Debugf("privilege2: %#v", privilege2) + } + + for _, perm := range privilege2.Perms { + if perm.Access == "message" && perm.Type == "outgoing" { + gateway.MessageOutgoingPermissionVersion = 2 + } + } + + var displayed stanza.MarkDisplayed + msg.Get(&displayed) + if displayed.ID != "" { + log.Debugf("displayed: %#v", displayed) + + bare, _, ok := gateway.SplitJID(msg.From) + if !ok { + return + } + session, ok := sessions[bare] + if !ok { + return + } + toID, ok, _ := gateway.ToToID(msg.To) + if !ok { + return + } + msgId, err := strconv.ParseInt(displayed.ID, 10, 64) + if err == nil { + session.MarkAsRead(toID, msgId) + } + return + } + + if msg.Thread == "" && msg.Subject != "" && msg.Type == "groupchat" { + log.Debugf("MUC subject change: %#v", msg) + + bare, _, ok := gateway.SplitJID(msg.From) + if !ok { + return + } + session, ok := sessions[bare] + if !ok { + return + } + toID, ok, isGroup := gateway.ToToID(msg.To) + if !ok || !isGroup { + return + } + _, resource, ok := gateway.SplitJID(msg.To) + if ok && resource != "" { + return + } + + go func() { + pinOk := session.NewPinnedMessage(toID, msg.Subject, msg.From) + if !pinOk { + gateway.SendErrorMessage(msg.From, gateway.MUCJID(toID), "", 406, true, component) + } + }() + } } if msg.Type == "error" { @@ -171,9 +443,11 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { if msg.XMLName.Space == "jabber:component:accept" && msg.Error.Code == 401 { suffix := "@" + msg.From - for bare := range sessions { + for bare, session := range sessions { if strings.HasSuffix(bare, suffix) { - gateway.SendServiceMessage(bare, "Your server \"" + msg.From + "\" does not allow to send carbons", component) + for _, jid := range session.GetCarbonFullJids(true, "", false) { + gateway.SendServiceMessage(jid, "Your server \""+msg.From+"\" does not allow to send carbons", component) + } } } } @@ -193,7 +467,15 @@ func HandlePresence(s xmpp.Sender, p stanza.Packet) { } if prs.To == gateway.Jid.Bare() { handlePresence(s, prs) + return } + var mucExt stanza.MucPresence + prs.Get(&mucExt) + if mucExt.XMLName.Space != "" { + handleMUCPresence(s, prs, mucExt) + return + } + tryHandleMUCPresence(s, prs) } func handleSubscription(s xmpp.Sender, p stanza.Presence) { @@ -218,7 +500,7 @@ func handleSubscription(s xmpp.Sender, p stanza.Presence) { _ = gateway.ResumableSend(component, reply) - toID, ok := toToID(p.To) + toID, ok, _ := gateway.ToToID(p.To) if !ok { return } @@ -230,7 +512,7 @@ func handleSubscription(s xmpp.Sender, p stanza.Presence) { if !ok { return } - go session.ProcessStatusUpdate(toID, "", "", gateway.SPImmed(false)) + go session.ProcessStatusUpdate(toID, "", "", true, gateway.SPImmed(false)) } func handlePresence(s xmpp.Sender, p stanza.Presence) { @@ -278,23 +560,239 @@ func handlePresence(s xmpp.Sender, p stanza.Presence) { // due to the weird implementation of go-tdlib wrapper, it won't // return the client instance until successful authorization go func() { - err := session.Connect(resource) + err := session.Connect(resource, false) if err != nil { log.Error(errors.Wrap(err, "TDlib connection failure")) } else { - for status := range session.StatusesRange() { - go session.ProcessStatusUpdate( - status.ID, - status.Description, - status.XMPP, - gateway.SPImmed(false), - ) + var probeFiredForFrom bool + probeFiredLock.Lock() + _, probeFiredForFrom = probeFired[p.From] + probeFired[p.From] = true + probeFiredLock.Unlock() + + if p.Type != "probe" || !probeFiredForFrom { + for status := range session.StatusesRange() { + show, description, typ := status.Destruct() + newArgs := []args.V{ + gateway.SPImmed(false), + } + if typ != "" { + newArgs = append(newArgs, gateway.SPType(typ)) + } + go session.ProcessStatusUpdate( + status.ID, + description, + show, + true, + newArgs..., + ) + } + probeClientFeatures(p.From, component) + session.UpdateChatNicknames() } } }() } } +func handleMUCPresence(s xmpp.Sender, p stanza.Presence, mucExt stanza.MucPresence) { + log.WithFields(log.Fields{ + "type": p.Type, + "from": p.From, + "to": p.To, + }).Warn("MUC presence") + log.Debugf("%#v", p) + + if p.Type == "" { + toBare, nickname, ok := gateway.SplitJID(p.To) + if ok { + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + // separate declaration is crucial for passing as pointer to defer + var reply *stanza.Presence + reply = &stanza.Presence{Attrs: stanza.Attrs{ + From: toBare, + To: p.From, + Id: p.Id, + }} + defer gateway.ResumableSend(component, reply) + + if nickname == "" { + presenceReplySetError(reply, 400) + return + } + + chatId, ok, toIsGroup := gateway.ToToID(toBare) + if !ok || !toIsGroup { + presenceReplySetError(reply, 405) + return + } + + fromBare, fromResource, ok := gateway.SplitJID(p.From) + if !ok { + presenceReplySetError(reply, 400) + return + } + + session, ok := sessions[fromBare] + if !ok || !session.Session.MUC { + presenceReplySetError(reply, 407) + return + } + + chat, _, err := session.GetContactByID(chatId, nil, true) + if err != nil || !session.IsGroup(chat) { + presenceReplySetError(reply, 404) + return + } + + status := session.GetMyStatusInChat(chatId) + log.Debugf("status in group %v: %v", chatId, status) + switch status { + case telegram.ChatMemberStatusBanned, telegram.ChatMemberStatusIllegal: + presenceReplySetError(reply, 403) + return + } + + log.Debugf("%#v", mucExt) + maxStanzas, maxStanzasOk := mucExt.History.MaxStanzas.Get() + maxChars, maxCharsOk := mucExt.History.MaxChars.Get() + seconds, secondsOk := mucExt.History.Seconds.Get() + + var limit *telegram.MessageLimit + if maxStanzasOk { + limit = telegram.NewMessageLimitMessages(int32(maxStanzas)) + } else if maxCharsOk { + limit = telegram.NewMessageLimitChars(maxChars) + } else if secondsOk { + limit = telegram.NewMessageLimitSince(time.Now().Add(time.Duration(seconds) * -time.Second).Unix()) + } else if !mucExt.History.Since.IsZero() { + limit = telegram.NewMessageLimitSince(mucExt.History.Since.Unix()) + } else { + limit = telegram.NewMessageLimitMessages(MUC_DEFAULT_HISTORY_LIMIT) + } + session.JoinMUC(chatId, fromResource, limit) + } + } +} + +func tryHandleMUCPresence(s xmpp.Sender, p stanza.Presence) { + toBare, nickname, ok := gateway.SplitJID(p.To) + if !ok || nickname == "" { + return + } + + log.WithFields(log.Fields{ + "type": p.Type, + "from": p.From, + "to": p.To, + }).Warn("Nickname change presence?") + log.Debugf("%#v", p) + + fromBare, fromResource, ok := gateway.SplitJID(p.From) + if !ok { + return + } + + session, ok := sessions[fromBare] + if !ok || !session.Session.MUC { + return + } + + chatId, ok, toIsGroup := gateway.ToToID(toBare) + if !ok || !toIsGroup { + return + } + + chat, _, err := session.GetContactByID(chatId, nil, true) + if err != nil || !session.IsGroup(chat) { + return + } + + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + if p.Type == stanza.PresenceTypeError { + return + } + + if !session.MUCHasResource(chatId, fromResource) { + // groupchat 1.0 join + gateway.SendPresence( + component, + p.From, + gateway.SPFullFrom(p.To), + gateway.SPType("unavailable"), + gateway.SPMUCAffiliation("none"), + gateway.SPMUCRole("none"), + gateway.SPMUCStatusCodes([]uint16{110, 307, 333}), + ) + return + } + + switch p.Type { + case "": + if p.Show == "" { + handleMUCNicknameChange(component, p, session, chatId, toBare, nickname) + } + case stanza.PresenceTypeUnavailable: + handleMUCUnavailable(component, p, session, chatId, fromResource) + } +} + +func handleMUCNicknameChange(component *xmpp.Component, p stanza.Presence, session *telegram.Client, chatId int64, toBare string, newNickname string) { + log.Warn("🗿 Yes") + + from := toBare + nickname, ok := session.GetMyMUCNickname(chatId) + if ok { + if nickname == newNickname { + log.Warn("But whatever, it's the same") + return + } + from = from+"/"+nickname + } + reply := &stanza.Presence{ + Attrs: stanza.Attrs{ + From: from, + To: p.From, + Id: p.Id, + Type: stanza.PresenceTypeError, + }, + Error: stanza.Err{ + Code: 406, + Type: stanza.ErrorTypeModify, + Reason: "not-acceptable", + Text: "Telegram does not support changing nicknames per-chat. Issue a /setname command to the transport if you wish to change the global name", + }, + } + gateway.ResumableSend(component, reply) +} + +func handleMUCUnavailable(component *xmpp.Component, p stanza.Presence, session *telegram.Client, chatId int64, resource string) { + log.Warn("No, it's a MUC exit") + + session.LeaveMUC(chatId, resource) + + gateway.SendPresence( + component, + p.From, + gateway.SPFullFrom(p.To), + gateway.SPType("unavailable"), + gateway.SPMUCAffiliation("member"), + gateway.SPMUCRole("none"), + gateway.SPMUCJid(p.From), + gateway.SPMUCStatusCodes([]uint16{110}), + ) +} + func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { log.WithFields(log.Fields{ "from": iq.From, @@ -313,51 +811,30 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { return } - toParts := strings.Split(iq.To, "@") - toID, err := strconv.ParseInt(toParts[0], 10, 64) - if err != nil { + toID, toOk, isGroup := gateway.ToToID(iq.To) + if !toOk { log.Error("Invalid IQ to") return } - chat, user, err := session.GetContactByID(toID, nil) + + if isGroup { + toJid, err := stanza.NewJid(iq.To) + if err != nil { + log.Error("Invalid to JID!") + return + } + + if toJid.Resource != "" { + toID = session.GetMUCMemberIdByNickname(toID, toJid.Resource) + } + } + + info, err := session.GetVcardInfo(toID) if err != nil { log.Error(err) return } - var fn, photo, nickname, given, family, tel, info string - if chat != nil { - fn = chat.Title - - if chat.Photo != nil { - file, path, err := session.OpenPhotoFile(chat.Photo.Small, 32) - if err == nil { - defer file.Close() - - buf := new(bytes.Buffer) - binval := base64.NewEncoder(base64.StdEncoding, buf) - _, err = io.Copy(binval, file) - binval.Close() - if err == nil { - photo = buf.String() - } else { - log.Errorf("Error calculating base64: %v", path) - } - } else if path != "" { - log.Errorf("Photo does not exist: %v", path) - } else { - log.Errorf("PHOTO: %#v", err.Error()) - } - } - info = session.GetChatDescription(chat) - } - if user != nil { - nickname = user.Username - given = user.FirstName - family = user.LastName - tel = user.PhoneNumber - } - answer := stanza.IQ{ Attrs: stanza.Attrs{ From: iq.To, @@ -365,7 +842,7 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { Id: iq.Id, Type: "result", }, - Payload: makeVCardPayload(typ, iq.To, fn, photo, nickname, given, family, tel, info), + Payload: makeVCardPayload(typ, iq.To, info, session), } log.Debugf("%#v", answer) @@ -378,12 +855,110 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { _ = gateway.ResumableSend(component, &answer) } -func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ) { - iqDisco, ok := iq.Payload.(*stanza.DiscoInfo) - if !ok { - log.Error("Not a disco info request") +func handleGetAvatarDataIq(s xmpp.Sender, iq *stanza.IQ, pubsub *stanza.PubSubGeneric) { + fromJid, err := stanza.NewJid(iq.From) + if err != nil { + log.Errorf("Invalid from JID %v", iq.From) return } + + chatId, ok, _ := gateway.ToToID(iq.To) + if !ok { + log.Errorf("Invalid chat id in To JID %v", iq.To) + return + } + + session, ok := sessions[fromJid.Bare()] + if !ok { + log.Errorf("IQ from stranger %v", iq.From) + return + } + + var id string + if len(pubsub.Items.List) > 0 { + id = pubsub.Items.List[0].Id + } + log.Infof("Avatar id %v for chat %v", id, iq.To) + + pubsubAnswer := stanza.PubSubGeneric{ + Items: &stanza.Items{ + Node: gateway.NodeAvatarData, + }, + } + + answer := stanza.IQ{ + Attrs: stanza.Attrs{ + From: iq.To, + To: iq.From, + Id: iq.Id, + Type: "result", + }, + Payload: &pubsubAnswer, + } + + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + defer gateway.ResumableSend(component, &answer) + + hashedAvatar := session.GetHashedAvatar(chatId) + + if id != "" && hashedAvatar.Hash != id { + log.Infof("Cache contains %v hash for chat %v, but %v was requested; aborting", hashedAvatar.Hash, iq.To, id) + return + } + if hashedAvatar.File == 0 { + log.Infof("Avatar for chat %v is explicitly missing", iq.To) + return + } + + file, err := session.GetFile(hashedAvatar.File) + if err != nil { + log.WithFields(log.Fields{ + "chatId": chatId, + }).Error(errors.Wrap(err, "Cannot get avatar file")) + return + } + + dataString := session.GetPhotoBase64(file) + if dataString == "" { + log.Errorf("Error reading avatar file for chat %v", iq.To) + return + } + + pubsubAnswer.Items.List = append(pubsubAnswer.Items.List, stanza.Item{ + Id: hashedAvatar.Hash, + Any: &stanza.Node{ + XMLName: xml.Name{Local: "data", Space: gateway.NodeAvatarData}, + Content: dataString, + }, + }) + + log.WithFields(log.Fields{ + "length": len(dataString), + }).Debugf("%#v", answer) +} + +func getTelegramChatType(from string, to string) (gateway.ChatType, error) { + toId, ok, _ := gateway.ToToID(to) + if ok { + bare, _, ok := gateway.SplitJID(from) + if ok { + session, ok := sessions[bare] + if ok { + chatType, _, chatTypeErr := session.GetChatType(toId, true) + return chatType, chatTypeErr + } + } + } + + return gateway.ChatTypeUnknown, errors.New("Unknown chat type") +} + +func iqResultStub(s xmpp.Sender, iq *stanza.IQ) (*xmpp.Component, *stanza.IQ, bool) { answer, err := stanza.NewIQ(stanza.Attrs{ Type: stanza.IQTypeResult, From: iq.To, @@ -391,89 +966,1953 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ) { Id: iq.Id, Lang: "en", }) + if err != nil { + log.Errorf("Failed to create answer IQ: %v", err) + return nil, nil, false + } + + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return nil, nil, false + } + + return component, answer, true +} + +func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + var session gateway.TelegramSession + bare, _, fromOk := gateway.SplitJID(iq.From) + if fromOk { + session, _ = sessions[bare] + } + + disco, errorCode := gateway.GetDiscoInfo(session, di.Node, iq.To) + if errorCode != 0 { + iqAnswerSetError(answer, errorCode) + return + } + answer.Payload = disco + + log.Debugf("%#v", answer.Payload) +} + +func handleGetDiscoItems(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoItems) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + log.Debugf("discoItems: %#v", di) + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + + if toIsGroup { + toJid, err := stanza.NewJid(iq.To) + if err == nil && toJid.Resource != "" { + iqAnswerSetError(answer, 400) + return + } + } + + disco := answer.DiscoItems() + + if di.Node == gateway.NSCommand { + chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) + + var cmdType gateway.CommandType + if toOk { + cmdType = gateway.CommandTypeChat + } else { + cmdType = gateway.CommandTypeTransport + } + + var isOnline bool + bare, _, ok := gateway.SplitJID(iq.From) + if ok { + session, ok := sessions[bare] + if ok { + isOnline = session.Online() + + if toOk { + isBot, err := session.IsBot(toID, true) + if err == nil && isBot { + disco.AddItem(iq.To, "botmenu", "Bot Menu") + } + } + } + } + + if !(toOk || isOnline) { + disco.AddItem(iq.To, "loginwizard", "Login Wizard") + } + + commands := gateway.GetCommands(cmdType) + for _, name := range gateway.SortedCommandKeys(commands, gateway.OnlineFilterAny) { + command := commands[name] + if chatTypeErr == nil && !gateway.IsCommandForChatType(command, chatType) { + continue + } + if !isOnline && command.LoginOnly { + continue + } + disco.AddItem(iq.To, name, gateway.CommandToHelpString(name, command)) + } + } else if di.Node == "" { + if !toOk { + bare, _, fromOk := gateway.SplitJID(iq.From) + if fromOk { + // raw access, no need to create a new instance if not connected + session, sessionOk := sessions[bare] + if sessionOk && session.Session.MUC { + disco.AddItem(gateway.Jid.Bare(), "", "Telegram group chats") + for _, chat := range session.GetGroupChats() { + jid := gateway.MUCJID(chat.Id) + disco.AddItem(jid, "", chat.Title) + } + } + } + } + } + answer.Payload = disco + + log.Debugf("%#v", answer) +} + +func handleGetQueryRegister(s xmpp.Sender, iq *stanza.IQ) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + _, toOk, toIsGroup := gateway.ToToID(iq.To) + + bare, _, ok := gateway.SplitJID(iq.From) + var session *telegram.Client + var sessionOk bool + if ok { + session, sessionOk = sessions[bare] + } + + if toOk { + if toIsGroup { + nickname := "me" + if sessionOk { + nickname = session.GetMUCNickname(0) + } + answer.Payload = extensions.QueryRegister{ + Instructions: "MUC username is static", + Username: nickname, + Registered: &extensions.QueryRegisterRegistered{}, + } + } else { + query := extensions.QueryRegister{} + iqAnswerRegisterSetError(answer, &query, 404) + return + } + } else { + var login string + if sessionOk { + login = session.Session.Login + } + + var query stanza.IQPayload + if login == "" { + query = extensions.QueryRegister{ + Instructions: fmt.Sprintf("Authorization in Telegram is a multi-step process, so please accept %v to your contacts and follow further instructions (provide the authentication code there, etc.).\nFor now, please provide your login.", iq.To), + } + } else { + query = extensions.QueryRegister{ + Instructions: "Already logged in", + Username: login, + Registered: &extensions.QueryRegisterRegistered{}, + } + } + answer.Payload = query + + log.Debugf("%#v", query) + + if login == "" { + gateway.SubscribeToTransport(component, iq.From) + } + } +} + +func handleGetVersion(s xmpp.Sender, iq *stanza.IQ) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + answer.Version().SetInfo(gateway.Jid.Resource, gateway.Version, "") + + log.Debugf("%#v", answer.Payload) +} + +func handleGetEntityTime(s xmpp.Sender, iq *stanza.IQ) { + component, ok := s.(*xmpp.Component) + if !ok { + log.Error("Not a component") + return + } + + // separate declaration is crucial for passing as pointer to defer + var answer *stanza.IQ + var err error + answer, err = stanza.NewIQ(stanza.Attrs{ + Type: stanza.IQTypeResult, + From: iq.To, + To: iq.From, + Id: iq.Id, + Lang: "en", + }) if err != nil { log.Errorf("Failed to create answer IQ: %v", err) return } - _, ok := toToID(iq.To) - typ gateway.ContactType - if ok { - typ = gateway.ContactPM - } else { - typ = gateway.ContactTransport + defer gateway.ResumableSend(component, answer) + + fromJid, err := stanza.NewJid(iq.From) + if err != nil { + log.Error("Invalid from JID!") + return } - disco := gateway.GetDiscoInfo(typ, []string{}) - disco.Node = iqDisco.Node - answer.Payload = disco - log.Debugf("%#v", answer) + session, ok := sessions[fromJid.Bare()] + if !ok { + log.Error("IQ from stranger") + return + } + entityTime := extensions.EntityTime{ + Tzo: session.GetTZD(), + Utc: time.Now().UTC().Format(time.RFC3339), + } + answer.Payload = &entityTime + + log.Debugf("%#v", entityTime) +} + +func handleGetQueryMucAdmin(s xmpp.Sender, iq *stanza.IQ, query *extensions.QueryMucAdmin) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 406) + return + } + + chat, _, err := session.GetContactByID(toID, nil, true) + if err != nil || !session.IsGroup(chat) { + iqAnswerSetError(answer, 405) + return + } + + if len(query.Items) != 1 { + iqAnswerSetError(answer, 400) + return + } + item := query.Items[0] + + if item.Role != "" && item.Affiliation != "" { + iqAnswerSetError(answer, 400) + return + } + + var membersList telegram.MembersList + switch item.Role { + case "moderator": + membersList = telegram.MembersListAdministrators + case "participant": + membersList = telegram.MembersListMembers + } + switch item.Affiliation { + case "owner": + membersList = telegram.MembersListCreators + case "admin": + membersList = telegram.MembersListAdministrators + case "member": + membersList = telegram.MembersListMembers + case "outcast": + membersList = telegram.MembersListBanned + } + + if membersList == telegram.MembersListNone { + iqAnswerSetError(answer, 400) + return + } + + payload := &extensions.QueryMucAdmin{} + answer.Payload = payload + + members, err := session.GetChatMembers(toID, false, "", membersList) + if err == nil { + for _, member := range members { + senderId, nickname, affiliation, role := session.TgMemberToMUCOccupant(member, chat) + if item.Role != "" && role != item.Role { + continue + } + if item.Affiliation != "" && affiliation != item.Affiliation { + continue + } + newItem := extensions.QueryMucAdminItem{} + if item.Affiliation == "" || item.Affiliation != "outcast" { + newItem.Nick = nickname + } + if item.Role != "" { + newItem.Role = role + newItem.Jid = gateway.CHATJID(senderId, true) + } else { + newItem.Affiliation = affiliation + newItem.Jid = gateway.CHATJID(senderId, false) + } + payload.Items = append(payload.Items, &newItem) + } + } +} + +func handleGetQueryMucOwner(s xmpp.Sender, iq *stanza.IQ) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 405) + return + } + + chat, _, err := session.GetContactByID(toID, nil, true) + if err != nil || chat == nil || !session.IsGroup(chat) { + iqAnswerSetError(answer, 405) + return + } + + payload := &extensions.QueryMucOwner{} + answer.Payload = payload + + dummyString := "" + changeSubject := "1" + if chat.Permissions != nil && !chat.Permissions.CanPinMessages { + changeSubject = "0" + } + + payload.Form = &stanza.Form{ + Type: stanza.FormTypeForm, + Title: fmt.Sprintf("Configuration for \"%v\" room", chat.Title), + Fields: []*stanza.Field{ + &stanza.Field{ + Var: "FORM_TYPE", + Type: stanza.FieldTypeHidden, + ValuesList: []string{"http://jabber.org/protocol/muc#roominfo"}, + }, + &stanza.Field{ + Var: "muc#roomconfig_roomname", + Label: "Group name", + Required: &dummyString, + ValuesList: []string{chat.Title}, + }, + &stanza.Field{ + Var: "muc#roomconfig_roomdesc", + Label: "Description (optional)", + ValuesList: []string{session.GetChatDescription(chat)}, + }, + &stanza.Field{ + Var: "muc#roomconfig_changesubject", + Label: "Allow Occupants to Change Subject?", + Type: stanza.FieldTypeBool, + ValuesList: []string{changeSubject}, + }, + }, + } +} + +func handleGetQueryMAM(s xmpp.Sender, iq *stanza.IQ, query extensions.MAMQuery) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + payload2 := &extensions.MAM2Query{} + payload1 := &extensions.MAM1Query{} + payload0 := &extensions.MAM0Query{} + ns := query.Namespace() + switch ns { + case gateway.NS_MAM2: + answer.Payload = payload2 + case gateway.NS_MAM1: + answer.Payload = payload1 + case gateway.NS_MAM0: + answer.Payload = payload0 + } + + form := stanza.Form{ + Type: stanza.FormTypeForm, + Fields: []*stanza.Field{ + &stanza.Field{ + Var: "FORM_TYPE", + Type: stanza.FieldTypeHidden, + ValuesList: []string{ns}, + }, + &stanza.Field{ + Var: "with", + Type: stanza.FieldTypeJidSingle, + }, + &stanza.Field{ + Var: "start", + Type: stanza.FieldTypeTextSingle, + }, + &stanza.Field{ + Var: "end", + Type: stanza.FieldTypeTextSingle, + }, + &stanza.Field{ + Var: "before-id", + Type: stanza.FieldTypeTextSingle, + }, + &stanza.Field{ + Var: "after-id", + Type: stanza.FieldTypeTextSingle, + }, + &stanza.Field{ + Var: "ids", + Type: stanza.FieldTypeListMulti, + }, + }, + } + payload2.Form = &form + payload1.Form = &form + payload0.Form = &form + log.Debugf("MAM info request: %#v", query) +} + +func handleGetMetadataMAM2(s xmpp.Sender, iq *stanza.IQ) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 405) + return + } + + chat, _, err := session.GetContactByID(toID, nil, true) + if err != nil || chat == nil || !session.IsGroup(chat) { + iqAnswerSetError(answer, 405) + return + } + + payload := &extensions.MAM2Metadata{} + answer.Payload = payload + + quotaTs := time.Now().AddDate(0, 0, -int(gateway.MAMThreshold)) + preFirstMessage, preFirstMessageErr := session.FindMessageByTime(toID, quotaTs) + var preFirstMessageId int64 + if preFirstMessageErr == nil && preFirstMessage != nil { + preFirstMessageId = preFirstMessage.Id + } else { + preFirstMessageId = 1 + } + startMessage, startMessageErr := session.GetNextMessage(toID, preFirstMessageId) + if startMessageErr == nil && startMessage != nil { + payload.Start = &extensions.MAM2MetadataStart{ + Id: strconv.FormatInt(startMessage.Id, 10), + Timestamp: extensions.TimestampToRFC3339(int64(startMessage.Date)), + } + } + + endMessage, endMessageErr := session.GetPreviousMessage(toID, 0) + if endMessageErr == nil && endMessage != nil { + payload.End = &extensions.MAM2MetadataEnd{ + Id: strconv.FormatInt(endMessage.Id, 10), + Timestamp: extensions.TimestampToRFC3339(int64(endMessage.Date)), + } + } + log.Debugf("MAM metadata: %#v", payload) +} + +func handleSetQueryRegister(s xmpp.Sender, iq *stanza.IQ, query *extensions.QueryRegister) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + _, toOk, _ := gateway.ToToID(iq.To) + if toOk { + iqAnswerRegisterSetError(answer, query, 400) + return + } + + if query.Remove != nil { + iqAnswerRegisterSetError(answer, query, 405) + return + } + + var login string + var session *telegram.Client + bare, resource, ok := gateway.SplitJID(iq.From) + if ok { + session, ok = sessions[bare] + if ok { + login = session.Session.Login + } + } + + if login == "" { + if !ok { + session, ok = getTelegramInstance(bare, &persistence.Session{}, component) + if !ok { + iqAnswerRegisterSetError(answer, query, 500) + return + } + } + + err := session.TryLogin(resource, query.Username) + if err != nil { + if err.Error() == telegram.TelegramAuthDone { + iqAnswerRegisterSetError(answer, query, 406) + } else { + iqAnswerRegisterSetError(answer, query, 500) + } + return + } + + err = session.SetPhoneNumber(query.Username) + if err != nil { + iqAnswerRegisterSetError(answer, query, 500) + return + } + + // everything okay, the response should be empty with no payload/error at this point + gateway.SubscribeToTransport(component, iq.From) + } else { + iqAnswerRegisterSetError(answer, query, 406) + } +} + +func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + + cancelSend := false + + defer func() { + if !cancelSend { + gateway.ResumableSend(component, answer) + } + }() + + log.Debugf("command: %#v", command) + + bare, resource, ok := gateway.SplitJID(iq.From) + if !ok { + return + } + toId, toOk, _ := gateway.ToToID(iq.To) + + var cmdString string + var cmdType gateway.CommandType + var form *stanza.Form + for _, ce := range command.CommandElements { + fo, formOk := ce.(*stanza.Form) + if formOk { + form = fo + break + } + } + if toOk { + cmdType = gateway.CommandTypeChat + } else { + cmdType = gateway.CommandTypeTransport + } + if form != nil { + if command.Node == "config" { + session, ok := sessions[bare] + if ok { + var infoStrings []string + var warnString, errString string + for _, field := range form.Fields { + if len(field.ValuesList) > 0 { + // 10. In accordance with Section 3.2.2.1 of XML Schema Part 2: Datatypes, the allowable + // lexical representations for the xs:boolean datatype are the strings "0" and "false" + // for the concept 'false' and the strings "1" and "true" for the concept 'true'; + // implementations MUST support both styles of lexical representation. + fieldValue := persistence.NormalizeProperty(field.Var, field.ValuesList[0]) + + if gateway.MessageOutgoingPermissionVersion == 0 && field.Var == "carbons" && fieldValue == "true" { + warnString = "The server did not allow to enable carbons" + continue + } + + oldValue, err := session.Session.Get(field.Var) + if err != nil || oldValue != fieldValue { + value, err := session.Session.Set(field.Var, fieldValue) + if err != nil { + errString = fmt.Sprintf("Error for field %v: %v, aborting", field.Var, err.Error()) + break + } + if field.Var == "muc" { + switch fieldValue { + case "true": + go session.MigrateToMUCs() + case "false": + go session.MigrateFromMUCs() + } + } + infoStrings = append(infoStrings, fmt.Sprintf("%s set to %s", field.Var, value)) + gateway.DirtySessions = true + } + } + } + + var elements []stanza.CommandElement + if errString != "" { + elements = append(elements, &stanza.Note{ + Text: errString, + Type: stanza.CommandNoteTypeErr, + }) + } + if warnString != "" { + elements = append(elements, &stanza.Note{ + Text: warnString, + Type: stanza.CommandNoteTypeWarn, + }) + } + for _, infoString := range infoStrings { + elements = append(elements, &stanza.Note{ + Text: infoString, + Type: stanza.CommandNoteTypeInfo, + }) + } + + answer.Payload = &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + Status: stanza.CommandStatusCompleted, + CommandElements: elements, + } + } + } else if !toOk && command.Node == "loginwizard" { + var session *telegram.Client + answer.Payload, cancelSend, session = loginWizardPayload(bare, form, resource, command.Action) + + log.Debugf("immediate loginwizard payload: %#v", answer.Payload) + if cancelSend { + go sendLoginWizardResponse(component, answer, session) + } + } else if toOk && command.Node == "botmenu" { + payload := &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + } + answer.Payload = payload + + if len(form.Fields) == 1 && form.Fields[0] != nil && + form.Fields[0].Var == "command" && len(form.Fields[0].ValuesList) == 1 { + session, ok := sessions[bare] + if ok { + msgText := "/" + form.Fields[0].ValuesList[0] + session.LastBotCmdString = msgText + tgMessage, _ := session.ProcessOutgoingMessage(toId, msgText, iq.From, 0, 0, false, true) + if tgMessage != nil { + payload.Status = stanza.CommandStatusCompleted + } else { + setCommandPayloadError(payload, "Failed to send a bot command") + } + } else { + setCommandPayloadError(payload, "Session is lost") + } + } else { + setCommandPayloadError(payload, "Broken form") + } + } else { + // just for the case the client messed the order somehow + sort.Slice(form.Fields, func(i int, j int) bool { + iField := form.Fields[i] + jField := form.Fields[j] + if iField != nil && jField != nil { + ii, iErr := strconv.ParseInt(iField.Var, 10, 64) + ji, jErr := strconv.ParseInt(jField.Var, 10, 64) + return iErr == nil && jErr == nil && ii < ji + } + return false + }) + + var cmd strings.Builder + cmd.WriteString("/") + cmd.WriteString(command.Node) + for _, field := range form.Fields { + cmd.WriteString(" ") + if len(field.ValuesList) > 0 { + cmd.WriteString(field.ValuesList[0]) + } + } + + cmdString = cmd.String() + } + } else { + if command.Action == "" || command.Action == stanza.CommandActionExecute { + cmd, ok := gateway.GetCommand(cmdType, command.Node) + if ok && len(cmd.Arguments) > 0 { + var fields []*stanza.Field + if command.Node == "config" { + session, ok := sessions[bare] + if ok { + for _, key := range persistence.ConfigKeys { + // no reason to display the item if carbons won't work + if key == "carbons" && gateway.MessageOutgoingPermissionVersion == 0 { + continue + } + + value, err := session.Session.Get(key) + if err != nil { + log.Errorf("Achtung! Programming error in sessions with key %v", key) + continue + } + + var fieldType string + if persistence.PropertyType(key) == persistence.PropertyTypeBool { + fieldType = stanza.FieldTypeBool + } + + field := stanza.Field{ + Var: key, + Label: key, + Type: fieldType, + ValuesList: []string{value}, + } + fields = append(fields, &field) + log.Debugf("field: %#v", field) + } + } + } else { + for i, arg := range cmd.Arguments { + var required *string + if i < cmd.RequiredArgs { + dummyString := "" + required = &dummyString + } + + var fieldType string + var options []stanza.Option + if toOk && i == 0 { + switch command.Node { + case "mute", "kick", "ban", "promote", "unmute", "unban": + session, ok := sessions[bare] + if ok { + var membersList telegram.MembersList + switch command.Node { + case "unmute": + membersList = telegram.MembersListRestricted + case "unban": + membersList = telegram.MembersListBannedAndAdministrators + } + members, err := session.GetChatMembers(toId, true, "", membersList) + if err == nil { + fieldType = stanza.FieldTypeListSingle + switch command.Node { + // allow empty form + case "mute", "unmute": + options = append(options, stanza.Option{ + ValuesList: []string{""}, + }) + } + for _, member := range members { + senderId := session.GetSenderId(member.MemberId) + options = append(options, stanza.Option{ + Label: session.FormatContact(senderId), + ValuesList: []string{strconv.FormatInt(senderId, 10)}, + }) + } + } + } + } + } + + field := stanza.Field{ + Var: strconv.FormatInt(int64(i), 10), + Label: arg, + Required: required, + Type: fieldType, + Options: options, + } + fields = append(fields, &field) + log.Debugf("field: %#v", field) + } + } + form := stanza.Form{ + Type: stanza.FormTypeForm, + Title: command.Node, + Instructions: []string{cmd.Description}, + Fields: fields, + } + answer.Payload = &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + Status: stanza.CommandStatusExecuting, + CommandElements: []stanza.CommandElement{&form}, + } + log.Debugf("form: %#v", form) + } else if !toOk && command.Node == "loginwizard" { + var session *telegram.Client + answer.Payload, cancelSend, session = loginWizardPayload(bare, nil, resource, "") + + log.Debugf("immediate loginwizard payload: %#v", answer.Payload) + if cancelSend { + go sendLoginWizardResponse(component, answer, session) + } + } else if toOk && command.Node == "botmenu" { + session, ok := sessions[bare] + + var link *telegram.BotLink + var commands []*telegram.BotCommand + var err error + if ok { + link, commands, err = session.GetBotMenu(toId) + } + + payload := &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + } + answer.Payload = payload + + if !ok || err != nil { + setCommandPayloadError(payload, "Cannot retrieve commands") + } else { + if link != nil { + payload.Status = stanza.CommandStatusCompleted + payload.CommandElements = []stanza.CommandElement{ + &stanza.Note{ + Text: fmt.Sprintf("%v: %v", link.Description, link.Link), + Type: stanza.CommandNoteTypeInfo, + }, + } + } else { + var options []stanza.Option + for _, cmd := range commands { + options = append(options, stanza.Option{ + Label: fmt.Sprintf("/%v — %v", cmd.Command, cmd.Description), + ValuesList: []string{cmd.Command}, + }) + } + + dummyString := "" + field := stanza.Field{ + Var: "command", + Type: stanza.FieldTypeListSingle, + Required: &dummyString, + Options: options, + } + log.Debugf("field: %#v", field) + + form := stanza.Form{ + Type: stanza.FormTypeForm, + Fields: []*stanza.Field{&field}, + } + log.Debugf("form: %#v", form) + + payload.Status = stanza.CommandStatusExecuting + payload.CommandElements = []stanza.CommandElement{&form} + } + } + } else { + cmdString = "/" + command.Node + } + } else if command.Action == stanza.CommandActionCancel { + if command.Node == "loginwizard" { + session, ok := sessions[bare] + if ok { + session.ProcessTransportCommand("/cancelauth", resource) + } + } + answer.Payload = &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + Status: stanza.CommandStatusCancelled, + } + } + } + + if cmdString != "" { + session, ok := sessions[bare] + if !ok { + return + } + + var response string + var success bool + if toOk { + response, _, success = session.ProcessChatCommand(toId, cmdString) + } else { + response, success = session.ProcessTransportCommand(cmdString, resource) + } + + var noteType string + if success { + noteType = stanza.CommandNoteTypeInfo + } else { + noteType = stanza.CommandNoteTypeErr + } + + answer.Payload = &stanza.Command{ + SessionId: command.Node, + Node: command.Node, + Status: stanza.CommandStatusCompleted, + CommandElements: []stanza.CommandElement{ + &stanza.Note{ + Text: response, + Type: noteType, + }, + }, + } + + } + + log.Debugf("command response: %#v %v", answer.Payload, cancelSend) +} + +func handleSetQueryMucAdmin(s xmpp.Sender, iq *stanza.IQ, query *extensions.QueryMucAdmin) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 406) + return + } + + // pre-bake all data to make it transactional as much as possible + type Item struct { + UserID int64 + Nick string + Status telegram.ChatMemberStatus + } + + var items []Item + for _, item := range query.Items { + if item.Affiliation == "owner" { + iqAnswerSetError(answer, 403) + return + } + + if (item.Role != "" && item.Affiliation != "") || + (item.Role != "" && item.Nick == "") || + (item.Affiliation != "" && item.Jid == "") { + iqAnswerSetError(answer, 400) + return + } + + + var userID int64 + if item.Jid != "" { + userID, _, _ = gateway.ToToID(item.Jid) + } else if item.Nick != "" { + userID = session.GetMUCMemberIdByNickname(toID, item.Nick) + } + if userID == 0 { + iqAnswerSetError(answer, 404) + return + } + + nick := session.GetMUCNickname(userID) + + var status telegram.ChatMemberStatus + + switch item.Role { + case "none": + status = telegram.ChatMemberStatusKicked + case "visitor": + status = telegram.ChatMemberStatusMuted + case "participant": + status = telegram.ChatMemberStatusUnmuted + case "moderator": + status = telegram.ChatMemberStatusPromoted + } + switch item.Affiliation { + case "none": + status = telegram.ChatMemberStatusKicked + case "outcast": + status = telegram.ChatMemberStatusBanned + case "member": + status = telegram.ChatMemberStatusUnmuted + case "admin": + status = telegram.ChatMemberStatusPromoted + } + + // nothing has been detected + if status == telegram.ChatMemberStatusIllegal { + iqAnswerSetError(answer, 400) + return + } + + items = append(items, Item{ + UserID: userID, + Nick: nick, + Status: status, + }) + } + + for _, item := range items { + err := session.SetChatMemberStatus(toID, item.UserID, item.Status, 0, "", item.Nick) + if err != nil { + code, ok := telegram.GetErrorCode(err) + if !ok { + code = 500 + } + iqAnswerSetError(answer, int(code)) + answer.Error.Text = err.Error() + return + } + } +} + +func handleSetQueryMucOwner(s xmpp.Sender, iq *stanza.IQ, query *extensions.QueryMucOwner) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 405) + return + } + + chat, _, err := session.GetContactByID(toID, nil, true) + if err != nil || chat == nil || !session.IsGroup(chat) { + iqAnswerSetError(answer, 405) + return + } + + if query.Form != nil { + switch query.Form.Type { + case stanza.FormTypeSubmit: + // okay, noop + case stanza.FormTypeCancel: + return + default: + iqAnswerSetError(answer, 400) + return + } + + err = nil + for _, field := range query.Form.Fields { + switch field.Var { + case "muc#roomconfig_roomname", "muc#roomconfig_roomdesc", "muc#roomconfig_changesubject": + if len(field.ValuesList) != 1 { + iqAnswerSetError(answer, 400) + return + } + + value := field.ValuesList[0] + + switch field.Var { + case "muc#roomconfig_roomname": + if value == "" { + iqAnswerSetError(answer, 400) + return + } + if value != chat.Title { + err = session.SetChatTitle(toID, value) + if err != nil { + break + } + } + case "muc#roomconfig_roomdesc": + if value != session.GetChatDescription(chat) { + err = session.SetChatDescription(toID, value) + if err != nil { + break + } + } + case "muc#roomconfig_changesubject": + b, ok := ToBool(value) + if !ok { + iqAnswerSetError(answer, 400) + return + } + permissions := chat.Permissions + if permissions != nil && b != permissions.CanPinMessages { + newPermissions := telegram.CloneChatPermissions(permissions) + newPermissions.CanPinMessages = b + + err = session.SetChatPermissions(toID, newPermissions) + if err != nil { + break + } + } + } + } + } + } else if query.Destroy != nil { + err = session.DestroyMUC(toID) + } else { + // per 1.0 spec version, it also could be a destruction, but too dangerous to cover probably + iqAnswerSetError(answer, 400) + return + } + + if err != nil { + code, ok := telegram.GetErrorCode(err) + if !ok { + code = 500 + } + iqAnswerSetError(answer, int(code)) + answer.Error.Text = err.Error() + return + } + +} + +func handleSetQueryMAM(s xmpp.Sender, iq *stanza.IQ, query extensions.MAMQuery) { + component, answer, ok := iqResultStub(s, iq) + if !ok { + return + } + defer gateway.ResumableSend(component, answer) + + bare, _, fromOk := gateway.SplitJID(iq.From) + if !fromOk { + iqAnswerSetError(answer, 400) + return + } + + session, sessionOk := sessions[bare] + if !sessionOk || !session.Session.MUC { + iqAnswerSetError(answer, 403) + return + } + + toID, toOk, toIsGroup := gateway.ToToID(iq.To) + if !toOk || !toIsGroup { + iqAnswerSetError(answer, 405) + return + } + + chat, _, err := session.GetContactByID(toID, nil, true) + if err != nil || chat == nil || !session.IsGroup(chat) { + iqAnswerSetError(answer, 405) + return + } + + var startTime, endTime time.Time + var beforeId, afterId int64 + var ids []string + + var rsmBefore, rsmAfter int64 + var rsmLastPage bool + var rsmLimit int32 + var justCount bool + + form := query.GetForm() + queryRs := query.GetSet() + ns := query.Namespace() + + log.Debugf("MAM query to %v: %#v %#v %#v", toID, query, form, queryRs) + if form != nil && form.Type == stanza.FormTypeSubmit { + for _, field := range form.Fields { + if len(field.ValuesList) < 1 { + iqAnswerSetError(answer, 400) + return + } + + value := field.ValuesList[0] + log.Debugf("MAM query field: %v %v", field.Var, value) + + switch field.Var { + case "FORM_TYPE": + if value != ns { + iqAnswerSetError(answer, 400) + return + } + case "with": // okay, and? + case "start", "end": + timestamp, err := time.Parse(time.RFC3339, value) + if err != nil { + iqAnswerSetError(answer, 400) + return + } + + switch field.Var { + case "start": + startTime = timestamp + case "end": + endTime = timestamp + } + case "before-id": + beforeId, ok = parseMessageId(value) + if !ok { + iqAnswerSetError(answer, 400) + return + } + case "after-id": + afterId, ok = parseMessageId(value) + if !ok { + iqAnswerSetError(answer, 400) + return + } + case "ids": + ids = field.ValuesList + default: + iqAnswerSetError(answer, 501) + return + } + } + } + + if queryRs != nil { + if queryRs.After != nil { + rsmAfter, ok = parseMessageId(*queryRs.After) + if !ok { + iqAnswerSetError(answer, 400) + return + } + log.Debugf("MAM RSM after: %v", rsmAfter) + } + if queryRs.Before != nil { + before := *queryRs.Before + if before == "" { + rsmLastPage = true + log.Debugf("MAM RSM last page") + } else { + rsmBefore, ok = parseMessageId(*queryRs.Before) + if !ok { + iqAnswerSetError(answer, 400) + return + } + log.Debugf("MAM RSM before: %v", rsmBefore) + } + } + if queryRs.Max != nil { + rsmLimit = int32(*queryRs.Max) + if rsmLimit == 0 { + justCount = true + } + log.Debugf("MAM RSM max: %v", rsmLimit) + } + + if queryRs.First != nil { + iqAnswerSetError(answer, 400) + return + } + if queryRs.Index != nil { + iqAnswerSetError(answer, 501) + return + } + if queryRs.Last != nil { + iqAnswerSetError(answer, 400) + return + } + } + + if rsmLimit == 0 && !justCount { + rsmLimit = 100 + } + + // check for mutual parameter compatibility, there's a lot of them, nah? + if ((!startTime.IsZero() || !endTime.IsZero()) && (beforeId != 0 || afterId != 0 || ids != nil)) || + ((beforeId != 0 || afterId != 0) && (!startTime.IsZero() || !endTime.IsZero() || ids != nil)) || + (ids != nil && (!startTime.IsZero() || !endTime.IsZero() || beforeId != 0 || afterId != 0)) { + iqAnswerSetError(answer, 501) + log.Debugf("MAM: incompatible parameters") + return + } + + // dummy call to circumvent unexported type + messages, _, err := session.GetMessagesBetween(toID, 0, 0, 0, false) + + var order bool // false from start, true from end + // lower priority + if !endTime.IsZero() { + order = true + } + // higher priority + if beforeId != 0 { + order = false + } + if afterId != 0 { + order = true + } + if rsmAfter != 0 { + order = false + } + if rsmBefore != 0 || rsmLastPage { + order = true + } + if order { + rsmLimit = -rsmLimit // hacky, I know, and? :P + } + + quotaTs := time.Now().AddDate(0, 0, -int(gateway.MAMThreshold)) + var beyond, complete bool + fromStart := !order + toEnd := order + var overallyFirstMessageId, overallyLastMessageId int64 + + reverse := query.GetFlipPage() != nil + + if ids != nil { + for _, sId := range ids { + id, ok := parseMessageId(sId) + if !ok { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: bogus id") + return + } + msg, err := session.GetMessage(toID, id) + if err != nil { + iqAnswerSetError(answer, 404) + log.Debugf("MAM: unknown id") + return + } + messages = append(messages, msg) + } + messages = telegram.ChronologicallySortMessages(messages, reverse) + complete = true + } else if beforeId != 0 || afterId != 0 { + if (beforeId != 0 && afterId != 0) && beforeId < afterId { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: before < after") + return + } + + // don't allow to fetch far beyond the quota + var replaceWithQuotaTs bool + if afterId != 0 { + afterMessage, afterMessageErr := session.GetMessage(toID, afterId) + if afterMessageErr == nil && afterMessage != nil { + if int64(afterMessage.Date) < quotaTs.Unix() { + replaceWithQuotaTs = true + } + } else { + iqAnswerSetError(answer, 404) + log.Debugf("MAM: unknown after") + return + } + } else { + replaceWithQuotaTs = true + } + if replaceWithQuotaTs { + preFirstMessage, preFirstMessageErr := session.FindMessageByTime(toID, quotaTs) + if preFirstMessageErr == nil && preFirstMessage != nil { + afterId = preFirstMessage.Id + } else { + // there seem to be no messages older than quota, it's safe to fetch from the very start + afterId = 1 // https://github.com/tdlib/td/issues/195#issuecomment-380836359 + } + } + + if beforeId != 0 { + beforeMessage, beforeMessageErr := session.GetMessage(toID, beforeId) + if beforeMessageErr != nil || beforeMessage == nil { + iqAnswerSetError(answer, 404) + log.Debugf("MAM: unknown before") + return + } + overallyLastMessage, overallyLastMessageErr := session.GetPreviousMessage(toID, beforeId) + if overallyLastMessageErr == nil && overallyLastMessage != nil { + overallyLastMessageId = overallyLastMessage.Id + } + } else { + newestMessage, newestMessageErr := session.GetPreviousMessage(toID, 0) + if newestMessageErr == nil && newestMessage != nil { + overallyLastMessageId = newestMessage.Id + } + } + + if rsmAfter != 0 { + if rsmAfter < afterId || (beforeId != 0 && rsmAfter > beforeId) { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: unknown RSM after") + return + } + if rsmAfter != afterId { + fromStart = false + overallyFirstMessage, overallyFirstMessageErr := session.GetNextMessage(toID, afterId) + if overallyFirstMessageErr == nil && overallyFirstMessage != nil { + overallyFirstMessageId = overallyFirstMessage.Id + } + } + afterId = rsmAfter + } + + if rsmBefore != 0 { + if rsmBefore < afterId || (beforeId != 0 && rsmBefore > beforeId) { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: unknown RSM before") + return + } + if rsmBefore < beforeId { + toEnd = false + } + beforeId = rsmBefore + } + + var lastMessageId int64 + // should be fine even with beforeId=0 as it would mean the last as needed + lastMessage, lastMessageErr := session.GetPreviousMessage(toID, beforeId) + if lastMessageErr == nil && lastMessage != nil { + lastMessageId = lastMessage.Id + } else { + beyond = true + } + + if !beyond { + messages, complete, err = session.GetMessagesBetween(toID, afterId, lastMessageId, rsmLimit, reverse) + } + } else { // time limit or no limits at all + // don't allow to fetch far beyond the quota + if !endTime.IsZero() && endTime.Before(quotaTs) { + beyond = true + complete = true + } + if (!startTime.IsZero() && startTime.Before(quotaTs)) || startTime.IsZero() { + startTime = quotaTs + } + + if !beyond { + var fromMessageId int64 + var lastMessageId int64 + fromMessage, fromMessageErr := session.FindMessageByTime(toID, startTime) + if fromMessageErr == nil && fromMessage != nil { + fromMessageId = fromMessage.Id + } else { + // there seem to be no messages older than quota, it's safe to fetch from the very start + fromMessageId = 1 // https://github.com/tdlib/td/issues/195#issuecomment-380836359 + } + + if !endTime.IsZero() { + endMsg, endMsgErr := session.FindMessageByTime(toID, endTime) + if endMsgErr == nil && endMsg != nil { + lastMessageId = endMsg.Id + overallyLastMessageId = lastMessageId + } else { + beyond = true + complete = true + } + } else { + newestMessage, newestMessageErr := session.GetPreviousMessage(toID, 0) + if newestMessageErr == nil && newestMessage != nil { + overallyLastMessageId = newestMessage.Id + } + } + + if rsmAfter != 0 { + rsmAfterMessage, rsmAfterMessageErr := session.GetMessage(toID, rsmAfter) + if rsmAfterMessageErr == nil && rsmAfterMessage != nil { + if !endTime.IsZero() && int64(rsmAfterMessage.Date) > endTime.Unix() { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: RSM after out of range") + return + } + + if rsmAfterMessage.Id > fromMessageId && int64(rsmAfterMessage.Date) >= startTime.Unix() { + fromStart = false + overallyFirstMessage, overallyFirstMessageErr := session.GetNextMessage(toID, fromMessageId) + if overallyFirstMessageErr == nil && overallyFirstMessage != nil { + overallyFirstMessageId = overallyFirstMessage.Id + } + } + fromMessageId = rsmAfterMessage.Id + } else { + iqAnswerSetError(answer, 404) + log.Debugf("MAM: unknown RSM after") + return + } + } + + if rsmBefore != 0 { + rsmBeforeMessage, rsmBeforeMessageErr := session.GetMessage(toID, rsmBefore) + if rsmBeforeMessageErr == nil && rsmBeforeMessage != nil { + if int64(rsmBeforeMessage.Date) < startTime.Unix() { + iqAnswerSetError(answer, 400) + log.Debugf("MAM: RSM before out of range") + return + } + + if endTime.IsZero() || int64(rsmBeforeMessage.Date) <= endTime.Unix() { + newLastMessage, newLastMessageErr := session.GetPreviousMessage(toID, rsmBeforeMessage.Id) + if newLastMessageErr == nil && newLastMessage != nil { + if lastMessageId == 0 || lastMessageId != newLastMessage.Id { + toEnd = false + lastMessageId = newLastMessage.Id + } + } else { + // nothing?.. not complete, just empty + beyond = true + } + } + } else { + iqAnswerSetError(answer, 404) + log.Debugf("MAM: unknown RSM before") + return + } + } + + if !beyond { // yes🗿, twice + messages, complete, err = session.GetMessagesBetween(toID, fromMessageId, lastMessageId, rsmLimit, reverse) + } + } + } + + log.Debugf("obtained %v messages", len(messages)) + // ty zhe lopnesh, detochka + queryId := ns + " " + query.GetQueryId() + for _, message := range messages { + session.SendDelayedMUCMessage(toID, message, iq.From, queryId) + } + + rs := stanza.ResultSet{} + switch ns { + case gateway.NS_MAM2: + answer.Payload = &extensions.MAM2Fin{ + ResultSet: &rs, + Complete: complete, + Stable: false, + } + case gateway.NS_MAM1: + answer.Payload = &extensions.MAM1Fin{ + ResultSet: &rs, + Complete: complete, + Stable: false, + } + case gateway.NS_MAM0: + answer.Payload = &extensions.MAM0Fin{ + ResultSet: &rs, + Complete: complete, + Stable: false, + } + } + + if answer.Payload == nil { + log.Error("Unknown MAM version") + return + } + + if beyond { + count := 0 + rs.Count = &count + log.Debugf("MAM: beyond") + } else { + if len(messages) > 0 { + firstMsgId := messages[0].Id + lastMsgId := messages[len(messages)-1].Id + if fromStart { + if reverse { + overallyFirstMessageId = lastMsgId + } else { + overallyFirstMessageId = firstMsgId + } + } + if toEnd { + if reverse { + overallyLastMessageId = firstMsgId + } else { + overallyLastMessageId = lastMsgId + } + } + } + + var firstMsgPositionCount, lastMsgPositionCount int32 + // estimate overall count + if overallyFirstMessageId != 0 && overallyLastMessageId != 0 { + firstMsgPosition, firstMsgPositionErr := session.GetChatMessagePosition(toID, overallyFirstMessageId) + if firstMsgPositionErr == nil && firstMsgPosition != nil { + firstMsgPositionCount = firstMsgPosition.Count + } + lastMsgPosition, lastMsgPositionErr := session.GetChatMessagePosition(toID, overallyLastMessageId) + if lastMsgPositionErr == nil && lastMsgPosition != nil { + lastMsgPositionCount = lastMsgPosition.Count + } + log.WithFields(log.Fields{ + "overallyFirstMessageId": overallyFirstMessageId, + "overallyLastMessageId": overallyLastMessageId, + "firstMsgPositionCount": firstMsgPositionCount, + "lastMsgPositionCount": lastMsgPositionCount, + }).Debug("RSM count") + if firstMsgPositionCount != 0 && lastMsgPositionCount != 0 { + count := int(firstMsgPositionCount - lastMsgPositionCount + 1) + rs.Count = &count + } + } + + if len(messages) > 0 { + firstMsgId := messages[0].Id + lastMsgId := messages[len(messages)-1].Id + if reverse { + firstMsgId, lastMsgId = lastMsgId, firstMsgId + } + rs.First = &stanza.First{ + Content: strconv.FormatInt(firstMsgId, 10), + } + if firstMsgPositionCount != 0 { + rsmFirstMsgPosition, rsmFirstMsgPositionErr := session.GetChatMessagePosition(toID, firstMsgId) + if rsmFirstMsgPositionErr == nil && rsmFirstMsgPosition != nil { + index := int(firstMsgPositionCount - rsmFirstMsgPosition.Count) + rs.First.Index = &index + } + } + last := strconv.FormatInt(lastMsgId, 10) + rs.Last = &last + } + } + if log.GetLevel() == log.DebugLevel { + xmlFin, err := xml.Marshal(answer.Payload) + if err == nil { + log.Debug(string(xmlFin)) + } else { + log.Debugf("MAM fin: %#v %#v", answer.Payload, rs) + } + } +} + +func iqAnswerSetError(answer *stanza.IQ, code int) { + iqAnswerSetErrorInternal(answer, code, false) +} + +func iqAnswerRegisterSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code int) { + answer.Payload = *payload + iqAnswerSetErrorInternal(answer, code, true) +} + +func iqAnswerSetErrorInternal(answer *stanza.IQ, code int, registerMode bool) { + answer.Type = stanza.IQTypeError + switch code { + case 400: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeModify, + Reason: "bad-request", + } + case 401: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeAuth, + Reason: "not-authorized", + } + case 403: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeAuth, + Reason: "forbidden", + } + case 404: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeCancel, + Reason: "item-not-found", + } + case 405: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeCancel, + Reason: "not-allowed", + } + case 406: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeModify, + Reason: "not-acceptable", + } + case 500: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeWait, + Reason: "internal-server-error", + } + case 501: + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeCancel, + Reason: "feature-not-implemented", + } + default: + log.Error("Unknown error code, falling back with empty reason") + answer.Error = &stanza.Err{ + Type: stanza.ErrorTypeCancel, + Reason: "undefined-condition", + } + } + answer.Error.Code = code + + if registerMode { + switch code { + case 404: + answer.Error.Text = "No such room" + case 405: + answer.Error.Text = "Logging out is dangerous. If you are sure you would be able to receive the authentication code again, issue the /logout command to the transport" + case 406: + answer.Error.Text = "Phone number already provided, chat with the transport for further instruction" + } + } +} + +func presenceReplySetError(reply *stanza.Presence, code int) { + reply.Type = stanza.PresenceTypeError + reply.Error = stanza.Err{ + Code: code, + } + switch code { + case 400: + reply.Error.Type = stanza.ErrorTypeModify + reply.Error.Reason = "jid-malformed" + case 403: + reply.Error.Type = stanza.ErrorTypeAuth + reply.Error.Reason = "forbidden" + case 404: + reply.Error.Type = stanza.ErrorTypeCancel + reply.Error.Reason = "item-not-found" + case 405: + reply.Error.Type = stanza.ErrorTypeCancel + reply.Error.Reason = "not-allowed" + case 407: + reply.Error.Type = stanza.ErrorTypeAuth + reply.Error.Reason = "registration-required" + default: + log.Error("Unknown error code, falling back with empty reason") + reply.Error.Type = stanza.ErrorTypeCancel + reply.Error.Reason = "undefined-condition" + } +} + +func setCommandPayloadError(payload *stanza.Command, err string) { + note := stanza.Note{ + Text: err, + Type: stanza.CommandNoteTypeErr, + } + payload.Status = stanza.CommandStatusCompleted + payload.CommandElements = append(payload.CommandElements, ¬e) +} + +func probeClientFeatures(jid string, component *xmpp.Component) { + id, err := uuid.NewRandom() + if err != nil { + log.Error("Could not generate ID for a client features probe") + return + } + + probe := stanza.IQ{ + Attrs: stanza.Attrs{ + From: gateway.Jid.Bare(), + To: jid, + Id: id.String(), + Type: stanza.IQTypeGet, + }, + Payload: &stanza.DiscoInfo{}, + } + log.Debugf("%#v", probe) + + gateway.ResumableSend(component, &probe) +} + +func handleClientFeatures(s xmpp.Sender, iq *stanza.IQ, discoInfo *stanza.DiscoInfo) { + fromJid, err := stanza.NewJid(iq.From) + if err != nil { + log.Error("Invalid from JID!") + return + } + bareFrom := fromJid.Bare() + + session, ok := sessions[bareFrom] + if !ok { + log.Errorf("Got client features for unknown JID %v", bareFrom) + return + } + + var features []string + var avatarNotify bool + for _, feature := range discoInfo.Features { + features = append(features, feature.Var) + if feature.Var == gateway.NodeAvatarMetadataNotify { + avatarNotify = true + } + } + + session.XmppClientFeaturesLock.Lock() + session.XmppClientFeatures[fromJid.Resource] = &features + session.XmppClientFeaturesLock.Unlock() + + log.Debugf("Features for %v: %#v", iq.From, features) + + if avatarNotify { + go sendPubSubAvatarNotifications(s, iq.From, session) + } +} + +func sendPubSubAvatarNotifications(s xmpp.Sender, jid string, session *telegram.Client) { component, ok := s.(*xmpp.Component) if !ok { log.Error("Not a component") return } - _ = gateway.ResumableSend(component, answer) + for _, chatId := range session.OwnChatsKeys() { + chat, _, err := session.GetContactByID(chatId, nil, true) + if err != nil || chat == nil { + continue + } + + if chat.Photo == nil { + session.SetEmptyAvatarHash(chatId) + continue + } + + sha1 := session.GetPhotoSha1(chat.Photo.Small, chat.Id) + size := session.GetPhotoSize(chat.Photo.Small) + + var chatJid string + if session.Session.MUC && session.IsGroup(chat) { + chatJid = gateway.MUCJID(chat.Id) + } else { + chatJid = gateway.CHATJID(chat.Id, false) + } + gateway.SendPubSubAvatarNotification(component, jid, chatJid, sha1, size) + } } -func toToID(to string) (int64, bool) { - toParts := strings.Split(to, "@") - if len(toParts) < 2 { - return 0, false +func parseMessageId(sId string) (int64, bool) { + if sId[0] == 'e' { + idParts := strings.Split(sId[1:], ":") + if len(idParts) >= 1 { + sId = idParts[0] + } + } else if sId[0] == 'c' { + sId = sId[1:] } - toID, err := strconv.ParseInt(toParts[0], 10, 64) + id, err := strconv.ParseInt(sId, 10, 64) if err != nil { - log.WithFields(log.Fields{ - "to": to, - }).Error(errors.Wrap(err, "Invalid to JID!")) + log.Warn(errors.Wrap(err, "Failed to parse message ID!")) return 0, false } - return toID, true + return id, true } -func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, info string) stanza.IQPayload { +func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload { + var base64Photo string + if info.Photo != nil { + base64Photo = session.GetPhotoBase64(info.Photo) + } + if typ == TypeVCardTemp { vcard := &extensions.IqVcardTemp{} - vcard.Fn.Text = fn - if photo != "" { + vcard.Fn.Text = info.Fn + if base64Photo != "" { vcard.Photo.Type.Text = "image/jpeg" - vcard.Photo.Binval.Text = photo + vcard.Photo.Binval.Text = base64Photo } - vcard.Nickname.Text = nickname - vcard.N.Given.Text = given - vcard.N.Family.Text = family - vcard.Tel.Number.Text = tel - vcard.Desc.Text = info + vcard.Nickname.Text = strings.Join(info.Nicknames, ",") + vcard.N.Given.Text = info.Given + vcard.N.Family.Text = info.Family + vcard.Tel.Number.Text = info.Tel + vcard.Desc.Text = info.Info return vcard } else if typ == TypeVCard4 { nodes := []stanza.Node{} - if fn != "" { + if info.Fn != "" { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "fn"}, Nodes: []stanza.Node{ stanza.Node{ XMLName: xml.Name{Local: "text"}, - Content: fn, + Content: info.Fn, }, }, }) } - if photo != "" { + if base64Photo != "" { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "photo"}, Nodes: []stanza.Node{ stanza.Node{ XMLName: xml.Name{Local: "uri"}, - Content: "data:image/jpeg;base64," + photo, + Content: "data:image/jpeg;base64," + base64Photo, }, }, }) } - if nickname != "" { + for _, nickname := range info.Nicknames { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "nickname"}, Nodes: []stanza.Node{ @@ -492,39 +2931,39 @@ func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, inf }, }) } - if family != "" || given != "" { + if info.Family != "" || info.Given != "" { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "n"}, Nodes: []stanza.Node{ stanza.Node{ XMLName: xml.Name{Local: "surname"}, - Content: family, + Content: info.Family, }, stanza.Node{ XMLName: xml.Name{Local: "given"}, - Content: given, + Content: info.Given, }, }, }) } - if tel != "" { + if info.Tel != "" { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "tel"}, Nodes: []stanza.Node{ stanza.Node{ XMLName: xml.Name{Local: "uri"}, - Content: "tel:" + tel, + Content: "tel:" + info.Tel, }, }, }) } - if info != "" { + if info.Info != "" { nodes = append(nodes, stanza.Node{ XMLName: xml.Name{Local: "note"}, Nodes: []stanza.Node{ stanza.Node{ XMLName: xml.Name{Local: "text"}, - Content: info, + Content: info.Info, }, }, }) @@ -532,7 +2971,7 @@ func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, inf pubsub := &stanza.PubSubGeneric{ Items: &stanza.Items{ - Node: NodeVCard4, + Node: gateway.NodeVCard4, List: []stanza.Item{ stanza.Item{ Id: id, @@ -556,3 +2995,15 @@ func makeVCardPayload(typ byte, id, fn, photo, nickname, given, family, tel, inf return nil } + +// ToBool returns bool, ok +func ToBool(b string) (bool, bool) { + switch b { + case "0", "false": + return false, true + case "1", "true": + return true, true + } + + return false, false +} diff --git a/xmpp/loginwizard.go b/xmpp/loginwizard.go new file mode 100644 index 0000000..53b45e5 --- /dev/null +++ b/xmpp/loginwizard.go @@ -0,0 +1,173 @@ +package xmpp + +import ( + "fmt" + + "dev.narayana.im/narayana/telegabber/persistence" + "dev.narayana.im/narayana/telegabber/telegram" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func loginWizardPayload(bare string, requestForm *stanza.Form, resource string, action string) (payload *stanza.Command, cancelSend bool, returnSession *telegram.Client) { + payload = &stanza.Command{ + SessionId: "loginwizard", + Node: "loginwizard", + } + + session, ok := sessions[bare] + if ok { + returnSession = session + + var command string + + if requestForm == nil { + session.StartLoginWizard(false) + cancelSend = true + } else if action == stanza.CommandActionComplete || action == stanza.CommandActionExecute { + command = "/finish" + } else { + if len(requestForm.Fields) != 1 { + setCommandPayloadError(payload, "Hey, don't tinker with the form!") + return + } + field := requestForm.Fields[0] + if field != nil { + if len(field.ValuesList) < 1 { + setCommandPayloadError(payload, "No value") + return + } + value := field.ValuesList[0] + switch field.Var { + case "login", "code", "password", "preset": + if field.Var == "preset" && value == "" { + command = "/pass" + } else { + command = fmt.Sprintf("/%v %v", field.Var, value) + } + case "muc": + fieldValue := persistence.NormalizeProperty(field.Var, value) + command = fmt.Sprintf("/config muc %v", fieldValue) + default: + setCommandPayloadError(payload, "Unknown field") + return + } + } + } + + if command != "" { + session.StartLoginWizard(true) + response, success := session.ProcessTransportCommand(command, resource) + if !success { + setCommandPayloadError(payload, response) + session.StopLoginWizard() + return + } + + cancelSend = true + } + } else { + setCommandPayloadError(payload, fmt.Sprintf("Session is not initialized, add the transport (%v) to contacts first", gateway.Jid.Bare())) + } + + return +} + +func sendLoginWizardResponse(component *xmpp.Component, answer *stanza.IQ, session *telegram.Client) { + payload := &stanza.Command{ + SessionId: "loginwizard", + Node: "loginwizard", + } + + nextStage := telegram.LoginStageLogin + if session != nil { + nextStage = session.GetLoginWizardNextStage() + } + log.Debugf("nextStage: %v", nextStage) + + if nextStage == telegram.LoginStageNone || nextStage == telegram.LoginStageCancel { + setCommandPayloadError(payload, "Cancelled") + session.StopLoginWizard() + } else if nextStage == telegram.LoginStageSuccess { + payload.Status = stanza.CommandStatusCompleted + session.StopLoginWizard() + } else { + required := "" + var fieldType string + var finishAction bool + var options []stanza.Option + var note string + + switch nextStage { + case telegram.LoginStagePreset: + fieldType = stanza.FieldTypeListSingle + finishAction = true + options = []stanza.Option{ + stanza.Option{ + ValuesList: []string{""}, + }, + stanza.Option{ + Label: "Modern", + ValuesList: []string{"modern"}, + }, + stanza.Option{ + Label: "Classic", + ValuesList: []string{"classic"}, + }, + } + note = "Do you want to use a config preset?\nModern enables brand new XMPP features,\nClassic targets legacy clients stuck in 00s." + case telegram.LoginStageMUC: + fieldType = stanza.FieldTypeBool + finishAction = true + + value, err := session.Session.Get("muc") + if err != nil { + log.Error("Achtung! Programming error in retrieving MUC config option") + value = "false" + } + + options = append(options, stanza.Option{ + ValuesList: []string{value}, + }) + note = "Enable MUCs? Telegabber still supports the legacy group-to-PM mapping too." + } + + form := stanza.Form{ + Type: stanza.FormTypeForm, + Title: "Login Wizard", + Fields: []*stanza.Field{ + &stanza.Field{ + Var: string(nextStage), + Label: string(nextStage), + Required: &required, + Type: fieldType, + Options: options, + }, + }, + } + payload.Status = stanza.CommandStatusExecuting + payload.CommandElements = append(payload.CommandElements, &form) + + actions := stanza.Actions{ + Next: &struct{}{}, + } + if finishAction { + actions.Complete = &struct{}{} + } + payload.CommandElements = append(payload.CommandElements, &actions) + + if note != "" { + payload.CommandElements = append(payload.CommandElements, &stanza.Note{ + Text: note, + Type: stanza.CommandNoteTypeInfo, + }) + } + } + + answer.Payload = payload + + gateway.ResumableSend(component, answer) +}