diff --git a/.gitignore b/.gitignore index b132b72..cf4df11 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ sessions/ session.dat session.dat.new release/ +tdlib/ diff --git a/Dockerfile b/Dockerfile index 6fea570..c3858e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ WORKDIR /src RUN make ${MAKEOPTS} FROM scratch AS telegabber -COPY --from=build /src/telegabber /usr/local/bin/ +COPY --from=build /src/release/telegabber /usr/local/bin/ ENTRYPOINT ["/usr/local/bin/telegabber"] FROM scratch AS binaries diff --git a/Makefile b/Makefile index a1d58fc..b1facea 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ .PHONY: all test COMMIT := $(shell git rev-parse --short HEAD) -TD_COMMIT := "8517026415e75a8eec567774072cbbbbb52376c1" +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 ./badger @@ -16,3 +17,9 @@ lint: 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/go.mod b/go.mod index f6d001e..4eb2643 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( 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 @@ -23,7 +24,6 @@ require ( 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/google/uuid v1.1.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 @@ -33,5 +33,6 @@ require ( nhooyr.io/websocket v1.6.5 // indirect ) -replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20220708184440-35d9cd68e55f -replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615 +replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20240512132113-6725c3862314 + +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 93d61b0..82e391a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,16 @@ 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= -dev.narayana.im/narayana/go-xmpp v0.0.0-20220708184440-35d9cd68e55f h1:aT50UsPH1dLje9CCAquRRhr7I9ZvL3kQU6WIWTe8PZ0= -dev.narayana.im/narayana/go-xmpp v0.0.0-20220708184440-35d9cd68e55f/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= 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/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= diff --git a/persistence/sessions.go b/persistence/sessions.go index dc5de46..27d361b 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,18 +35,30 @@ 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"` - MUC bool `yaml:":muc"` - 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", @@ -54,17 +67,27 @@ var configKeys = []string{ "oobmode", "carbons", "hideids", + "receipts", + "nativeedits", + "ignoregroupdeletions", } 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 } } @@ -106,6 +129,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, @@ -117,6 +150,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 @@ -134,6 +174,12 @@ func (s *Session) Get(key string) (string, error) { 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") @@ -141,9 +187,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 } @@ -152,6 +201,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 @@ -205,11 +257,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) @@ -221,6 +306,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" diff --git a/persistence/sessions_test.go b/persistence/sessions_test.go index e79de24..a8ec171 100644 --- a/persistence/sessions_test.go +++ b/persistence/sessions_test.go @@ -49,6 +49,7 @@ func TestSessionToMap(t *testing.T) { RawMessages: true, MUC: true, OOBMode: true, + Receipts: true, } m := session.ToMap() sample := map[string]string{ @@ -60,6 +61,8 @@ func TestSessionToMap(t *testing.T) { "oobmode": "true", "carbons": "false", "hideids": "false", + "receipts": "true", + "nativeedits": "false", } if !reflect.DeepEqual(m, sample) { t.Errorf("Map does not match the sample: %v", m) @@ -87,3 +90,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..95cdea5 --- /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 -t' + +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 13d2341..731d193 100644 --- a/telegabber.go +++ b/telegabber.go @@ -12,6 +12,7 @@ import ( "dev.narayana.im/narayana/telegabber/xmpp" log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" goxmpp "gosrc.io/xmpp" ) @@ -60,6 +61,9 @@ 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) @@ -89,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/client.go b/telegram/client.go index f3bfe4a..56cd323 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -16,25 +16,6 @@ import ( "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 @@ -60,6 +41,12 @@ func NewMUCState() *MUCState { } } +// HashedAvatar stores a SHA-1 hash and a Telegram file ID +type HashedAvatar struct { + Hash string + File int32 +} + // Client stores the metadata for lazily invoked TDlib instance type Client struct { client *client.Client @@ -72,19 +59,34 @@ type Client struct { jid string Session *persistence.Session resources map[string]bool - outbox map[string]string content *config.TelegramContentConfig cache *cache.Cache online bool + loginWizard *loginWizardMetadata + + lastAuthorizationStateType string + + outbox map[string]string + editOutbox map[string]string + DelayedStatuses map[int64]*DelayedStatus DelayedStatusesLock sync.Mutex lastMsgHashes map[int64]uint64 + lastMsgIds map[int64]string msgHashSeed maphash.Seed mucCache map[int64]*MUCState + LastBotCmdString string + + XmppClientFeatures map[string]*[]string + XmppClientFeaturesLock sync.Mutex + + AvatarHashes map[int64]*HashedAvatar + AvatarHashesLock sync.Mutex + locks clientLocks SendMessageLock sync.Mutex } @@ -95,20 +97,27 @@ type clientLocks struct { resourcesLock sync.Mutex outboxLock sync.Mutex mucCacheLock sync.Mutex + editOutboxLock sync.Mutex lastMsgHashesLock sync.Mutex + lastMsgIdsLock sync.RWMutex authorizerReadLock sync.Mutex authorizerWriteLock sync.Mutex + + loginWizardReadLock sync.Mutex + loginWizardWriteLock sync.Mutex +} + +type loginWizardMetadata struct { + nextStage chan string + 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, @@ -149,19 +158,23 @@ 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), - outbox: make(map[string]string), - mucCache: make(map[int64]*MUCState), - content: &conf.Content, - cache: cache.NewCache(), - options: options, - DelayedStatuses: make(map[int64]*DelayedStatus), - lastMsgHashes: make(map[int64]uint64), - msgHashSeed: maphash.MakeSeed(), + 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), + mucCache: make(map[int64]*MUCState), + options: options, + DelayedStatuses: make(map[int64]*DelayedStatus), + lastMsgHashes: make(map[int64]uint64), + lastMsgIds: make(map[int64]string), + msgHashSeed: maphash.MakeSeed(), + XmppClientFeatures: make(map[string]*[]string), + AvatarHashes: make(map[int64]*HashedAvatar), locks: clientLocks{ chatMessageLocks: make(map[int64]*sync.Mutex), }, 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 d365d4e..cda84cb 100644 --- a/telegram/commands.go +++ b/telegram/commands.go @@ -3,17 +3,20 @@ package telegram import ( "fmt" "github.com/pkg/errors" + "sort" "strconv" "strings" "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 TelegramAuthDone string = "Authorization is done already" @@ -48,102 +51,190 @@ var permissionsMember = client.ChatPermissions{ var permissionsReadonly = client.ChatPermissions{} var transportCommands = map[string]command{ - "login": command{"phone", "sign in"}, - "logout": command{"", "sign out"}, - "cancelauth": command{"", "quit the signin wizard"}, - "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»"}, + "help": command{0, []string{}, "help", false, nil}, + "login": command{1, []string{"phone"}, "sign in", false, nil}, + "logout": command{0, []string{}, "sign out", true, nil}, + "cancelauth": command{0, []string{}, "quit the signin wizard", false, nil}, + "code": command{1, []string{"xxxxx"}, "check one-time code", false, nil}, + "password": command{1, []string{"********"}, "check 2fa password", false, nil}, + "setusername": command{0, []string{"@username"}, "update @username", true, nil}, + "setname": command{1, []string{"first", "last"}, "update name", true, nil}, + "setbio": command{0, []string{"Lorem ipsum"}, "update about", true, nil}, + "setpassword": command{0, []string{"old", "new"}, "set or remove password", true, nil}, + "config": command{0, []string{"param", "value"}, "view or update configuration options", false, nil}, + "report": command{2, []string{"chat", "comment"}, "report a chat by id or @username", true, nil}, + "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil}, + "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil}, + "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil}, + "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil}, } +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{ - "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"}, - "vcard": command{"", "print vCard as text"}, - "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)"}, + "help": command{0, []string{}, "help", false, nil}, + "d": command{0, []string{"n"}, "delete your last message(s)", true, nil}, + "s": command{1, []string{"edited message"}, "edit your last message", true, nil}, + "silent": command{1, []string{"message"}, "send a message without sound", true, nil}, + "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}, + "raw": command{1, []string{"message"}, "send a raw message not interpeted as a transport command (e.g. a bot command)", true, nil}, + "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil}, + "vcard": command{0, []string{}, "print vCard as text", true, nil}, + "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil}, + "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil}, + "group": command{1, []string{"title"}, "create groupchat «title» with current user", true, ¬ForGroups}, + "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil}, + "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil}, + "secret": command{0, []string{}, "create secretchat with current user", true, ¬ForGroups}, + "search": command{0, []string{"string", "[limit]"}, "search in current chat", true, nil}, + "history": command{0, []string{"limit"}, "get last [limit] messages from current chat", true, nil}, + "block": command{0, []string{}, "blacklist current user", true, ¬ForGroups}, + "unblock": command{0, []string{}, "unblacklist current user", true, ¬ForGroups}, + "invite": command{1, []string{"id or @username"}, "add user to current chat", true, ¬ForPM}, + "link": command{0, []string{}, "get invite link for current chat", true, ¬ForPM}, + "kick": command{1, []string{"id or @username"}, "remove user from current chat", true, ¬ForPM}, + "mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", true, ¬ForPMAndBasic}, + "unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", true, ¬ForPMAndBasic}, + "ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", true, ¬ForPM}, + "unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", true, ¬ForPM}, + "promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", true, ¬ForPM}, + "leave": command{0, []string{}, "leave current chat", true, ¬ForPM}, + "leave!": command{0, []string{}, "leave current chat (for owners)", true, ¬ForPM}, + "ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", true, &onlyForSecret}, + "close": command{0, []string{}, "close current secret chat", true, &onlyForSecret}, + "delete": command{0, []string{}, "delete current chat from chat list", true, nil}, + "members": command{0, []string{"query"}, "search members [by optional query] in current chat (requires admin rights)", true, nil}, } 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 { + RequiredArgs int + Arguments []string + Description string + LoginOnly bool + NotFor *[]ChatType +} +type configurationOption struct { arguments string description string } -type configurationOption command -type helpType int +// CommandType disinguishes command sets by chat +type CommandType int const ( - helpTypeTransport helpType = iota - helpTypeChat + CommandTypeTransport CommandType = iota + CommandTypeChat ) -func helpString(ht helpType) string { - var str strings.Builder +// GetCommands exposes the set of commands +func GetCommands(typ CommandType) map[string]command { var commandMap map[string]command - switch ht { - case helpTypeTransport: + switch typ { + case CommandTypeTransport: commandMap = transportCommands - case helpTypeChat: + case CommandTypeChat: commandMap = chatCommands } - str.WriteString("Available commands:\n") - for name, command := range commandMap { - str.WriteString("/") - str.WriteString(name) - if command.arguments != "" { - str.WriteString(" ") - str.WriteString(command.arguments) + return commandMap +} + +// GetCommand obtains one command +func GetCommand(typ CommandType, cmd string) (command, bool) { + commands := GetCommands(typ) + command, ok := commands[cmd] + return command, ok +} + +// SortedCommandKeys sorts a slice with command keys +func SortedCommandKeys(commandMap map[string]command) []string { + keys := make([]string, len(commandMap)) + + i := 0 + for k := range commandMap { + keys[i] = k + i++ + } + + sort.Strings(keys) + + return keys +} + +// 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(" — ") - str.WriteString(command.description) + 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 +} + +func (c *Client) helpString(typ CommandType, chatId int64) string { + var str strings.Builder + + commandMap := GetCommands(typ) + chatType, _, chatTypeErr := c.GetChatType(chatId) + + str.WriteString("Available commands:\n") + for _, name := range SortedCommandKeys(commandMap) { + command := commandMap[name] + if chatTypeErr == nil && !IsCommandForChatType(command, chatType) { + continue + } + str.WriteString(CommandToHelpString(name, command)) str.WriteString("\n") } - if ht == helpTypeTransport { + if typ == CommandTypeTransport { str.WriteString("Configuration options\n") - for name, option := range transportConfigurationOptions { + for _, name := range persistence.ConfigKeys { + option := transportConfigurationOptions[name] str.WriteString(name) str.WriteString(" ") str.WriteString(option.arguments) @@ -185,12 +276,8 @@ func keyValueString(key, value string) string { } func (c *Client) unsubscribe(chatID int64) error { - return gateway.SendPresence( - c.xmpp, - c.jid, - gateway.SPFrom(strconv.FormatInt(chatID, 10)), - gateway.SPType("unsubscribed"), - ) + args := gateway.SimplePresence(chatID, "unsubscribed") + return c.sendPresence(args...) } func (c *Client) usernameOrIDToID(username string) (int64, error) { @@ -214,23 +301,27 @@ 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 := transportCommands[cmd] + if !ok { + return unknownCommand, false + } + if len(args) < command.RequiredArgs { + return notEnoughArguments, false + } + switch cmd { case "login", "code", "password": if cmd == "login" && c.Session.Login != "" { - return "Phone number already provided, use /cancelauth to start over" - } - - if len(args) < 1 { - return notEnoughArguments + return "Phone number already provided, use /cancelauth to start over", false } if cmd == "login" { err := c.TryLogin(resource, args[0]) if err != nil { - return err.Error() + return err.Error(), false } c.locks.authorizerWriteLock.Lock() @@ -242,11 +333,11 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string defer c.locks.authorizerWriteLock.Unlock() if c.authorizer == nil { - return TelegramNotInitialized + return TelegramNotInitialized, false } if c.authorizer.isClosed { - return TelegramAuthDone + return TelegramAuthDone, false } switch cmd { @@ -261,31 +352,30 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string // sign out case "logout": if !c.Online() { - return notOnline + return notOnline, false + } + + _, err := c.client.LogOut() + if err != nil { + return errors.Wrap(err, "Logout error").Error(), false } for _, id := range c.cache.ChatsKeys() { c.unsubscribe(id) } - _, err := c.client.LogOut() - if err != nil { - c.forceClose() - return errors.Wrap(err, "Logout error").Error() - } - c.Session.Login = "" // cancel auth case "cancelauth": if c.Online() { - return "Not allowed when online, use /logout instead" + return "Not allowed when online, use /logout instead", false } c.cancelAuth() - return "Cancelled" + return "Cancelled", true // set @username case "setusername": if !c.Online() { - return notOnline + return notOnline, false } var username string @@ -297,17 +387,15 @@ 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) @@ -321,7 +409,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string } else { c.locks.authorizerWriteLock.Unlock() if !c.Online() { - return notOnline + return notOnline, false } _, err := c.client.SetName(&client.SetNameRequest{ @@ -329,32 +417,33 @@ 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 + return notOnline, false } _, 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 + return notOnline, false } 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{ @@ -362,56 +451,60 @@ 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.MessageOutgoingPermissionVersion == 0 && args[0] == "carbons" && args[1] == "true" { - return "The server did not allow to enable carbons" + 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 } 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 "report": - if len(args) < 2 { - return "Not enough arguments" - } - contact, _, err := c.GetContactByUsername(args[0]) 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) @@ -422,32 +515,45 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string case "channel": return c.cmdChannel(args, cmdline) case "help": - return helpString(helpTypeTransport) + return c.helpString(CommandTypeTransport, 0), true } - return "" + return "", true } // 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) { +// 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) { if !c.Online() { - return notOnline, true + return notOnline, true, false } cmd, args := parseCommand(cmdline) + command, ok := chatCommands[cmd] + if !ok { + return unknownCommand, false, false + } + if len(args) < command.RequiredArgs { + return notEnoughArguments, true, false + } + + chatType, _, chatTypeErr := c.GetChatType(chatID) + if chatTypeErr == nil && !IsCommandForChatType(command, chatType) { + return "Not applicable for this chat type", true, false + } + 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 { @@ -456,7 +562,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) @@ -473,28 +579,25 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) Revoke: true, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // 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.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 0)) @@ -506,17 +609,13 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) InputMessageContent: content, }) if err != nil { - return "Message editing error", true + return "Message editing error", true, false } } else { - return "Message processing error", true + return "Message processing error", true, false } // send without sound case "silent": - if len(args) < 1 { - return "Not enough arguments", true - } - content := c.PrepareOutgoingMessageContent(rawCmdArguments(cmdline, 0)) if content != nil { @@ -528,17 +627,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] @@ -592,7 +687,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) break } - return "Invalid schedule time specifier", true + return "Invalid schedule time specifier", true, false } } @@ -607,27 +702,38 @@ 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 } // 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{ @@ -636,7 +742,7 @@ 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 { @@ -647,7 +753,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) case "vcard": info, err := c.GetVcardInfo(chatID) if err != nil { - return err.Error(), true + return err.Error(), true, false } _, link := c.PermastoreFile(info.Photo, true) entries := []string{ @@ -657,67 +763,66 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) keyValueString("Full name", info.Given+" "+info.Family), keyValueString("Phone number", info.Tel), } - return strings.Join(entries, "\n"), true + 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]) 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{ @@ -726,7 +831,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": @@ -734,18 +839,17 @@ 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]) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ @@ -754,78 +858,89 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) Status: &client.ChatMemberStatusLeft{}, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } - // 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]) 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.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, 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]) + 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.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, 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]) 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 } } @@ -837,17 +952,16 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) }, }) 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]) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ @@ -856,17 +970,16 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) 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]) if err != nil { - return err.Error(), true + return err.Error(), true, false + } + if contact == nil { + return "Contact not found", true, false } // clone the permissions @@ -885,7 +998,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) Status: &status, }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // leave current chat case "leave": @@ -893,12 +1006,12 @@ 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) if err != nil { - return err.Error(), true + return err.Error(), true, false } // leave current chat (for owners) case "leave!": @@ -906,12 +1019,12 @@ 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) if err != nil { - return err.Error(), true + return err.Error(), true, false } // set TTL case "ttl": @@ -920,7 +1033,7 @@ 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.SetChatMessageAutoDeleteTime(&client.SetChatMessageAutoDeleteTimeRequest{ @@ -929,13 +1042,16 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) }) if err != nil { - return err.Error(), true + return err.Error(), true, false } // close secret chat case "close": chat, _, err := c.GetContactByID(chatID, nil) 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() @@ -945,12 +1061,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) if err != nil { - return err.Error(), true + return err.Error(), true, false } } // delete current chat @@ -961,12 +1077,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) if err != nil { - return err.Error(), true + return err.Error(), true, false } // message search case "search": @@ -985,7 +1101,7 @@ 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, true, "") @@ -1001,7 +1117,7 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool) messages, err := c.getNLastMessages(chatID, limit) if err != nil { - return err.Error(), true + return err.Error(), true, false } c.sendMessagesReverse(chatID, messages, true, "") // chat members @@ -1011,126 +1127,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(CommandTypeChat, chatID), true, true default: - return "", false + return "", false, false } - return "", true + return "", 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) - return "" + return "", 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 "", 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 "", 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 "", true } diff --git a/telegram/connect.go b/telegram/connect.go index b1b8b10..d37c5fd 100644 --- a/telegram/connect.go +++ b/telegram/connect.go @@ -2,7 +2,6 @@ package telegram import ( "github.com/pkg/errors" - "strconv" "time" "dev.narayana.im/narayana/telegabber/xmpp/gateway" @@ -69,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 @@ -130,6 +129,7 @@ func (c *Client) Connect(resource string) error { tdlibClient, err := client.NewClient(c.authorizer, c.options...) if err != nil { c.locks.authorizationReady.Unlock() + c.wizardStageOrPrompt("cancel", "") return errors.Wrap(err, "Couldn't initialize a Telegram client instance") } @@ -138,6 +138,8 @@ func (c *Client) Connect(resource string) error { // stage 3: if a client is succesfully created, AuthorizationStateReady is already reached log.Warn("Authorization successful!") + c.wizardStageOrPrompt("success", "") + c.me, err = c.client.GetMe() if err != nil { log.Error("Could not retrieve me info") @@ -159,7 +161,7 @@ func (c *Client) Connect(resource string) error { } gateway.SubscribeToTransport(c.xmpp, c.jid) - gateway.SendPresence(c.xmpp, c.jid, gateway.SPStatus("Logged in as: "+c.Session.Login)) + c.sendPresence(gateway.SPStatus("Logged in as: " + c.Session.Login)) }() return nil @@ -228,12 +230,8 @@ func (c *Client) Disconnect(resource string, quit bool) bool { // 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"), - ) + args := gateway.SimplePresence(id, "unavailable") + c.sendPresence(args...) } c.close() @@ -260,6 +258,8 @@ func (c *Client) interactor() { log.Infof("Telegram authorization state: %#v", stateType) log.Debugf("%#v", state) + c.lastAuthorizationStateType = stateType + switch stateType { // stage 0: set login case client.TypeAuthorizationStateWaitPhoneNumber: @@ -267,12 +267,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("login", "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("code", "Please, enter authorization code via /code 12345") // stage 1b: wait for registration case client.TypeAuthorizationStateWaitRegistration: log.Warn("Waiting for full name...") @@ -280,7 +280,7 @@ 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("password", "Please, enter 2FA passphrase via /password 12345") } c.locks.authorizerReadLock.Unlock() } @@ -299,6 +299,7 @@ func (c *Client) forceClose() { 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() @@ -313,6 +314,7 @@ func (c *Client) close() { } func (c *Client) cancelAuth() { + c.StopLoginWizard() c.close() c.Session.Login = "" } diff --git a/telegram/formatter/formatter.go b/telegram/formatter/formatter.go index 740fa09..a8c94a0 100644 --- a/telegram/formatter/formatter.go +++ b/telegram/formatter/formatter.go @@ -8,15 +8,31 @@ import ( "github.com/zelenin/go-tdlib/client" ) -// Insertion is a piece of text in given position -type Insertion struct { +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 } -// 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 +40,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 +62,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] @@ -120,21 +141,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 +177,89 @@ func ClaspDirectives(text string, entities []*client.TextEntity) []*client.TextE return alignedEntities } -func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) (*Insertion, *Insertion) { - return &Insertion{ +func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) []*insertion { + return []*insertion{ + &insertion{ Offset: entity.Offset, Runes: lbrace, - }, &Insertion{ + Type: insertionOpening, + }, + &insertion{ Offset: entity.Offset + entity.Length, Runes: rbrace, - } + Type: insertionClosing, + }, + } } -// EntityToMarkdown generates the wrapping Markdown tags -func EntityToMarkdown(entity *client.TextEntity) (*Insertion, *Insertion) { +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 +270,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 +300,59 @@ 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 } // 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, ) 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,36 +369,70 @@ func Format( startStack, endStack = startStack.rebalance(endStack, entity.Offset) - startInsertion, endInsertion := entityToMarkup(entity) - if startInsertion != nil { - startStack = append(startStack, startInsertion) + insertions := entityToMarkup(entity, doubledRunes, markupMode) + if len(insertions) > 1 { + startStack = append(startStack, insertions[0:len(insertions)-1]...) } - if endInsertion != nil { - endStack = append(endStack, endInsertion) + 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 - for _, cp := range sourceText { - for insertion != nil && insertion.Offset <= runeI { + for i, cp := range doubledRunes { + if skipNext { + skipNext = false + continue + } + + for insertion != nil && int(insertion.Offset) <= i { markupRunes = append(markupRunes, insertion.Runes...) insertion = nextInsertion() } 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 } } for insertion != nil { diff --git a/telegram/formatter/formatter_test.go b/telegram/formatter/formatter_test.go index e4bdd23..187d486 100644 --- a/telegram/formatter/formatter_test.go +++ b/telegram/formatter/formatter_test.go @@ -7,7 +7,7 @@ import ( ) func TestNoFormatting(t *testing.T) { - markup := Format("abc\ndef", []*client.TextEntity{}, EntityToMarkdown) + markup := Format("abc\ndef", []*client.TextEntity{}, MarkupModeMarkdown) if markup != "abc\ndef" { t.Errorf("No formatting expected, but: %v", markup) } @@ -20,7 +20,7 @@ func TestFormattingSimple(t *testing.T) { Length: 4, Type: &client.TextEntityTypeBold{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown) if markup != "👙**🐧🐖**" { t.Errorf("Wrong simple formatting: %v", markup) } @@ -40,7 +40,7 @@ func TestFormattingAdjacent(t *testing.T) { Url: "https://narayana.im/", }, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown) if markup != "a👙_🐧_[🐖](https://narayana.im/)" { t.Errorf("Wrong adjacent formatting: %v", markup) } @@ -63,18 +63,18 @@ func TestFormattingAdjacentAndNested(t *testing.T) { Length: 2, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown) 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 +82,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 +99,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 +116,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 +133,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 +142,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 +208,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) if markup != "abc\ndef" { t.Errorf("No formatting expected, but: %v", markup) } @@ -221,7 +221,7 @@ func TestFormattingXEP0393Simple(t *testing.T) { Length: 4, Type: &client.TextEntityTypeBold{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "👙*🐧🐖*" { t.Errorf("Wrong simple formatting: %v", markup) } @@ -241,7 +241,7 @@ func TestFormattingXEP0393Adjacent(t *testing.T) { Url: "https://narayana.im/", }, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "a👙_🐧_🐖 " { t.Errorf("Wrong adjacent formatting: %v", markup) } @@ -264,7 +264,7 @@ func TestFormattingXEP0393AdjacentAndNested(t *testing.T) { Length: 2, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "```\n*👙*🐧\n```_🐖_" { t.Errorf("Wrong adjacent&nested formatting: %v", markup) } @@ -287,7 +287,7 @@ func TestFormattingXEP0393AdjacentItalicBoldItalic(t *testing.T) { Length: 69, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "_раса двуногих крысолюдей, *которую так редко замечают, что многие отрицают само их существование*_" { t.Errorf("Wrong adjacent italic/bold-italic formatting: %v", markup) } @@ -315,7 +315,7 @@ func TestFormattingXEP0393MultipleAdjacent(t *testing.T) { Length: 1, Type: &client.TextEntityTypeItalic{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "a*bcd*_e_" { t.Errorf("Wrong multiple adjacent formatting: %v", markup) } @@ -343,7 +343,7 @@ func TestFormattingXEP0393Intersecting(t *testing.T) { Length: 1, Type: &client.TextEntityTypeBold{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "a*b*_*cd*e_" { t.Errorf("Wrong intersecting formatting: %v", markup) } @@ -361,7 +361,7 @@ func TestFormattingXEP0393InlineCode(t *testing.T) { Length: 25, Type: &client.TextEntityTypePre{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) if markup != "Is `Gajim` a thing?\n\n```\necho 'Hello'\necho 'world'\n```\n\nhruck(" { t.Errorf("Wrong intersecting formatting: %v", markup) } @@ -374,7 +374,7 @@ func TestFormattingMarkdownStrikethrough(t *testing.T) { Length: 3, Type: &client.TextEntityTypeStrikethrough{}, }, - }, EntityToMarkdown) + }, MarkupModeMarkdown) if markup != "Everyone ~~dis~~likes cake." { t.Errorf("Wrong strikethrough formatting: %v", markup) } @@ -387,14 +387,14 @@ func TestFormattingXEP0393Strikethrough(t *testing.T) { Length: 3, Type: &client.TextEntityTypeStrikethrough{}, }, - }, EntityToXEP0393) + }, MarkupModeXEP0393) 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 +409,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 +424,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 +439,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 +459,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 +472,111 @@ 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) + 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) + 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) + 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) + 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) + 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) + if markup != "> **_hruck\np~~uc~~k_\nshuck**\n\ntext" { + t.Errorf("Wrong blockquote formatting: %v", markup) + } +} diff --git a/telegram/handlers.go b/telegram/handlers.go index c7185f5..1ce1636 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -55,6 +55,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, + strconv.FormatInt(chatId, 10), + c.xmpp, + typ, + xmppId, + ) +} + func (c *Client) updateHandler() { listener := c.client.GetListener() defer listener.Close() @@ -141,6 +166,12 @@ func (c *Client) updateHandler() { uhOh() } c.updateChatTitle(typedUpdate) + case client.TypeUpdateChatReadOutbox: + typedUpdate, ok := update.(*client.UpdateChatReadOutbox) + if !ok { + uhOh() + } + c.updateChatReadOutbox(typedUpdate) default: // log only handled types continue @@ -211,6 +242,9 @@ func (c *Client) updateChatLastMessage(update *client.UpdateChatLastMessage) { // message received func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { chatId := update.Message.ChatId + if c.Session.IsChatIgnored(chatId) { + return + } // guarantee sequential message delivering per chat lock := c.getChatMessageLock(chatId) @@ -218,10 +252,25 @@ func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { lock.Lock() defer lock.Unlock() + c.updateLastMessageHash(update.Message.ChatId, update.Message.Id, update.Message.Content) + + 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 } @@ -230,23 +279,31 @@ func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { }).Warn("New message from chat") c.ProcessIncomingMessage(chatId, update.Message) - - c.updateLastMessageHash(update.Message.ChatId, update.Message.Id, update.Message.Content) }() } // message content updated func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { + if c.Session.IsChatIgnored(update.ChatId) { + return + } + markupFunction := c.getFormatter() defer c.updateLastMessageHash(update.ChatId, update.MessageId, update.NewContent) + log.Debugf("newContent: %#v", update.NewContent) + + lock := c.getChatMessageLock(update.ChatId) + lock.Lock() + lock.Unlock() c.SendMessageLock.Lock() c.SendMessageLock.Unlock() - xmppId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, update.ChatId, update.MessageId) + + xmppId, xmppIdErr := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, update.ChatId, update.MessageId) var ignoredResource string - if err == nil { - ignoredResource = c.popFromOutbox(xmppId) + if xmppIdErr == nil { + ignoredResource = c.popFromEditOutbox(xmppId) } else { log.Infof("Couldn't retrieve XMPP message ids for %v, an echo may happen", update.MessageId) } @@ -260,19 +317,62 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { if update.NewContent.MessageContentType() == client.TypeMessageText && c.hasLastMessageHashChanged(update.ChatId, update.MessageId, update.NewContent) { textContent := update.NewContent.(*client.MessageText) - var editChar string - if c.Session.AsciiArrows { - editChar = "e " - } else { - editChar = "✎ " + log.Debugf("textContent: %#v", textContent.Text) + + var replaceId string + sId := strconv.FormatInt(update.MessageId, 10) + var isCarbon bool + + // 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) + } } - text := editChar + fmt.Sprintf("%v | %s", update.MessageId, formatter.Format( + + message, messageErr := c.client.GetMessage(&client.GetMessageRequest{ + ChatId: update.ChatId, + MessageId: update.MessageId, + }) + var prefix string + if messageErr == nil { + isCarbon = c.isCarbonsEnabled() && message.IsOutgoing + // 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's a carbon", update.ChatId, update.MessageId) + } + + var text strings.Builder + + if replaceId == "" { + 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, )) + + sChatId := strconv.FormatInt(update.ChatId, 10) for _, jid := range jids { - gateway.SendMessage(jid, strconv.FormatInt(update.ChatId, 10), text, "e"+strconv.FormatInt(update.MessageId, 10), c.xmpp, nil, 0, false, false, "") + gateway.SendMessage(jid, sChatId, text.String(), "e"+sId, c.xmpp, nil, 0, replaceId, isCarbon, false, false, "") } } } @@ -280,6 +380,16 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { // message(s) deleted func (c *Client) updateDeleteMessages(update *client.UpdateDeleteMessages) { if update.IsPermanent { + if c.Session.IsChatIgnored(update.ChatId) { + return + } + if c.Session.IgnoreGroupDeletions { + chatType, _, chatTypeErr := c.GetChatType(update.ChatId) + if chatTypeErr == nil && (chatType == ChatTypeBasicGroup || chatType == ChatTypeSupergroup) { + return + } + } + var deleteChar string if c.Session.AsciiArrows { deleteChar = "X " @@ -301,19 +411,25 @@ func (c *Client) updateAuthorizationState(update *client.UpdateAuthorizationStat } } -// clean uploaded files func (c *Client) updateMessageSendSucceeded(update *client.UpdateMessageSendSucceeded) { + // 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.updateLastMessageHash(update.Message.ChatId, update.Message.Id, update.Message.Content) + + 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) { + // clean uploaded files file, _ := c.contentToFile(update.Message.Content) if file != nil && file.Local != nil { c.cleanTempFile(file.Local.Path) @@ -339,3 +455,7 @@ func (c *Client) updateChatTitle(update *client.UpdateChatTitle) { chat.Title = update.Title } } + +func (c *Client) updateChatReadOutbox(update *client.UpdateChatReadOutbox) { + c.sendMarker(update.ChatId, update.LastReadOutboxMessageId, gateway.MarkerTypeDisplayed) +} diff --git a/telegram/loginwizard.go b/telegram/loginwizard.go new file mode 100644 index 0000000..0e5cc9e --- /dev/null +++ b/telegram/loginwizard.go @@ -0,0 +1,82 @@ +package telegram + +import ( + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// StartLoginWizard initiates a loginWizard object +func (c *Client) StartLoginWizard(inCommand bool) { + if c.loginWizard == nil { + c.loginWizard = &loginWizardMetadata{ + nextStage: make(chan string, 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() string { + 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 { + if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber || + c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing || + c.Session.Login == "" { + return "login" + } + switch c.lastAuthorizationStateType { + case client.TypeAuthorizationStateWaitCode: + return "code" + case client.TypeAuthorizationStateWaitPassword: + return "password" + } + } + } + + return "" +} + +func (c *Client) wizardStageOrPrompt(stage, message string) { + c.locks.loginWizardWriteLock.Lock() + if c.loginWizard == nil { + c.locks.loginWizardWriteLock.Unlock() + if message != "" { + gateway.SendServiceMessage(c.jid, message, c.xmpp) + } + } else { + if !c.loginWizard.chanBusy { + log.Debugf("writing wizard stage %v", stage) + c.loginWizard.nextStage <- stage + } else { + log.Warn("Skipping stage %v, wizard cannot keep up", stage) + } + c.loginWizard.chanBusy = true + c.locks.loginWizardWriteLock.Unlock() + } +} diff --git a/telegram/utils.go b/telegram/utils.go index a730113..a3233c7 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -1,7 +1,9 @@ package telegram import ( + "bytes" "crypto/sha1" + "encoding/base64" "encoding/binary" "fmt" "github.com/pkg/errors" @@ -16,6 +18,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "dev.narayana.im/narayana/telegabber/telegram/cache" "dev.narayana.im/narayana/telegabber/telegram/formatter" @@ -36,18 +39,58 @@ type VCardInfo struct { 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 + +// ChatType is an enum of chat types, roughly corresponding to TDLib's one but better +type ChatType int const ( - ChatTypeOther byte = iota - ChatTypePM - ChatTypeGroup + ChatTypeUnknown ChatType = iota + ChatTypePrivate + ChatTypeBasicGroup + ChatTypeSupergroup + ChatTypeSecret + ChatTypeChannel +) + +// MembersList is an enum of member list filters +type MembersList int + +const ( + MembersListMembers MembersList = iota + MembersListRestricted + MembersListBanned + MembersListBannedAndAdministrators ) // GetContactByUsername resolves username to user id retrieves user and chat information @@ -127,10 +170,10 @@ func (c *Client) GetContactByID(id int64, chat *client.Chat) (*client.Chat, *cli return chat, user, nil } -// GetChatType checks if a chat is PM or group -func (c *Client) GetChatType(id int64) (byte, error) { +// GetChatType obtains chat type from its information +func (c *Client) GetChatType(id int64) (ChatType, *client.Chat, error) { if !c.Online() || id == 0 { - return ChatTypeOther, errOffline + return ChatTypeUnknown, nil, errOffline } var err error @@ -141,20 +184,75 @@ func (c *Client) GetChatType(id int64) (byte, error) { ChatId: id, }) if err != nil { - return ChatTypeOther, err + return ChatTypeUnknown, nil, err } c.cache.SetChat(id, chat) } chatType := chat.Type.ChatTypeType() - if chatType == client.TypeChatTypePrivate || chatType == client.TypeChatTypeSecret { - return ChatTypePM, nil + if chatType == client.TypeChatTypePrivate { + return ChatTypePrivate, chat, nil + } else if chatType == client.TypeChatTypeBasicGroup { + return ChatTypeBasicGroup, chat, nil + } else if chatType == client.TypeChatTypeSupergroup { + supergroup, _ := chat.Type.(*client.ChatTypeSupergroup) + if supergroup.IsChannel { + return ChatTypeChannel, chat, nil + } + return ChatTypeSupergroup, chat, nil + } else if chatType == client.TypeChatTypeSecret { + return ChatTypeSecret, chat, nil } - if c.IsGroup(chat) { - return ChatTypeGroup, nil + + return ChatTypeUnknown, chat, errors.New("Unknown chat type") +} + +// IsPM checks if a chat is PM +func (c *Client) IsPM(id int64) (bool, *client.Chat, error) { + typ, chat, err := c.GetChatType(id) + if err != nil { + return false, chat, err } - return ChatTypeOther, nil + + if typ == ChatTypePrivate || typ == ChatTypeSecret { + return true, chat, nil + } + return false, chat, nil +} + +// IsBot checks if a chat is a bot +func (c *Client) IsBot(id int64) (bool, error) { + _, user, err := c.GetContactByID(id, nil) + 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) { @@ -211,6 +309,85 @@ 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 + } + + 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] = &HashedAvatar{ + Hash: "", + File: 0, + } + c.AvatarHashesLock.Unlock() +} + +// GetPhotoSha1AndSize obtains data for PEP +func (c *Client) GetPhotoSha1AndSize(photo *client.File, chatId int64) (string, int64) { + sha1 := c.GetPhotoSha1(photo, chatId) + + size := photo.Size + if size == 0 { + size = photo.ExpectedSize + } + + return sha1, 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] = &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) +} + // ProcessStatusUpdate sets contact status func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, oldArgs ...args.V) error { if !c.Online() { @@ -232,20 +409,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o var photo string if chat != nil && chat.Photo != nil { - file, path, err := c.ForceOpenFile(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 @@ -285,22 +449,17 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o c.cache.SetStatus(chatID, cacheShow, 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)), } + newArgs = gateway.SPAppendFrom(newArgs, chatID) if presenceType != "" { newArgs = append(newArgs, gateway.SPType(presenceType)) } - return gateway.SendPresence( - c.xmpp, - c.jid, - newArgs..., - ) + return c.sendPresence(newArgs...) } // JoinMUC saves MUC join fact and sends initialization data @@ -419,7 +578,7 @@ func (c *Client) sendMUCSubject(chatID int64, resource string) { if err == nil { gateway.SendSubjectMessage( toJid, - mucJid + "/" + c.GetMUCNickname(c.GetSenderId(pin)), + mucJid + "/" + c.GetMUCNickname(c.getMessageSenderId(pin)), c.messageToText(pin, false), strconv.FormatInt(pin.Id, 10), c.xmpp, @@ -432,7 +591,7 @@ func (c *Client) sendMUCSubject(chatID int64, resource string) { // GetMUCNickname generates a unique nickname for a MUC member func (c *Client) GetMUCNickname(chatID int64) string { - return c.formatContact(chatID) + return c.FormatContact(chatID) } func (c *Client) updateMUCsNickname(memberID int64, newNickname string) { @@ -511,7 +670,8 @@ func (c *Client) GetMyMUCNickname(chatID int64) (string, bool) { return member.Nickname, true } -func (c *Client) formatContact(chatID int64) string { +// FormatContact retrieves a complete "full name (@usernames)" string for display +func (c *Client) FormatContact(chatID int64) string { if chatID == 0 { return "" } @@ -544,44 +704,97 @@ func (c *Client) formatContact(chatID int64) string { } // GetSenderId extracts a sender id from a message -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 - } +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, + } +} - replyId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, message.ChatId, message.ReplyToMessageId) - if err != nil { - replyId = 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(), + ) + // make the whole quote fit one line + text = strings.ReplaceAll(text, "\n", " ") } - reply = &gateway.Reply{ - Author: fmt.Sprintf("%v@%s", c.GetSenderId(replyMsg), gateway.Jid.Full()), - Id: replyId, + 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, + } } } @@ -604,9 +817,16 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess return "" } + return c.formatMessageContent(preview, c.messageToStub(message, preview, "")) +} + +func (c *Client) formatMessageContent(preview bool, message *messageStub) 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)) + } + str.WriteString(fmt.Sprintf("%s | ", message.Sender)) // add date if !preview { str.WriteString( @@ -617,10 +837,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) @@ -637,33 +854,33 @@ 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) { @@ -809,41 +1026,45 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { return "" } - markupFunction := c.getFormatter() - 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, 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 { @@ -855,86 +1076,86 @@ 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, ) } 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, ) } 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, ) } 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, ) } 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, ) } 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, ) } 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, ) } case client.TypeMessageContact: - contact, _ := message.Content.(*client.MessageContact) + contact, _ := content.(*client.MessageContact) if preview { return contact.Contact.FirstName + " " + contact.Contact.LastName } else { @@ -952,10 +1173,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 @@ -981,8 +1202,8 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { return strings.Join(rows, "\n") } case client.TypeMessageChatSetMessageAutoDeleteTime: - ttl, _ := message.Content.(*client.MessageChatSetMessageAutoDeleteTime) - name := c.formatContact(ttl.FromUserId) + ttl, _ := content.(*client.MessageChatSetMessageAutoDeleteTime) + name := c.FormatContact(ttl.FromUserId) if name == "" { if ttl.MessageAutoDeleteTime == 0 { return "The self-destruct timer was disabled" @@ -998,7 +1219,7 @@ func (c *Client) messageToText(message *client.Message, preview bool) string { } } - 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) { @@ -1067,21 +1288,23 @@ 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) { - chatType, err := c.GetChatType(message.ChatId) +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) if err != nil { log.Errorf("Could not determine chat type: %v", err) } - isCarbonsEnabled := gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons // with carbons, hide for all messages in PM and only for outgoing in group chats - hideSender := (isCarbonsEnabled && (message.IsOutgoing || chatType == ChatTypePM)) || (c.Session.MUC && chatType == ChatTypeGroup) + hideSender := c.isCarbonsEnabled() && (message.IsOutgoing || isPM) || (c.Session.MUC && c.IsGroup(chat)) - var replyStart, replyEnd int prefix := []string{} // message direction var directionChar string @@ -1100,7 +1323,7 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string, } } } - if (chatType != ChatTypePM && !c.Session.MUC) || !c.Session.HideIds { + if (!isPM && !c.Session.MUC) || !c.Session.HideIds { prefix = append(prefix, directionChar+strconv.FormatInt(message.Id, 10)) } // show sender in group chats @@ -1110,20 +1333,39 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string, 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 + + if len(prefix) > 0 { + replyStart = c.countCharsInLines(&prefix) + (len(prefix)-1)*len(messageHeaderSeparator) + } + + replyLine := "reply: " + c.formatMessageContent(preview, tgReply) + prefix = append(prefix, replyLine) + + replyEnd = replyStart + utf8.RuneCountInString(replyLine) + if len(prefix) > 0 { + 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 != "" { @@ -1134,7 +1376,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 { @@ -1153,6 +1395,17 @@ func (c *Client) ensureDownloadFile(file *client.File) *client.File { return file } +// \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 +} + // ProcessIncomingMessage is a legacy wrapper for SendMessageToGateway aiming only PM messages func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { c.SendMessageToGateway(chatId, message, "", false, "", []string{}) @@ -1165,21 +1418,21 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id var isGroupchat bool var originalFrom string if len(groupChatTos) == 0 { - isCarbon = gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons && message.IsOutgoing + isCarbon = c.isCarbonsEnabled() && message.IsOutgoing jids = c.getCarbonFullJids(isCarbon, "") } else { isGroupchat = true jids = groupChatTos - senderId := c.GetSenderId(message) + senderId := c.getMessageSenderId(message) if senderId != 0 { originalFrom = strconv.FormatInt(senderId, 10) + "@" + gateway.Jid.Full() } } var text, oob, auxText string - - reply, replyMsg := c.getMessageReply(message) + var reply *gateway.Reply + var replyObtained bool content := message.Content if content != nil && content.MessageContentType() == client.TypeMessageChatChangePhoto { @@ -1190,6 +1443,24 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id c.cache.SetChat(chatId, chat) go c.ProcessStatusUpdate(chatId, "", "", gateway.SPImmed(true)) text = "" + + if chat.Photo == nil { + c.SetEmptyAvatarHash(chatId) + } else { + sha1, size := c.GetPhotoSha1AndSize(chat.Photo.Small, chatId) + + for resource := range c.resourcesRange() { + features, ok := c.XmppClientFeatures[resource] + if ok && features != nil { + for _, feature := range *features { + if feature == gateway.NodeAvatarMetadataNotify { + go gateway.SendPubSubAvatarNotification(c.xmpp, c.jid+"/"+resource, chatId, sha1, size) + break + } + } + } + } + } } } else { text = c.messageToText(message, false) @@ -1206,45 +1477,51 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id 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 { + isPM, _, err := c.IsPM(chatId) + if err == nil { + ignorePrefix = isPM && 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 var sId string @@ -1267,11 +1544,21 @@ func (c *Client) SendMessageToGateway(chatId int64, message *client.Message, id } for _, jid := range jids { - gateway.SendMessageWithOOB(jid, from, text, sId, c.xmpp, reply, timestamp, oob, isCarbon, isGroupchat, originalFrom) + gateway.SendMessageWithOOB(jid, from, text, sId, c.xmpp, reply, timestamp, oob, "", isCarbon, isGroupchat, c.Session.Receipts, originalFrom) if auxText != "" { - gateway.SendMessage(jid, from, auxText, sId, c.xmpp, reply, timestamp, isCarbon, isGroupchat, originalFrom) + gateway.SendMessage(jid, from, auxText, sId, c.xmpp, reply, timestamp, "", isCarbon, isGroupchat, c.Session.Receipts, originalFrom) } } + c.UpdateLastChatMessageId(chatId, sId) +} + +// 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 @@ -1280,15 +1567,15 @@ func (c *Client) PrepareOutgoingMessageContent(text string) client.InputMessageC } // ProcessOutgoingMessage executes commands or sends messages to mapped chats, returns message id -func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid string, replyId int64, replaceId int64, isGroupchat bool) *client.Message { +func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid string, replyId int64, replaceId int64, isGroupchat, raw bool) *client.Message { if !c.Online() { // we're offline return nil } - if replaceId == 0 && (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 != "" { c.returnMessage(returnJid, chatID, response, 0, isGroupchat) } @@ -1376,7 +1663,7 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str tgMessage, err := c.client.SendMessage(&client.SendMessageRequest{ ChatId: chatID, - ReplyToMessageId: reply, + ReplyTo: &client.InputMessageReplyToMessage{MessageId: reply}, InputMessageContent: content, }) if err != nil { @@ -1427,6 +1714,11 @@ func (c *Client) prepareOutgoingMessageContent(text string, file *client.InputFi return content } +// ChatsKeys proxies the following function from unexported cache +func (c *Client) ChatsKeys() []int64 { + return c.cache.ChatsKeys() +} + // StatusesRange proxies the following function from unexported cache func (c *Client) StatusesRange() chan *cache.Status { return c.cache.StatusesRange() @@ -1485,7 +1777,7 @@ func (c *Client) roster(resource string) { c.ProcessStatusUpdate(chat, "", "") } - 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) } @@ -1531,6 +1823,13 @@ func (c *Client) getNLastMessages(chatID int64, limit int32) ([]*client.Message, return messages, nil } +// 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{ @@ -1666,15 +1965,16 @@ func (c *Client) GetGroupChats() []*client.Chat { // 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 } // 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")) + args := gateway.SimplePresence(id, "subscribe") if chat == nil { chat, _, _ = c.GetContactByID(id, nil) @@ -1689,11 +1989,11 @@ func (c *Client) subscribeToID(id int64, chat *client.Chat) { gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), 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) { @@ -1746,9 +2046,9 @@ func (c *Client) UpdateChatNicknames() { } newArgs := []args.V{ - gateway.SPFrom(strconv.FormatInt(id, 10)), gateway.SPNickname(chat.Title), } + newArgs = gateway.SPAppendFrom(newArgs, id) cachedStatus, ok := c.cache.GetStatus(id) if ok { @@ -1759,17 +2059,34 @@ func (c *Client) UpdateChatNicknames() { } } - gateway.SendPresence( - c.xmpp, - c.jid, - newArgs..., - ) + c.sendPresence(newArgs...) gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), 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() @@ -1778,14 +2095,12 @@ func (c *Client) AddToOutbox(xmppId, resource string) { c.outbox[xmppId] = resource } -func (c *Client) popFromOutbox(xmppId string) string { +func (c *Client) getFromOutbox(xmppId string) string { c.locks.outboxLock.Lock() defer c.locks.outboxLock.Unlock() resource, ok := c.outbox[xmppId] - if ok { - delete(c.outbox, xmppId) - } else { + if !ok { log.Warnf("No %v xmppId in outbox", xmppId) } return resource @@ -1860,8 +2175,23 @@ func (c *Client) hasLastMessageHashChanged(chatId, messageId int64, content clie return !ok || oldHash != newHash } -func (c *Client) getFormatter() func(*client.TextEntity) (*formatter.Insertion, *formatter.Insertion) { - return formatter.EntityToXEP0393 +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 { @@ -1901,7 +2231,7 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p message := messages[i] if plain { - reply, _ := c.getMessageReply(message) + reply, _ := c.getMessageReply(message, false, true) gateway.SendMessage( c.jid, @@ -1911,6 +2241,8 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p c.xmpp, reply, 0, + "", + false, false, false, "", @@ -1921,9 +2253,82 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message, p message, "", true, - mucJid + "/" + c.GetMUCNickname(c.GetSenderId(message)), + mucJid + "/" + c.GetMUCNickname(c.getMessageSenderId(message)), []string{toJid}, ) } } } + +// 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) { + 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{}} + } + + limit := int32(9999) + if limited { + limit = 20 + + chat, _, err := c.GetContactByID(chatID, nil) + if err != nil { + return nil, err + } else if chat == nil { + return nil, errors.New("Chat not found") + } + + 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") + } + } + + 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 + } + members = append(members, chatMembers.Members...) + } + return members, nil +} diff --git a/telegram/utils_test.go b/telegram/utils_test.go index e54ddb5..e89077d 100644 --- a/telegram/utils_test.go +++ b/telegram/utils_test.go @@ -431,20 +431,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) } } @@ -452,20 +449,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) } } @@ -473,20 +467,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) } } @@ -495,15 +486,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) } } @@ -511,52 +499,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) } @@ -568,7 +605,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) } @@ -580,7 +617,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/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index 9007f6b..8bf3302 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -3,6 +3,7 @@ package gateway import ( "encoding/xml" "github.com/pkg/errors" + "strconv" "strings" "sync" "time" @@ -10,6 +11,7 @@ import ( "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" "gosrc.io/xmpp" @@ -23,7 +25,24 @@ type Reply struct { End uint64 } +type MarkerType byte + +const ( + MarkerTypeReceived MarkerType = iota + MarkerTypeDisplayed +) + +type marker struct { + Type MarkerType + Id 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" // Queue stores presences to send later var Queue = make(map[string]*stanza.Presence) @@ -43,41 +62,57 @@ var DirtySessions = false var MessageOutgoingPermissionVersion = 0 // SendMessage creates and sends a message stanza -func SendMessage(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, isCarbon, isGroupchat bool, originalFrom string) { - sendMessageWrapper(to, from, body, "", "", id, component, reply, timestamp, "", isCarbon, isGroupchat, false, originalFrom, 0) +func SendMessage(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, replaceId string, isCarbon, isGroupchat, requestReceipt bool, originalFrom string) { + sendMessageWrapper(to, from, body, "", "", id, component, reply, nil, timestamp, "", replaceId, isCarbon, isGroupchat, false, requestReceipt, originalFrom, 0) } // SendServiceMessage creates and sends a simple message stanza from transport func SendServiceMessage(to, body string, component *xmpp.Component) { - sendMessageWrapper(to, "", body, "", "", "", component, nil, 0, "", false, false, false, "", 0) + 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) } // SendTextMessage creates and sends a simple message stanza func SendTextMessage(to, from, body string, component *xmpp.Component) { - sendMessageWrapper(to, from, body, "", "", "", component, nil, 0, "", false, false, false, "", 0) + var id string + if uuid, err := uuid.NewRandom(); err == nil { + id = uuid.String() + } + sendMessageWrapper(to, from, body, "", "", id, component, nil, nil, 0, "", "", false, false, false, false, "", 0) } // 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, 0, "", false, isGroupchat, false, "", code) + sendMessageWrapper(to, from, "", "", text, "", component, nil, nil, 0, "", "", false, isGroupchat, false, false, "", code) } // 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, 0, "", false, isGroupchat, false, "", code) + sendMessageWrapper(to, from, body, "", errorText, id, component, nil, nil, 0, "", "", false, isGroupchat, false, false, "", code) } // SendMessageWithOOB creates and sends a message stanza with OOB URL -func SendMessageWithOOB(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, oob string, isCarbon, isGroupchat bool, originalFrom string) { - sendMessageWrapper(to, from, body, "", "", id, component, reply, timestamp, oob, isCarbon, isGroupchat, false, originalFrom, 0) +func SendMessageWithOOB(to, from, body, id string, component *xmpp.Component, reply *Reply, timestamp int64, oob, replaceId string, isCarbon, isGroupchat, requestReceipt bool, originalFrom string) { + sendMessageWrapper(to, from, body, "", "", id, component, reply, nil, timestamp, oob, replaceId, isCarbon, isGroupchat, false, requestReceipt, originalFrom, 0) } // 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, timestamp, "", false, true, true, "", 0) + sendMessageWrapper(to, from, "", subject, "", id, component, nil, nil, timestamp, "", "", false, true, true, false, "", 0) } -func sendMessageWrapper(to, from, body, subject, errorText, id string, component *xmpp.Component, reply *Reply, timestamp int64, oob string, isCarbon, isGroupchat, forceSubject bool, originalFrom string, errorCode int) { +// 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) +} + +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) { toJid, err := stanza.NewJid(to) if err != nil { log.WithFields(log.Fields{ @@ -207,6 +242,20 @@ func sendMessageWrapper(to, from, body, subject, errorText, id string, component 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}) + } if isCarbon { carbonMessage := extensions.ClientMessage{ @@ -466,6 +515,20 @@ func SendPresence(component *xmpp.Component, to string, args ...args.V) error { return nil } +// SPAppendFrom appends numeric from and resource to varargs +func SPAppendFrom(oldArgs []args.V, id int64) []args.V { + newArgs := append(oldArgs, SPFrom(strconv.FormatInt(id, 10))) + 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) @@ -510,3 +573,46 @@ func affilationToRole(affilation string) string { } return "none" } + +// SendPubSubAvatarNotification encourages clients to fetch an avatar +func SendPubSubAvatarNotification(component *xmpp.Component, jid string, chatId int64, 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{ + "chatId": chatId, + }).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: strconv.FormatInt(chatId, 10) + "@" + Jid.Bare(), + To: jid, + Type: stanza.MessageTypeHeadline, + }, + Extensions: []stanza.MsgExtension{event}, + } + + _ = ResumableSend(component, message) +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index eea14be..5f3f9ff 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -1,12 +1,10 @@ package xmpp import ( - "bytes" - "encoding/base64" "encoding/xml" "fmt" "github.com/pkg/errors" - "io" + "sort" "strconv" "strings" @@ -15,6 +13,7 @@ import ( "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" @@ -25,13 +24,6 @@ const ( TypeVCardTemp byte = iota TypeVCard4 ) -const NodeVCard4 string = "urn:xmpp:vcard4" - -type discoType int -const ( - discoTypeInfo discoType = iota - discoTypeItems -) func logPacketType(p stanza.Packet) { log.Warnf("Ignoring packet: %T\n", p) @@ -46,27 +38,31 @@ 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 handleGetDisco(discoTypeInfo, s, iq) + go handleGetDiscoInfo(s, iq, discoInfo) return } - _, ok = iq.Payload.(*stanza.DiscoItems) + discoItems, ok := iq.Payload.(*stanza.DiscoItems) if ok { - go handleGetDisco(discoTypeItems, s, iq) + go handleGetDiscoItems(s, iq, discoItems) return } _, ok = iq.Payload.(*extensions.QueryRegister) @@ -74,12 +70,23 @@ func HandleIq(s xmpp.Sender, p stanza.Packet) { go handleGetQueryRegister(s, iq) return } - } else if iq.Type == "set" { + } else if iq.Type == stanza.IQTypeSet { query, ok := iq.Payload.(*extensions.QueryRegister) if ok { go handleSetQueryRegister(s, iq, query) return } + command, ok := iq.Payload.(*stanza.Command) + if ok { + go handleSetQueryCommand(s, iq, command) + return + } + } else if iq.Type == stanza.IQTypeResult { + discoInfo, ok := iq.Payload.(*stanza.DiscoInfo) + if ok { + go handleClientFeatures(s, iq, discoInfo) + return + } } } @@ -216,7 +223,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { session.SendMessageLock.Lock() defer session.SendMessageLock.Unlock() - tgMessage := session.ProcessOutgoingMessage(toID, text, msg.From, replyId, replaceId, isGroupchat) + tgMessage := session.ProcessOutgoingMessage(toID, text, msg.From, replyId, replaceId, isGroupchat, false) if tgMessage != nil { if replaceId != 0 { // not needed (is it persistent among clients though?) @@ -224,22 +231,25 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { if err != nil { log.Errorf("Failed to replace id %v with %v %v", replace.Id, msg.Id, tgMessageId) } */ - session.AddToOutbox(replace.Id, resource) + session.AddToEditOutbox(replace.Id, resource) } else { err = gateway.IdsDB.Set(session.Session.Login, bare, toID, tgMessage.Id, msg.Id) - if err != nil { + 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 == "" { + if isGroupchat && toJid.Resource == "" && tgMessage.SenderId != nil { session.SendMessageToGateway( toID, tgMessage, msg.Id, false, - msg.To + "/" + session.GetMUCNickname(session.GetSenderId(tgMessage)), + msg.To + "/" + session.GetMUCNickname(session.GetSenderId(tgMessage.SenderId)), []string{msg.From}, ) } @@ -257,7 +267,7 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { } 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) } @@ -289,6 +299,30 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) { 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 := toToID(msg.To) + if !ok { + return + } + msgId, err := strconv.ParseInt(displayed.ID, 10, 64) + if err == nil { + session.MarkAsRead(toID, msgId) + } + return + } } if msg.Type == "error" { @@ -430,6 +464,7 @@ func handlePresence(s xmpp.Sender, p stanza.Presence) { newArgs..., ) } + probeClientFeatures(p.From, component) session.UpdateChatNicknames() } }() @@ -621,7 +656,130 @@ func handleGetVcardIq(s xmpp.Sender, iq *stanza.IQ, typ byte) { _ = gateway.ResumableSend(component, &answer) } -func handleGetDisco(dt discoType, s xmpp.Sender, iq *stanza.IQ) { +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 := 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, ok := session.AvatarHashes[chatId] + if !ok { + log.Info("Could not find avatar in cache, fetching immediately") + + chat, _, err := session.GetContactByID(chatId, nil) + if err != nil || chat == nil || chat.Photo == nil { + return + } + + file := chat.Photo.Small + + sha1 := session.GetPhotoSha1(file, chatId) + hashedAvatar = &telegram.HashedAvatar{ + Hash: sha1, + File: file.Id, + } + + session.AvatarHashesLock.Lock() + session.AvatarHashes[chatId] = hashedAvatar + session.AvatarHashesLock.Unlock() + } + + 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) (telegram.ChatType, error) { + toId, ok := toToID(to) + if ok { + bare, _, ok := gateway.SplitJID(from) + if ok { + session, ok := sessions[bare] + if ok { + chatType, _, chatTypeErr := session.GetChatType(toId) + return chatType, chatTypeErr + } + } + } + + return telegram.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, @@ -631,17 +789,29 @@ func handleGetDisco(dt discoType, s xmpp.Sender, iq *stanza.IQ) { }) if err != nil { log.Errorf("Failed to create answer IQ: %v", err) - return + return nil, nil, false } - if dt == discoTypeInfo { - disco := answer.DiscoInfo() - toID, toOk := toToID(iq.To) - if !toOk { - disco.AddIdentity("Telegram Gateway", "gateway", "telegram") - disco.AddFeatures("jabber:iq:register") - } + 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) + + disco := answer.DiscoInfo() + toID, toOk := toToID(iq.To) + + if di.Node == "" { var isMuc bool bare, _, fromOk := gateway.SplitJID(iq.From) if fromOk { @@ -692,20 +862,103 @@ func handleGetDisco(dt discoType, s xmpp.Sender, iq *stanza.IQ) { } } } - if toOk && !isMuc { - disco.AddIdentity("", "account", "registered") - } - answer.Payload = disco - } else if dt == discoTypeItems { - disco := answer.DiscoItems() - _, ok := toToID(iq.To) - if !ok { - bare, _, ok := gateway.SplitJID(iq.From) + if toOk { + if !isMuc { + 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(gateway.NSCommand) + } else { + chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) + + var cmdType telegram.CommandType + if toOk { + cmdType = telegram.CommandTypeChat + } else { + cmdType = telegram.CommandTypeTransport + } + + for name, command := range telegram.GetCommands(cmdType) { + if di.Node == name { + if chatTypeErr == nil && !telegram.IsCommandForChatType(command, chatType) { + break + } + disco.AddIdentity(telegram.CommandToHelpString(name, command), "automation", "command-node") + disco.AddFeatures(gateway.NSCommand, "jabber:x:data") + break + } + } + } + answer.Payload = disco +} + +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 := toToID(iq.To) + + disco := answer.DiscoItems() + + if di.Node == gateway.NSCommand { + chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) + + var cmdType telegram.CommandType + if toOk { + cmdType = telegram.CommandTypeChat + } else { + cmdType = telegram.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) + if err == nil && isBot { + disco.AddItem(iq.To, "botmenu", "Bot Menu") + } + } + } + } + + if !(toOk || isOnline) { + disco.AddItem(iq.To, "loginwizard", "Login Wizard") + } + + commands := telegram.GetCommands(cmdType) + for _, name := range telegram.SortedCommandKeys(commands) { + command := commands[name] + if chatTypeErr == nil && !telegram.IsCommandForChatType(command, chatType) { + continue + } + if !isOnline && command.LoginOnly { + continue + } + disco.AddItem(iq.To, name, telegram.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, ok := sessions[bare] - if ok && session.Session.MUC { + session, sessionOk := sessions[bare] + if sessionOk && session.Session.MUC { bareJid := gateway.Jid.Bare() disco.AddItem(bareJid, "", "Telegram group chats") for _, chat := range session.GetGroupChats() { @@ -715,39 +968,18 @@ func handleGetDisco(dt discoType, s xmpp.Sender, iq *stanza.IQ) { } } } - - answer.Payload = disco } + answer.Payload = disco log.Debugf("%#v", answer) - - component, ok := s.(*xmpp.Component) - if !ok { - log.Error("Not a component") - return - } - - _ = gateway.ResumableSend(component, answer) } func handleGetQueryRegister(s xmpp.Sender, iq *stanza.IQ) { - component, ok := s.(*xmpp.Component) + component, answer, ok := iqResultStub(s, iq) if !ok { - log.Error("Not a component") - return - } - - 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 } + defer gateway.ResumableSend(component, answer) var login string bare, _, ok := gateway.SplitJID(iq.From) @@ -774,32 +1006,16 @@ func handleGetQueryRegister(s xmpp.Sender, iq *stanza.IQ) { log.Debugf("%#v", query) - _ = gateway.ResumableSend(component, answer) - if login == "" { gateway.SubscribeToTransport(component, iq.From) } } func handleSetQueryRegister(s xmpp.Sender, iq *stanza.IQ, query *extensions.QueryRegister) { - component, ok := s.(*xmpp.Component) + component, answer, ok := iqResultStub(s, iq) if !ok { - log.Error("Not a component") return } - - 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 - } - defer gateway.ResumableSend(component, answer) if query.Remove != nil { @@ -849,6 +1065,391 @@ func handleSetQueryRegister(s xmpp.Sender, iq *stanza.IQ, query *extensions.Quer } } +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 := toToID(iq.To) + + var cmdString string + var cmdType telegram.CommandType + var form *stanza.Form + for _, ce := range command.CommandElements { + fo, formOk := ce.(*stanza.Form) + if formOk { + form = fo + break + } + } + if toOk { + cmdType = telegram.CommandTypeChat + } else { + cmdType = telegram.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 { + fieldValue := field.ValuesList[0] + + if gateway.MessageOutgoingPermissionVersion == 0 && field.Var == "carbons" && fieldValue == "true" { + warnString = "The server did not allow to enable carbons" + continue + } + + // 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. + if persistence.PropertyType(field.Var) == persistence.PropertyTypeBool { + if fieldValue == "0" { + fieldValue = "false" + } + if fieldValue == "1" { + fieldValue = "true" + } + } + + 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 + } + 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) + + 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 := telegram.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 iqAnswerSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code int) { answer.Type = stanza.IQTypeError answer.Payload = *payload @@ -911,6 +1512,94 @@ func presenceReplySetError(reply *stanza.Presence, code int) { } } +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 + } + + for _, chatId := range session.ChatsKeys() { + chat, _, err := session.GetContactByID(chatId, nil) + if err != nil || chat == nil { + continue + } + + if chat.Photo == nil { + session.SetEmptyAvatarHash(chatId) + continue + } + + sha1, size := session.GetPhotoSha1AndSize(chat.Photo.Small, chat.Id) + + gateway.SendPubSubAvatarNotification(component, jid, chat.Id, sha1, size) + } +} + func toToID(to string) (int64, bool) { toParts := strings.Split(to, "@") if len(toParts) < 2 { @@ -929,24 +1618,7 @@ func toToID(to string) (int64, bool) { func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *telegram.Client) stanza.IQPayload { var base64Photo string if info.Photo != nil { - file, path, err := session.ForceOpenFile(info.Photo, 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 { - base64Photo = 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()) - } + base64Photo = session.GetPhotoBase64(info.Photo) } if typ == TypeVCardTemp { @@ -1047,7 +1719,7 @@ func makeVCardPayload(typ byte, id string, info telegram.VCardInfo, session *tel pubsub := &stanza.PubSubGeneric{ Items: &stanza.Items{ - Node: NodeVCard4, + Node: gateway.NodeVCard4, List: []stanza.Item{ stanza.Item{ Id: id, diff --git a/xmpp/loginwizard.go b/xmpp/loginwizard.go new file mode 100644 index 0000000..d1b161a --- /dev/null +++ b/xmpp/loginwizard.go @@ -0,0 +1,101 @@ +package xmpp + +import ( + "fmt" + + "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) (payload *stanza.Command, cancelSend bool, returnSession *telegram.Client) { + payload = &stanza.Command{ + SessionId: "loginwizard", + Node: "loginwizard", + } + + session, ok := sessions[bare] + if ok { + returnSession = session + + if requestForm == nil { + session.StartLoginWizard(false) + cancelSend = true + } 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 + } + switch field.Var { + case "login", "code", "password": + default: + setCommandPayloadError(payload, "Unknown field") + return + } + + session.StartLoginWizard(true) + response, success := session.ProcessTransportCommand(fmt.Sprintf("/%v %v", field.Var, field.ValuesList[0]), 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 := "login" + if session != nil { + nextStage = session.GetLoginWizardNextStage() + } + log.Debugf("nextStage: %v", nextStage) + + if nextStage == "cancel" { + setCommandPayloadError(payload, "Cancelled") + session.StopLoginWizard() + } else if nextStage == "success" { + payload.Status = stanza.CommandStatusCompleted + session.StopLoginWizard() + } else { + required := "" + form := stanza.Form{ + Type: stanza.FormTypeForm, + Title: "Login Wizard", + Fields: []*stanza.Field{ + &stanza.Field{ + Var: nextStage, + Label: nextStage, + Required: &required, + }, + }, + } + payload.Status = stanza.CommandStatusExecuting + payload.CommandElements = append(payload.CommandElements, &form) + } + + answer.Payload = payload + + gateway.ResumableSend(component, answer) +}