Compare commits

...

32 commits
dev ... master

Author SHA1 Message Date
Bohdan Horbeshko ba8f4c08cf Attach prefix to OOB descriptions and omit empty ones only if sender is displayed by carbon 2024-06-01 16:45:21 -04:00
Bohdan Horbeshko af07773b07 Random IDs for service messages 2024-05-10 19:22:53 -04:00
Bohdan Horbeshko a74e2bcb7d Mute/unmute whole chats with no arguments 2024-05-05 13:16:38 -04:00
Bohdan Horbeshko a3f6d5f774 Support nativeedits for rawmessages=false 2024-04-27 00:31:21 -04:00
Bohdan Horbeshko 2459b14948 Version 1.9.2 2024-04-11 22:24:22 -04:00
Bohdan Horbeshko f15e44436b Use carbons for non-native edits too 2024-04-11 20:59:49 -04:00
Bohdan Horbeshko a36856b768 Fix filtering content updates for outgoing messages 2024-04-11 20:37:51 -04:00
Bohdan Horbeshko b499992148 Fix missing read markers in other XMPP clients than the message sender 2024-04-10 22:17:58 -04:00
Bohdan Horbeshko 144c5724ea Fix module cache in staging.Dockerfile 2024-04-09 19:09:47 -04:00
Bohdan Horbeshko 3e772be7a6 Add tdlib.Dockerfile 2024-04-09 19:08:37 -04:00
Bohdan Horbeshko 908bd76aac Add staging.Dockerfile 2024-03-29 07:39:10 -04:00
Bohdan Horbeshko 67c38823f2 Avoid broken state on a failed logout attempt 2024-03-29 07:35:06 -04:00
Bohdan Horbeshko f56e6ac187 Eliminate edit echos for outgoing messages 2024-01-31 09:27:18 -05:00
Bohdan Horbeshko 20e6d2558e Version 1.9.0 2024-01-29 05:00:42 -05:00
Bohdan Horbeshko 3a60a1cfaa Bump Makefile to TDLib commit with the logout fix 2024-01-29 04:50:57 -05:00
Bohdan Horbeshko ea004b7f7c Reflect Telegram edits natively by nativeedits option 2024-01-29 04:28:15 -05:00
Bohdan Horbeshko c141c4ad2b Fix markable 2024-01-27 06:47:12 -05:00
Bohdan Horbeshko 599cf16cdb Request and send to Telegram XEP-0333 displayed markers by "receipts" option 2024-01-27 06:13:45 -05:00
Bohdan Horbeshko 81fc3ea370 Also ack with XEP-0184 read receipts for outgoing messages 2024-01-27 03:25:17 -05:00
Bohdan Horbeshko e37c428c67 XEP-0333 read markers for outgoing messages 2024-01-26 21:02:47 -05:00
Bohdan Horbeshko b9b6ba14a4 Fix stuck logout 2024-01-24 18:54:25 -05:00
Bohdan Horbeshko b40ccf4a4d Fix presences sent with no resource 2024-01-24 18:52:40 -05:00
Bohdan Horbeshko 4532748c84 Support chosen quotes in replies and replies from other chats 2024-01-10 14:30:00 -05:00
Bohdan Horbeshko f2807779aa Fix ending braces for PreCode 2023-11-16 08:44:26 -05:00
Bohdan Horbeshko 705cfc1d49 gofmt 2023-11-16 08:06:21 -05:00
Bohdan Horbeshko dcb802358b Fix tests 2023-11-16 08:05:23 -05:00
Bohdan Horbeshko 6bd8379114 Support blockquotes in formatter 2023-11-15 19:38:45 -05:00
Bohdan Horbeshko 576acba0d1 Migrate to TDLib 1.8.21 2023-11-11 16:10:23 -05:00
Bohdan Horbeshko 67b8ad57f0 Fix reply length for hrunicode messages 2023-10-29 08:52:33 -04:00
Bohdan Horbeshko 282a6fc21b Hotfix: prevent lockup on login 2023-08-31 18:24:30 -04:00
Bohdan Horbeshko 4588170d1e Harden the authorizer access to prevent crashes 2023-08-31 17:26:35 -04:00
Bohdan Horbeshko aa561c5be6 Version 1.8.0 2023-08-28 10:20:50 -04:00
22 changed files with 1327 additions and 459 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ sessions/
session.dat session.dat
session.dat.new session.dat.new
release/ release/
tdlib/

View file

@ -29,7 +29,7 @@ WORKDIR /src
RUN make ${MAKEOPTS} RUN make ${MAKEOPTS}
FROM scratch AS telegabber 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"] ENTRYPOINT ["/usr/local/bin/telegabber"]
FROM scratch AS binaries FROM scratch AS binaries

View file

@ -1,12 +1,13 @@
.PHONY: all test .PHONY: all test
COMMIT := $(shell git rev-parse --short HEAD) COMMIT := $(shell git rev-parse --short HEAD)
TD_COMMIT := "8517026415e75a8eec567774072cbbbbb52376c1" TD_COMMIT := "5bbfc1cf5dab94f82e02f3430ded7241d4653551"
VERSION := "v1.8.0-dev" VERSION := "v1.9.6"
MAKEOPTS := "-j4" MAKEOPTS := "-j4"
all: all:
go build -ldflags "-X main.commit=${COMMIT}" -o telegabber mkdir -p release
go build -ldflags "-X main.commit=${COMMIT}" -o release/telegabber
test: test:
go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger go test -v ./config ./ ./telegram ./xmpp ./xmpp/gateway ./persistence ./telegram/formatter ./badger
@ -16,3 +17,9 @@ lint:
build_indocker: build_indocker:
docker build --build-arg "TD_COMMIT=${TD_COMMIT}" --build-arg "VERSION=${VERSION}" --build-arg "MAKEOPTS=${MAKEOPTS}" --output=release --target binaries . 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 .

2
go.mod
View file

@ -34,4 +34,4 @@ require (
) )
replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f replace gosrc.io/xmpp => dev.narayana.im/narayana/go-xmpp v0.0.0-20220524203317-306b4ff58e8f
replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20230730021136-47da33180615 replace github.com/zelenin/go-tdlib => dev.narayana.im/narayana/go-tdlib v0.0.0-20240124222245-b4c12addb061

4
go.sum
View file

@ -1,6 +1,10 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 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 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-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 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-20220524203317-306b4ff58e8f/go.mod h1:L3NFMqYOxyLz3JGmgFyWf7r9htE91zVGiK40oW4RwdY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View file

@ -3,6 +3,7 @@ package persistence
import ( import (
"github.com/pkg/errors" "github.com/pkg/errors"
"io/ioutil" "io/ioutil"
"sync"
"time" "time"
"dev.narayana.im/narayana/telegabber/yamldb" "dev.narayana.im/narayana/telegabber/yamldb"
@ -34,14 +35,18 @@ type SessionsMap struct {
// Session is a key-values subtree // Session is a key-values subtree
type Session struct { type Session struct {
Login string `yaml:":login"` Login string `yaml:":login"`
Timezone string `yaml:":timezone"` Timezone string `yaml:":timezone"`
KeepOnline bool `yaml:":keeponline"` KeepOnline bool `yaml:":keeponline"`
RawMessages bool `yaml:":rawmessages"` RawMessages bool `yaml:":rawmessages"`
AsciiArrows bool `yaml:":asciiarrows"` AsciiArrows bool `yaml:":asciiarrows"`
OOBMode bool `yaml:":oobmode"` OOBMode bool `yaml:":oobmode"`
Carbons bool `yaml:":carbons"` Carbons bool `yaml:":carbons"`
HideIds bool `yaml:":hideids"` HideIds bool `yaml:":hideids"`
Receipts bool `yaml:":receipts"`
NativeEdits bool `yaml:":nativeedits"`
IgnoredChats []int64 `yaml:":ignoredchats"`
ignoredChatsMap map[int64]bool `yaml:"-"`
} }
var configKeys = []string{ var configKeys = []string{
@ -52,17 +57,26 @@ var configKeys = []string{
"oobmode", "oobmode",
"carbons", "carbons",
"hideids", "hideids",
"receipts",
"nativeedits",
} }
var sessionDB *SessionsYamlDB var sessionDB *SessionsYamlDB
var sessionsLock sync.Mutex
// SessionMarshaller implementation for YamlDB // SessionMarshaller implementation for YamlDB
func SessionMarshaller() ([]byte, error) { func SessionMarshaller() ([]byte, error) {
cleanedMap := SessionsMap{} cleanedMap := SessionsMap{}
emptySessionsMap(&cleanedMap) emptySessionsMap(&cleanedMap)
sessionsLock.Lock()
defer sessionsLock.Unlock()
for jid, session := range sessionDB.Data.Sessions { for jid, session := range sessionDB.Data.Sessions {
if session.Login != "" { 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 cleanedMap.Sessions[jid] = session
} }
} }
@ -104,6 +118,16 @@ func initYamlDB(path string, dataPtr *SessionsMap) (*SessionsYamlDB, error) {
emptySessionsMap(dataPtr) 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{ return &SessionsYamlDB{
YamlDB: yamldb.YamlDB{ YamlDB: yamldb.YamlDB{
Path: path, Path: path,
@ -115,6 +139,13 @@ func initYamlDB(path string, dataPtr *SessionsMap) (*SessionsYamlDB, error) {
// Get retrieves a session value // Get retrieves a session value
func (s *Session) Get(key string) (string, error) { 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 { switch key {
case "timezone": case "timezone":
return s.Timezone, nil return s.Timezone, nil
@ -130,6 +161,10 @@ func (s *Session) Get(key string) (string, error) {
return fromBool(s.Carbons), nil return fromBool(s.Carbons), nil
case "hideids": case "hideids":
return fromBool(s.HideIds), nil return fromBool(s.HideIds), nil
case "receipts":
return fromBool(s.Receipts), nil
case "nativeedits":
return fromBool(s.NativeEdits), nil
} }
return "", errors.New("Unknown session property") return "", errors.New("Unknown session property")
@ -137,9 +172,12 @@ func (s *Session) Get(key string) (string, error) {
// ToMap converts the session to a map // ToMap converts the session to a map
func (s *Session) ToMap() map[string]string { func (s *Session) ToMap() map[string]string {
sessionsLock.Lock()
defer sessionsLock.Unlock()
m := make(map[string]string) m := make(map[string]string)
for _, configKey := range configKeys { for _, configKey := range configKeys {
value, _ := s.Get(configKey) value, _ := s.get(configKey)
m[configKey] = value m[configKey] = value
} }
@ -148,6 +186,9 @@ func (s *Session) ToMap() map[string]string {
// Set sets a session value // Set sets a session value
func (s *Session) Set(key string, value string) (string, error) { func (s *Session) Set(key string, value string) (string, error) {
sessionsLock.Lock()
defer sessionsLock.Unlock()
switch key { switch key {
case "timezone": case "timezone":
s.Timezone = value s.Timezone = value
@ -194,6 +235,20 @@ func (s *Session) Set(key string, value string) (string, error) {
} }
s.HideIds = b s.HideIds = b
return value, nil 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
} }
return "", errors.New("Unknown session property") return "", errors.New("Unknown session property")
@ -210,6 +265,51 @@ func (s *Session) TimezoneToLocation() *time.Location {
return zeroLocation 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 { func fromBool(b bool) string {
if b { if b {
return "true" return "true"

View file

@ -48,6 +48,7 @@ func TestSessionToMap(t *testing.T) {
Timezone: "klsf", Timezone: "klsf",
RawMessages: true, RawMessages: true,
OOBMode: true, OOBMode: true,
Receipts: true,
} }
m := session.ToMap() m := session.ToMap()
sample := map[string]string{ sample := map[string]string{
@ -58,6 +59,8 @@ func TestSessionToMap(t *testing.T) {
"oobmode": "true", "oobmode": "true",
"carbons": "false", "carbons": "false",
"hideids": "false", "hideids": "false",
"receipts": "true",
"nativeedits": "false",
} }
if !reflect.DeepEqual(m, sample) { if !reflect.DeepEqual(m, sample) {
t.Errorf("Map does not match the sample: %v", m) t.Errorf("Map does not match the sample: %v", m)
@ -85,3 +88,31 @@ func TestSessionSetAbsent(t *testing.T) {
t.Error("There shouldn't come a donkey!") 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")
}
}

46
staging.Dockerfile Normal file
View file

@ -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 \
go mod download
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 /

23
tdlib.Dockerfile Normal file
View file

@ -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/ /

View file

@ -12,10 +12,11 @@ import (
"dev.narayana.im/narayana/telegabber/xmpp" "dev.narayana.im/narayana/telegabber/xmpp"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/zelenin/go-tdlib/client"
goxmpp "gosrc.io/xmpp" goxmpp "gosrc.io/xmpp"
) )
var version string = "1.8.0-dev" var version string = "1.9.6"
var commit string var commit string
var sm *goxmpp.StreamManager var sm *goxmpp.StreamManager
@ -60,6 +61,9 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
client.SetLogVerbosityLevel(&client.SetLogVerbosityLevelRequest{
NewVerbosityLevel: stringToTdlibLogConstant(config.Telegram.Loglevel),
})
SetLogrusLevel(config.XMPP.Loglevel) SetLogrusLevel(config.XMPP.Loglevel)
log.Infof("Starting telegabber version %v", version) 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() { func exit() {
xmpp.Close(component) xmpp.Close(component)
close(cleanupDone) close(cleanupDone)

19
telegabber_test.go Normal file
View file

@ -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")
}
}

View file

@ -16,25 +16,6 @@ import (
"gosrc.io/xmpp" "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 // DelayedStatus describes an online status expiring on timeout
type DelayedStatus struct { type DelayedStatus struct {
TimestampOnline int64 TimestampOnline int64
@ -53,15 +34,18 @@ type Client struct {
jid string jid string
Session *persistence.Session Session *persistence.Session
resources map[string]bool resources map[string]bool
outbox map[string]string
content *config.TelegramContentConfig content *config.TelegramContentConfig
cache *cache.Cache cache *cache.Cache
online bool online bool
outbox map[string]string
editOutbox map[string]string
DelayedStatuses map[int64]*DelayedStatus DelayedStatuses map[int64]*DelayedStatus
DelayedStatusesLock sync.Mutex DelayedStatusesLock sync.Mutex
lastMsgHashes map[int64]uint64 lastMsgHashes map[int64]uint64
lastMsgIds map[int64]string
msgHashSeed maphash.Seed msgHashSeed maphash.Seed
locks clientLocks locks clientLocks
@ -73,17 +57,18 @@ type clientLocks struct {
chatMessageLocks map[int64]*sync.Mutex chatMessageLocks map[int64]*sync.Mutex
resourcesLock sync.Mutex resourcesLock sync.Mutex
outboxLock sync.Mutex outboxLock sync.Mutex
editOutboxLock sync.Mutex
lastMsgHashesLock sync.Mutex lastMsgHashesLock sync.Mutex
lastMsgIdsLock sync.RWMutex
authorizerReadLock sync.Mutex
authorizerWriteLock sync.Mutex
} }
// NewClient instantiates a Telegram App // NewClient instantiates a Telegram App
func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component, session *persistence.Session) (*Client, error) { func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component, session *persistence.Session) (*Client, error) {
var options []client.Option var options []client.Option
options = append(options, client.WithLogVerbosity(&client.SetLogVerbosityLevelRequest{
NewVerbosityLevel: stringToLogConstant(conf.Loglevel),
}))
if conf.Tdlib.Client.CatchTimeout != 0 { if conf.Tdlib.Client.CatchTimeout != 0 {
options = append(options, client.WithCatchTimeout( options = append(options, client.WithCatchTimeout(
time.Duration(conf.Tdlib.Client.CatchTimeout)*time.Second, time.Duration(conf.Tdlib.Client.CatchTimeout)*time.Second,
@ -129,12 +114,14 @@ func NewClient(conf config.TelegramConfig, jid string, component *xmpp.Component
jid: jid, jid: jid,
Session: session, Session: session,
resources: make(map[string]bool), resources: make(map[string]bool),
outbox: make(map[string]string),
content: &conf.Content, content: &conf.Content,
cache: cache.NewCache(), cache: cache.NewCache(),
outbox: make(map[string]string),
editOutbox: make(map[string]string),
options: options, options: options,
DelayedStatuses: make(map[int64]*DelayedStatus), DelayedStatuses: make(map[int64]*DelayedStatus),
lastMsgHashes: make(map[int64]uint64), lastMsgHashes: make(map[int64]uint64),
lastMsgIds: make(map[int64]string),
msgHashSeed: maphash.MakeSeed(), msgHashSeed: maphash.MakeSeed(),
locks: clientLocks{ locks: clientLocks{
chatMessageLocks: make(map[int64]*sync.Mutex), chatMessageLocks: make(map[int64]*sync.Mutex),

View file

@ -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")
}
}

View file

@ -85,8 +85,8 @@ var chatCommands = map[string]command{
"invite": command{"id or @username", "add user to current chat"}, "invite": command{"id or @username", "add user to current chat"},
"link": command{"", "get invite link for current chat"}, "link": command{"", "get invite link for current chat"},
"kick": command{"id or @username", "remove user to current chat"}, "kick": command{"id or @username", "remove user to current chat"},
"mute": command{"id or @username [hours]", "mute user in current chat"}, "mute": command{"[id or @username] [hours]", "mute the whole chat or a user in current chat"},
"unmute": command{"id or @username", "unrestrict user from current chat"}, "unmute": command{"[id or @username]", "unmute the whole chat or a user in the current chat"},
"ban": command{"id or @username [hours]", "restrict @username from current chat for [hours] or forever"}, "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)"}, "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"}, "promote": command{"id or @username [title]", "promote user to admin in current chat"},
@ -185,18 +185,14 @@ func keyValueString(key, value string) string {
} }
func (c *Client) unsubscribe(chatID int64) error { func (c *Client) unsubscribe(chatID int64) error {
return gateway.SendPresence( args := gateway.SimplePresence(chatID, "unsubscribed")
c.xmpp, return c.sendPresence(args...)
c.jid,
gateway.SPFrom(strconv.FormatInt(chatID, 10)),
gateway.SPType("unsubscribed"),
)
} }
func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message) { func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message) {
for i := len(messages) - 1; i >= 0; i-- { for i := len(messages) - 1; i >= 0; i-- {
message := messages[i] message := messages[i]
reply, _ := c.getMessageReply(message) reply, _ := c.getMessageReply(message, false, true)
gateway.SendMessage( gateway.SendMessage(
c.jid, c.jid,
@ -205,6 +201,8 @@ func (c *Client) sendMessagesReverse(chatID int64, messages []*client.Message) {
strconv.FormatInt(message.Id, 10), strconv.FormatInt(message.Id, 10),
c.xmpp, c.xmpp,
reply, reply,
"",
false,
false, false,
) )
} }
@ -250,8 +248,14 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
return err.Error() return err.Error()
} }
c.locks.authorizerWriteLock.Lock()
defer c.locks.authorizerWriteLock.Unlock()
c.authorizer.PhoneNumber <- args[0] c.authorizer.PhoneNumber <- args[0]
} else { } else {
c.locks.authorizerWriteLock.Lock()
defer c.locks.authorizerWriteLock.Unlock()
if c.authorizer == nil { if c.authorizer == nil {
return TelegramNotInitialized return TelegramNotInitialized
} }
@ -275,16 +279,15 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
return notOnline return notOnline
} }
for _, id := range c.cache.ChatsKeys() {
c.unsubscribe(id)
}
_, err := c.client.LogOut() _, err := c.client.LogOut()
if err != nil { if err != nil {
c.forceClose()
return errors.Wrap(err, "Logout error").Error() return errors.Wrap(err, "Logout error").Error()
} }
for _, id := range c.cache.ChatsKeys() {
c.unsubscribe(id)
}
c.Session.Login = "" c.Session.Login = ""
// cancel auth // cancel auth
case "cancelauth": case "cancelauth":
@ -324,10 +327,13 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
lastname = rawCmdArguments(cmdline, 1) lastname = rawCmdArguments(cmdline, 1)
} }
c.locks.authorizerWriteLock.Lock()
if c.authorizer != nil && !c.authorizer.isClosed { if c.authorizer != nil && !c.authorizer.isClosed {
c.authorizer.FirstName <- firstname c.authorizer.FirstName <- firstname
c.authorizer.LastName <- lastname c.authorizer.LastName <- lastname
c.locks.authorizerWriteLock.Unlock()
} else { } else {
c.locks.authorizerWriteLock.Unlock()
if !c.Online() { if !c.Online() {
return notOnline return notOnline
} }
@ -374,6 +380,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
} }
case "config": case "config":
if len(args) > 1 { if len(args) > 1 {
var msg string
if gateway.MessageOutgoingPermissionVersion == 0 && args[0] == "carbons" && args[1] == "true" { 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"
} }
@ -384,7 +391,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
} }
gateway.DirtySessions = true gateway.DirtySessions = true
return fmt.Sprintf("%s set to %s", args[0], value) return fmt.Sprintf("%s%s set to %s", msg, args[0], value)
} else if len(args) > 0 { } else if len(args) > 0 {
value, err := c.Session.Get(args[0]) value, err := c.Session.Get(args[0])
if err != nil { if err != nil {
@ -413,7 +420,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) string
text := rawCmdArguments(cmdline, 1) text := rawCmdArguments(cmdline, 1)
_, err = c.client.ReportChat(&client.ReportChatRequest{ _, err = c.client.ReportChat(&client.ReportChatRequest{
ChatId: contact.Id, ChatId: contact.Id,
Reason: &client.ChatReportReasonCustom{}, Reason: &client.ReportReasonCustom{},
Text: text, Text: text,
}) })
if err != nil { if err != nil {
@ -701,18 +708,18 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool)
} }
// blacklists current user // blacklists current user
case "block": case "block":
_, err := c.client.ToggleMessageSenderIsBlocked(&client.ToggleMessageSenderIsBlockedRequest{ _, err := c.client.SetMessageSenderBlockList(&client.SetMessageSenderBlockListRequest{
SenderId: &client.MessageSenderUser{UserId: chatID}, SenderId: &client.MessageSenderUser{UserId: chatID},
IsBlocked: true, BlockList: &client.BlockListMain{},
}) })
if err != nil { if err != nil {
return err.Error(), true return err.Error(), true
} }
// unblacklists current user // unblacklists current user
case "unblock": case "unblock":
_, err := c.client.ToggleMessageSenderIsBlocked(&client.ToggleMessageSenderIsBlockedRequest{ _, err := c.client.SetMessageSenderBlockList(&client.SetMessageSenderBlockListRequest{
SenderId: &client.MessageSenderUser{UserId: chatID}, SenderId: &client.MessageSenderUser{UserId: chatID},
IsBlocked: false, BlockList: nil,
}) })
if err != nil { if err != nil {
return err.Error(), true return err.Error(), true
@ -764,59 +771,65 @@ func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool)
if err != nil { if err != nil {
return err.Error(), true return err.Error(), true
} }
// mute @username [n hours] // mute [@username [n hours]]
case "mute": case "mute":
if len(args) < 1 { if len(args) > 0 {
return notEnoughArguments, true contact, _, err := c.GetContactByUsername(args[0])
}
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 err != nil { if err != nil {
return "Invalid number of hours", true return err.Error(), true
} }
}
_, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ var hours int64
ChatId: chatID, if len(args) > 1 {
MemberId: &client.MessageSenderUser{UserId: contact.Id}, hours, err = strconv.ParseInt(args[1], 10, 32)
Status: &client.ChatMemberStatusRestricted{ if err != nil {
IsMember: true, return "Invalid number of hours", true
RestrictedUntilDate: c.formatBantime(hours), }
Permissions: &permissionsReadonly, }
},
}) _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{
if err != nil { ChatId: chatID,
return err.Error(), true MemberId: &client.MessageSenderUser{UserId: contact.Id},
Status: &client.ChatMemberStatusRestricted{
IsMember: true,
RestrictedUntilDate: c.formatBantime(hours),
Permissions: &permissionsReadonly,
},
})
if err != nil {
return err.Error(), true
}
} else {
if !c.Session.IgnoreChat(chatID) {
return "Chat is already ignored", true
}
gateway.DirtySessions = true
} }
// unmute @username // unmute [@username]
case "unmute": case "unmute":
if len(args) < 1 { if len(args) > 0 {
return notEnoughArguments, true contact, _, err := c.GetContactByUsername(args[0])
} if err != nil {
return err.Error(), true
}
contact, _, err := c.GetContactByUsername(args[0]) _, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{
if err != nil { ChatId: chatID,
return err.Error(), true MemberId: &client.MessageSenderUser{UserId: contact.Id},
} Status: &client.ChatMemberStatusRestricted{
IsMember: true,
_, err = c.client.SetChatMemberStatus(&client.SetChatMemberStatusRequest{ RestrictedUntilDate: 0,
ChatId: chatID, Permissions: &permissionsMember,
MemberId: &client.MessageSenderUser{UserId: contact.Id}, },
Status: &client.ChatMemberStatusRestricted{ })
IsMember: true, if err != nil {
RestrictedUntilDate: 0, return err.Error(), true
Permissions: &permissionsMember, }
}, } else {
}) if !c.Session.UnignoreChat(chatID) {
if err != nil { return "Chat wasn't ignored", true
return err.Error(), true }
gateway.DirtySessions = true
} }
// ban @username from current chat [for N hours] // ban @username from current chat [for N hours]
case "ban": case "ban":

View file

@ -2,7 +2,6 @@ package telegram
import ( import (
"github.com/pkg/errors" "github.com/pkg/errors"
"strconv"
"time" "time"
"dev.narayana.im/narayana/telegabber/xmpp/gateway" "dev.narayana.im/narayana/telegabber/xmpp/gateway"
@ -69,10 +68,10 @@ func (stateHandler *clientAuthorizer) Handle(c *client.Client, state client.Auth
return nil return nil
case client.TypeAuthorizationStateLoggingOut: case client.TypeAuthorizationStateLoggingOut:
return client.ErrNotSupportedAuthorizationState return nil
case client.TypeAuthorizationStateClosing: case client.TypeAuthorizationStateClosing:
return client.ErrNotSupportedAuthorizationState return nil
case client.TypeAuthorizationStateClosed: case client.TypeAuthorizationStateClosed:
return client.ErrNotSupportedAuthorizationState return client.ErrNotSupportedAuthorizationState
@ -110,6 +109,7 @@ func (c *Client) Connect(resource string) error {
log.Warn("Connecting to Telegram network...") log.Warn("Connecting to Telegram network...")
c.locks.authorizerWriteLock.Lock()
c.authorizer = &clientAuthorizer{ c.authorizer = &clientAuthorizer{
TdlibParameters: make(chan *client.SetTdlibParametersRequest, 1), TdlibParameters: make(chan *client.SetTdlibParametersRequest, 1),
PhoneNumber: make(chan string, 1), PhoneNumber: make(chan string, 1),
@ -121,8 +121,10 @@ func (c *Client) Connect(resource string) error {
} }
go c.interactor() go c.interactor()
log.Warn("Interactor launched")
c.authorizer.TdlibParameters <- c.parameters c.authorizer.TdlibParameters <- c.parameters
c.locks.authorizerWriteLock.Unlock()
tdlibClient, err := client.NewClient(c.authorizer, c.options...) tdlibClient, err := client.NewClient(c.authorizer, c.options...)
if err != nil { if err != nil {
@ -156,7 +158,7 @@ func (c *Client) Connect(resource string) error {
} }
gateway.SubscribeToTransport(c.xmpp, c.jid) 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 return nil
@ -178,6 +180,9 @@ func (c *Client) TryLogin(resource string, login string) error {
time.Sleep(1e5) time.Sleep(1e5)
} }
c.locks.authorizerWriteLock.Lock()
defer c.locks.authorizerWriteLock.Unlock()
if c.authorizer == nil { if c.authorizer == nil {
return errors.New(TelegramNotInitialized) return errors.New(TelegramNotInitialized)
} }
@ -190,6 +195,9 @@ func (c *Client) TryLogin(resource string, login string) error {
} }
func (c *Client) SetPhoneNumber(login string) error { func (c *Client) SetPhoneNumber(login string) error {
c.locks.authorizerWriteLock.Lock()
defer c.locks.authorizerWriteLock.Unlock()
if c.authorizer == nil || c.authorizer.isClosed { if c.authorizer == nil || c.authorizer.isClosed {
return errors.New("Authorization not needed") return errors.New("Authorization not needed")
} }
@ -219,12 +227,8 @@ func (c *Client) Disconnect(resource string, quit bool) bool {
// we're offline (unsubscribe if logout) // we're offline (unsubscribe if logout)
for _, id := range c.cache.ChatsKeys() { for _, id := range c.cache.ChatsKeys() {
gateway.SendPresence( args := gateway.SimplePresence(id, "unavailable")
c.xmpp, c.sendPresence(args...)
c.jid,
gateway.SPFrom(strconv.FormatInt(id, 10)),
gateway.SPType("unavailable"),
)
} }
c.close() c.close()
@ -234,9 +238,16 @@ func (c *Client) Disconnect(resource string, quit bool) bool {
func (c *Client) interactor() { func (c *Client) interactor() {
for { for {
c.locks.authorizerReadLock.Lock()
if c.authorizer == nil {
log.Warn("Authorizer is lost, halting the interactor")
c.locks.authorizerReadLock.Unlock()
return
}
state, ok := <-c.authorizer.State state, ok := <-c.authorizer.State
if !ok { if !ok {
log.Warn("Interactor is disconnected") log.Warn("Interactor is disconnected")
c.locks.authorizerReadLock.Unlock()
return return
} }
@ -266,18 +277,27 @@ func (c *Client) interactor() {
log.Warn("Waiting for 2FA password...") log.Warn("Waiting for 2FA password...")
gateway.SendServiceMessage(c.jid, "Please, enter 2FA passphrase via /password 12345", c.xmpp) gateway.SendServiceMessage(c.jid, "Please, enter 2FA passphrase via /password 12345", c.xmpp)
} }
c.locks.authorizerReadLock.Unlock()
} }
} }
func (c *Client) forceClose() { func (c *Client) forceClose() {
c.locks.authorizerReadLock.Lock()
c.locks.authorizerWriteLock.Lock()
defer c.locks.authorizerReadLock.Unlock()
defer c.locks.authorizerWriteLock.Unlock()
c.online = false c.online = false
c.authorizer = nil c.authorizer = nil
} }
func (c *Client) close() { func (c *Client) close() {
c.locks.authorizerWriteLock.Lock()
if c.authorizer != nil && !c.authorizer.isClosed { if c.authorizer != nil && !c.authorizer.isClosed {
c.authorizer.Close() c.authorizer.Close()
} }
c.locks.authorizerWriteLock.Unlock()
if c.client != nil { if c.client != nil {
_, err := c.client.Close() _, err := c.client.Close()
if err != nil { if err != nil {

View file

@ -8,15 +8,31 @@ import (
"github.com/zelenin/go-tdlib/client" "github.com/zelenin/go-tdlib/client"
) )
// Insertion is a piece of text in given position type insertionType int
type Insertion struct {
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 Offset int32
Runes []rune Runes []rune
Type insertionType
} }
// InsertionStack contains the sequence of insertions // insertionStack contains the sequence of insertions
// from the start or from the end // from the start or from the end
type InsertionStack []*Insertion type insertionStack []*insertion
var boldRunesMarkdown = []rune("**") var boldRunesMarkdown = []rune("**")
var boldRunesXEP0393 = []rune("*") var boldRunesXEP0393 = []rune("*")
@ -24,13 +40,18 @@ var italicRunes = []rune("_")
var strikeRunesMarkdown = []rune("~~") var strikeRunesMarkdown = []rune("~~")
var strikeRunesXEP0393 = []rune("~") var strikeRunesXEP0393 = []rune("~")
var codeRunes = []rune("`") var codeRunes = []rune("`")
var preRuneStart = []rune("```\n") var preRunesStart = []rune("```\n")
var preRuneEnd = []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 // rebalance pumps all the values until the given offset to current stack (growing
// from start) from given stack (growing from end); should be called // from start) from given stack (growing from end); should be called
// before any insertions to the current stack at the given offset // 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 { for len(s2) > 0 && s2[len(s2)-1].Offset <= offset {
s = append(s, s2[len(s2)-1]) s = append(s, s2[len(s2)-1])
s2 = 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 // NewIterator is a second order function that sequentially scans and returns
// stack elements; starts returning nil when elements are ended // stack elements; starts returning nil when elements are ended
func (s InsertionStack) NewIterator() func() *Insertion { func (s insertionStack) NewIterator() func() *insertion {
i := -1 i := -1
return func() *Insertion { return func() *insertion {
i++ i++
if i < len(s) { if i < len(s) {
return s[i] return s[i]
@ -120,21 +141,10 @@ func MergeAdjacentEntities(entities []*client.TextEntity) []*client.TextEntity {
} }
// ClaspDirectives to the following span as required by XEP-0393 // 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)) alignedEntities := make([]*client.TextEntity, len(entities))
copy(alignedEntities, 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 { for i, entity := range alignedEntities {
var dirty bool var dirty bool
endOffset := entity.Offset + entity.Length endOffset := entity.Offset + entity.Length
@ -167,18 +177,89 @@ func ClaspDirectives(text string, entities []*client.TextEntity) []*client.TextE
return alignedEntities return alignedEntities
} }
func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) (*Insertion, *Insertion) { func markupBraces(entity *client.TextEntity, lbrace, rbrace []rune) []*insertion {
return &Insertion{ return []*insertion{
&insertion{
Offset: entity.Offset, Offset: entity.Offset,
Runes: lbrace, Runes: lbrace,
}, &Insertion{ Type: insertionOpening,
},
&insertion{
Offset: entity.Offset + entity.Length, Offset: entity.Offset + entity.Length,
Runes: rbrace, Runes: rbrace,
} Type: insertionClosing,
},
}
} }
// EntityToMarkdown generates the wrapping Markdown tags func quotePrependNewlines(entity *client.TextEntity, doubledRunes []rune, markupMode MarkupModeType) []*insertion {
func EntityToMarkdown(entity *client.TextEntity) (*Insertion, *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() { switch entity.Type.TextEntityTypeType() {
case client.TypeTextEntityTypeBold: case client.TypeTextEntityTypeBold:
return markupBraces(entity, boldRunesMarkdown, boldRunesMarkdown) return markupBraces(entity, boldRunesMarkdown, boldRunesMarkdown)
@ -189,22 +270,24 @@ func EntityToMarkdown(entity *client.TextEntity) (*Insertion, *Insertion) {
case client.TypeTextEntityTypeCode: case client.TypeTextEntityTypeCode:
return markupBraces(entity, codeRunes, codeRunes) return markupBraces(entity, codeRunes, codeRunes)
case client.TypeTextEntityTypePre: case client.TypeTextEntityTypePre:
return markupBraces(entity, preRuneStart, preRuneEnd) return markupBraces(entity, preRunesStart, preRunesEnd)
case client.TypeTextEntityTypePreCode: case client.TypeTextEntityTypePreCode:
preCode, _ := entity.Type.(*client.TextEntityTypePreCode) 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: case client.TypeTextEntityTypeTextUrl:
textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl) textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl)
return markupBraces(entity, []rune("["), []rune("]("+textURL.Url+")")) return markupBraces(entity, []rune("["), []rune("]("+textURL.Url+")"))
} }
return nil, nil return []*insertion{}
} }
// EntityToXEP0393 generates the wrapping XEP-0393 tags // entityToXEP0393 generates the wrapping XEP-0393 tags
func EntityToXEP0393(entity *client.TextEntity) (*Insertion, *Insertion) { func entityToXEP0393(entity *client.TextEntity, doubledRunes []rune, markupMode MarkupModeType) []*insertion {
if entity == nil || entity.Type == nil { if entity == nil || entity.Type == nil {
return nil, nil return []*insertion{}
} }
switch entity.Type.TextEntityTypeType() { switch entity.Type.TextEntityTypeType() {
@ -217,33 +300,59 @@ func EntityToXEP0393(entity *client.TextEntity) (*Insertion, *Insertion) {
case client.TypeTextEntityTypeCode: case client.TypeTextEntityTypeCode:
return markupBraces(entity, codeRunes, codeRunes) return markupBraces(entity, codeRunes, codeRunes)
case client.TypeTextEntityTypePre: case client.TypeTextEntityTypePre:
return markupBraces(entity, preRuneStart, preRuneEnd) return markupBraces(entity, preRunesStart, preRunesEnd)
case client.TypeTextEntityTypePreCode: case client.TypeTextEntityTypePreCode:
preCode, _ := entity.Type.(*client.TextEntityTypePreCode) 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: case client.TypeTextEntityTypeTextUrl:
textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl) textURL, _ := entity.Type.(*client.TextEntityTypeTextUrl)
// non-standard, Pidgin-specific // non-standard, Pidgin-specific
return markupBraces(entity, []rune{}, []rune(" <"+textURL.Url+">")) 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 // Format traverses an already sorted list of entities and wraps the text in a markup
func Format( func Format(
sourceText string, sourceText string,
entities []*client.TextEntity, entities []*client.TextEntity,
entityToMarkup func(*client.TextEntity) (*Insertion, *Insertion), markupMode MarkupModeType,
) string { ) string {
if len(entities) == 0 { if len(entities) == 0 {
return sourceText 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)) doubledRunes := textToDoubledRunes(sourceText)
endStack := make(InsertionStack, 0, len(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 // convert entities to a stack of brackets
var maxEndOffset int32 var maxEndOffset int32
@ -260,36 +369,70 @@ func Format(
startStack, endStack = startStack.rebalance(endStack, entity.Offset) startStack, endStack = startStack.rebalance(endStack, entity.Offset)
startInsertion, endInsertion := entityToMarkup(entity) insertions := entityToMarkup(entity, doubledRunes, markupMode)
if startInsertion != nil { if len(insertions) > 1 {
startStack = append(startStack, startInsertion) startStack = append(startStack, insertions[0:len(insertions)-1]...)
} }
if endInsertion != nil { if len(insertions) > 0 {
endStack = append(endStack, endInsertion) endStack = append(endStack, insertions[len(insertions)-1])
} }
} }
// flush the closing brackets that still remain in endStack // flush the closing brackets that still remain in endStack
startStack, endStack = startStack.rebalance(endStack, maxEndOffset) 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 // merge brackets into text
markupRunes := make([]rune, 0, len(sourceText)) markupRunes := make([]rune, 0, len(sourceText))
nextInsertion := startStack.NewIterator() nextInsertion := startStack.NewIterator()
insertion := nextInsertion() insertion := nextInsertion()
var runeI int32 var skipNext bool
for _, cp := range sourceText { for i, cp := range doubledRunes {
for insertion != nil && insertion.Offset <= runeI { if skipNext {
skipNext = false
continue
}
for insertion != nil && int(insertion.Offset) <= i {
markupRunes = append(markupRunes, insertion.Runes...) markupRunes = append(markupRunes, insertion.Runes...)
insertion = nextInsertion() insertion = nextInsertion()
} }
markupRunes = append(markupRunes, cp) markupRunes = append(markupRunes, cp)
// skip two UTF-16 code units (not points actually!) if needed // skip two UTF-16 code units (not points actually!) if needed
if cp > 0x0000ffff { if cp > bmpCeil {
runeI += 2 skipNext = true
} else {
runeI++
} }
} }
for insertion != nil { for insertion != nil {

View file

@ -7,7 +7,7 @@ import (
) )
func TestNoFormatting(t *testing.T) { func TestNoFormatting(t *testing.T) {
markup := Format("abc\ndef", []*client.TextEntity{}, EntityToMarkdown) markup := Format("abc\ndef", []*client.TextEntity{}, MarkupModeMarkdown)
if markup != "abc\ndef" { if markup != "abc\ndef" {
t.Errorf("No formatting expected, but: %v", markup) t.Errorf("No formatting expected, but: %v", markup)
} }
@ -20,7 +20,7 @@ func TestFormattingSimple(t *testing.T) {
Length: 4, Length: 4,
Type: &client.TextEntityTypeBold{}, Type: &client.TextEntityTypeBold{},
}, },
}, EntityToMarkdown) }, MarkupModeMarkdown)
if markup != "👙**🐧🐖**" { if markup != "👙**🐧🐖**" {
t.Errorf("Wrong simple formatting: %v", markup) t.Errorf("Wrong simple formatting: %v", markup)
} }
@ -40,7 +40,7 @@ func TestFormattingAdjacent(t *testing.T) {
Url: "https://narayana.im/", Url: "https://narayana.im/",
}, },
}, },
}, EntityToMarkdown) }, MarkupModeMarkdown)
if markup != "a👙_🐧_[🐖](https://narayana.im/)" { if markup != "a👙_🐧_[🐖](https://narayana.im/)" {
t.Errorf("Wrong adjacent formatting: %v", markup) t.Errorf("Wrong adjacent formatting: %v", markup)
} }
@ -63,18 +63,18 @@ func TestFormattingAdjacentAndNested(t *testing.T) {
Length: 2, Length: 2,
Type: &client.TextEntityTypeItalic{}, Type: &client.TextEntityTypeItalic{},
}, },
}, EntityToMarkdown) }, MarkupModeMarkdown)
if markup != "```\n**👙**🐧\n```_🐖_" { if markup != "```\n**👙**🐧\n```_🐖_" {
t.Errorf("Wrong adjacent&nested formatting: %v", markup) t.Errorf("Wrong adjacent&nested formatting: %v", markup)
} }
} }
func TestRebalanceTwoZero(t *testing.T) { func TestRebalanceTwoZero(t *testing.T) {
s1 := InsertionStack{ s1 := insertionStack{
&Insertion{Offset: 7}, &insertion{Offset: 7},
&Insertion{Offset: 8}, &insertion{Offset: 8},
} }
s2 := InsertionStack{} s2 := insertionStack{}
s1, s2 = s1.rebalance(s2, 7) s1, s2 = s1.rebalance(s2, 7)
if !(len(s1) == 2 && len(s2) == 0 && s1[0].Offset == 7 && s1[1].Offset == 8) { if !(len(s1) == 2 && len(s2) == 0 && s1[0].Offset == 7 && s1[1].Offset == 8) {
t.Errorf("Wrong rebalance 20: %#v %#v", s1, s2) t.Errorf("Wrong rebalance 20: %#v %#v", s1, s2)
@ -82,13 +82,13 @@ func TestRebalanceTwoZero(t *testing.T) {
} }
func TestRebalanceNeeded(t *testing.T) { func TestRebalanceNeeded(t *testing.T) {
s1 := InsertionStack{ s1 := insertionStack{
&Insertion{Offset: 7}, &insertion{Offset: 7},
&Insertion{Offset: 8}, &insertion{Offset: 8},
} }
s2 := InsertionStack{ s2 := insertionStack{
&Insertion{Offset: 10}, &insertion{Offset: 10},
&Insertion{Offset: 9}, &insertion{Offset: 9},
} }
s1, s2 = s1.rebalance(s2, 9) s1, s2 = s1.rebalance(s2, 9)
if !(len(s1) == 3 && len(s2) == 1 && if !(len(s1) == 3 && len(s2) == 1 &&
@ -99,13 +99,13 @@ func TestRebalanceNeeded(t *testing.T) {
} }
func TestRebalanceNotNeeded(t *testing.T) { func TestRebalanceNotNeeded(t *testing.T) {
s1 := InsertionStack{ s1 := insertionStack{
&Insertion{Offset: 7}, &insertion{Offset: 7},
&Insertion{Offset: 8}, &insertion{Offset: 8},
} }
s2 := InsertionStack{ s2 := insertionStack{
&Insertion{Offset: 10}, &insertion{Offset: 10},
&Insertion{Offset: 9}, &insertion{Offset: 9},
} }
s1, s2 = s1.rebalance(s2, 8) s1, s2 = s1.rebalance(s2, 8)
if !(len(s1) == 2 && len(s2) == 2 && if !(len(s1) == 2 && len(s2) == 2 &&
@ -116,13 +116,13 @@ func TestRebalanceNotNeeded(t *testing.T) {
} }
func TestRebalanceLate(t *testing.T) { func TestRebalanceLate(t *testing.T) {
s1 := InsertionStack{ s1 := insertionStack{
&Insertion{Offset: 7}, &insertion{Offset: 7},
&Insertion{Offset: 8}, &insertion{Offset: 8},
} }
s2 := InsertionStack{ s2 := insertionStack{
&Insertion{Offset: 10}, &insertion{Offset: 10},
&Insertion{Offset: 9}, &insertion{Offset: 9},
} }
s1, s2 = s1.rebalance(s2, 10) s1, s2 = s1.rebalance(s2, 10)
if !(len(s1) == 4 && len(s2) == 0 && if !(len(s1) == 4 && len(s2) == 0 &&
@ -133,7 +133,7 @@ func TestRebalanceLate(t *testing.T) {
} }
func TestIteratorEmpty(t *testing.T) { func TestIteratorEmpty(t *testing.T) {
s := InsertionStack{} s := insertionStack{}
g := s.NewIterator() g := s.NewIterator()
v := g() v := g()
if v != nil { if v != nil {
@ -142,9 +142,9 @@ func TestIteratorEmpty(t *testing.T) {
} }
func TestIterator(t *testing.T) { func TestIterator(t *testing.T) {
s := InsertionStack{ s := insertionStack{
&Insertion{Offset: 7}, &insertion{Offset: 7},
&Insertion{Offset: 8}, &insertion{Offset: 8},
} }
g := s.NewIterator() g := s.NewIterator()
v := g() v := g()
@ -208,7 +208,7 @@ func TestSortEmpty(t *testing.T) {
} }
func TestNoFormattingXEP0393(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" { if markup != "abc\ndef" {
t.Errorf("No formatting expected, but: %v", markup) t.Errorf("No formatting expected, but: %v", markup)
} }
@ -221,7 +221,7 @@ func TestFormattingXEP0393Simple(t *testing.T) {
Length: 4, Length: 4,
Type: &client.TextEntityTypeBold{}, Type: &client.TextEntityTypeBold{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "👙*🐧🐖*" { if markup != "👙*🐧🐖*" {
t.Errorf("Wrong simple formatting: %v", markup) t.Errorf("Wrong simple formatting: %v", markup)
} }
@ -241,7 +241,7 @@ func TestFormattingXEP0393Adjacent(t *testing.T) {
Url: "https://narayana.im/", Url: "https://narayana.im/",
}, },
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "a👙_🐧_🐖 <https://narayana.im/>" { if markup != "a👙_🐧_🐖 <https://narayana.im/>" {
t.Errorf("Wrong adjacent formatting: %v", markup) t.Errorf("Wrong adjacent formatting: %v", markup)
} }
@ -264,7 +264,7 @@ func TestFormattingXEP0393AdjacentAndNested(t *testing.T) {
Length: 2, Length: 2,
Type: &client.TextEntityTypeItalic{}, Type: &client.TextEntityTypeItalic{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "```\n*👙*🐧\n```_🐖_" { if markup != "```\n*👙*🐧\n```_🐖_" {
t.Errorf("Wrong adjacent&nested formatting: %v", markup) t.Errorf("Wrong adjacent&nested formatting: %v", markup)
} }
@ -287,7 +287,7 @@ func TestFormattingXEP0393AdjacentItalicBoldItalic(t *testing.T) {
Length: 69, Length: 69,
Type: &client.TextEntityTypeItalic{}, Type: &client.TextEntityTypeItalic{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "_раса двуногих крысолюдей, *которую так редко замечают, что многие отрицают само их существование*_" { if markup != "_раса двуногих крысолюдей, *которую так редко замечают, что многие отрицают само их существование*_" {
t.Errorf("Wrong adjacent italic/bold-italic formatting: %v", markup) t.Errorf("Wrong adjacent italic/bold-italic formatting: %v", markup)
} }
@ -315,7 +315,7 @@ func TestFormattingXEP0393MultipleAdjacent(t *testing.T) {
Length: 1, Length: 1,
Type: &client.TextEntityTypeItalic{}, Type: &client.TextEntityTypeItalic{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "a*bcd*_e_" { if markup != "a*bcd*_e_" {
t.Errorf("Wrong multiple adjacent formatting: %v", markup) t.Errorf("Wrong multiple adjacent formatting: %v", markup)
} }
@ -343,7 +343,7 @@ func TestFormattingXEP0393Intersecting(t *testing.T) {
Length: 1, Length: 1,
Type: &client.TextEntityTypeBold{}, Type: &client.TextEntityTypeBold{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "a*b*_*cd*e_" { if markup != "a*b*_*cd*e_" {
t.Errorf("Wrong intersecting formatting: %v", markup) t.Errorf("Wrong intersecting formatting: %v", markup)
} }
@ -361,7 +361,7 @@ func TestFormattingXEP0393InlineCode(t *testing.T) {
Length: 25, Length: 25,
Type: &client.TextEntityTypePre{}, Type: &client.TextEntityTypePre{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "Is `Gajim` a thing?\n\n```\necho 'Hello'\necho 'world'\n```\n\nhruck(" { if markup != "Is `Gajim` a thing?\n\n```\necho 'Hello'\necho 'world'\n```\n\nhruck(" {
t.Errorf("Wrong intersecting formatting: %v", markup) t.Errorf("Wrong intersecting formatting: %v", markup)
} }
@ -374,7 +374,7 @@ func TestFormattingMarkdownStrikethrough(t *testing.T) {
Length: 3, Length: 3,
Type: &client.TextEntityTypeStrikethrough{}, Type: &client.TextEntityTypeStrikethrough{},
}, },
}, EntityToMarkdown) }, MarkupModeMarkdown)
if markup != "Everyone ~~dis~~likes cake." { if markup != "Everyone ~~dis~~likes cake." {
t.Errorf("Wrong strikethrough formatting: %v", markup) t.Errorf("Wrong strikethrough formatting: %v", markup)
} }
@ -387,14 +387,14 @@ func TestFormattingXEP0393Strikethrough(t *testing.T) {
Length: 3, Length: 3,
Type: &client.TextEntityTypeStrikethrough{}, Type: &client.TextEntityTypeStrikethrough{},
}, },
}, EntityToXEP0393) }, MarkupModeXEP0393)
if markup != "Everyone ~dis~likes cake." { if markup != "Everyone ~dis~likes cake." {
t.Errorf("Wrong strikethrough formatting: %v", markup) t.Errorf("Wrong strikethrough formatting: %v", markup)
} }
} }
func TestClaspLeft(t *testing.T) { func TestClaspLeft(t *testing.T) {
text := "a b c" text := textToDoubledRunes("a b c")
entities := []*client.TextEntity{ entities := []*client.TextEntity{
&client.TextEntity{ &client.TextEntity{
Offset: 1, Offset: 1,
@ -409,7 +409,7 @@ func TestClaspLeft(t *testing.T) {
} }
func TestClaspBoth(t *testing.T) { func TestClaspBoth(t *testing.T) {
text := "a b c" text := textToDoubledRunes("a b c")
entities := []*client.TextEntity{ entities := []*client.TextEntity{
&client.TextEntity{ &client.TextEntity{
Offset: 1, Offset: 1,
@ -424,7 +424,7 @@ func TestClaspBoth(t *testing.T) {
} }
func TestClaspNotNeeded(t *testing.T) { func TestClaspNotNeeded(t *testing.T) {
text := " abc " text := textToDoubledRunes(" abc ")
entities := []*client.TextEntity{ entities := []*client.TextEntity{
&client.TextEntity{ &client.TextEntity{
Offset: 1, Offset: 1,
@ -439,7 +439,7 @@ func TestClaspNotNeeded(t *testing.T) {
} }
func TestClaspNested(t *testing.T) { func TestClaspNested(t *testing.T) {
text := "a b c" text := textToDoubledRunes("a b c")
entities := []*client.TextEntity{ entities := []*client.TextEntity{
&client.TextEntity{ &client.TextEntity{
Offset: 1, Offset: 1,
@ -459,7 +459,7 @@ func TestClaspNested(t *testing.T) {
} }
func TestClaspEmoji(t *testing.T) { func TestClaspEmoji(t *testing.T) {
text := "a 🐖 c" text := textToDoubledRunes("a 🐖 c")
entities := []*client.TextEntity{ entities := []*client.TextEntity{
&client.TextEntity{ &client.TextEntity{
Offset: 1, Offset: 1,
@ -472,3 +472,111 @@ func TestClaspEmoji(t *testing.T) {
t.Errorf("Wrong claspemoji: %#v", entities) 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)
}
}

View file

@ -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() { func (c *Client) updateHandler() {
listener := c.client.GetListener() listener := c.client.GetListener()
defer listener.Close() defer listener.Close()
@ -141,6 +166,12 @@ func (c *Client) updateHandler() {
uhOh() uhOh()
} }
c.updateChatTitle(typedUpdate) c.updateChatTitle(typedUpdate)
case client.TypeUpdateChatReadOutbox:
typedUpdate, ok := update.(*client.UpdateChatReadOutbox)
if !ok {
uhOh()
}
c.updateChatReadOutbox(typedUpdate)
default: default:
// log only handled types // log only handled types
continue continue
@ -204,6 +235,9 @@ func (c *Client) updateChatLastMessage(update *client.UpdateChatLastMessage) {
// message received // message received
func (c *Client) updateNewMessage(update *client.UpdateNewMessage) { func (c *Client) updateNewMessage(update *client.UpdateNewMessage) {
chatId := update.Message.ChatId chatId := update.Message.ChatId
if c.Session.IsChatIgnored(chatId) {
return
}
// guarantee sequential message delivering per chat // guarantee sequential message delivering per chat
lock := c.getChatMessageLock(chatId) lock := c.getChatMessageLock(chatId)
@ -211,6 +245,8 @@ func (c *Client) updateNewMessage(update *client.UpdateNewMessage) {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
c.updateLastMessageHash(update.Message.ChatId, update.Message.Id, update.Message.Content)
// ignore self outgoing messages // ignore self outgoing messages
if update.Message.IsOutgoing && if update.Message.IsOutgoing &&
update.Message.SendingState != nil && update.Message.SendingState != nil &&
@ -223,23 +259,31 @@ func (c *Client) updateNewMessage(update *client.UpdateNewMessage) {
}).Warn("New message from chat") }).Warn("New message from chat")
c.ProcessIncomingMessage(chatId, update.Message) c.ProcessIncomingMessage(chatId, update.Message)
c.updateLastMessageHash(update.Message.ChatId, update.Message.Id, update.Message.Content)
}() }()
} }
// message content updated // message content updated
func (c *Client) updateMessageContent(update *client.UpdateMessageContent) { func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
if c.Session.IsChatIgnored(update.ChatId) {
return
}
markupFunction := c.getFormatter() markupFunction := c.getFormatter()
defer c.updateLastMessageHash(update.ChatId, update.MessageId, update.NewContent) 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.Lock()
c.SendMessageLock.Unlock() 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 var ignoredResource string
if err == nil { if xmppIdErr == nil {
ignoredResource = c.popFromOutbox(xmppId) ignoredResource = c.popFromEditOutbox(xmppId)
} else { } else {
log.Infof("Couldn't retrieve XMPP message ids for %v, an echo may happen", update.MessageId) log.Infof("Couldn't retrieve XMPP message ids for %v, an echo may happen", update.MessageId)
} }
@ -253,19 +297,62 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
if update.NewContent.MessageContentType() == client.TypeMessageText && c.hasLastMessageHashChanged(update.ChatId, update.MessageId, update.NewContent) { if update.NewContent.MessageContentType() == client.TypeMessageText && c.hasLastMessageHashChanged(update.ChatId, update.MessageId, update.NewContent) {
textContent := update.NewContent.(*client.MessageText) textContent := update.NewContent.(*client.MessageText)
var editChar string log.Debugf("textContent: %#v", textContent.Text)
if c.Session.AsciiArrows {
editChar = "e " var replaceId string
} else { sId := strconv.FormatInt(update.MessageId, 10)
editChar = "✎ " 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.Text,
textContent.Text.Entities, textContent.Text.Entities,
markupFunction, markupFunction,
)) ))
sChatId := strconv.FormatInt(update.ChatId, 10)
for _, jid := range jids { for _, jid := range jids {
gateway.SendMessage(jid, strconv.FormatInt(update.ChatId, 10), text, "e"+strconv.FormatInt(update.MessageId, 10), c.xmpp, nil, false) gateway.SendMessage(jid, sChatId, text.String(), "e"+sId, c.xmpp, nil, replaceId, isCarbon, false)
} }
} }
} }
@ -273,6 +360,10 @@ func (c *Client) updateMessageContent(update *client.UpdateMessageContent) {
// message(s) deleted // message(s) deleted
func (c *Client) updateDeleteMessages(update *client.UpdateDeleteMessages) { func (c *Client) updateDeleteMessages(update *client.UpdateDeleteMessages) {
if update.IsPermanent { if update.IsPermanent {
if c.Session.IsChatIgnored(update.ChatId) {
return
}
var deleteChar string var deleteChar string
if c.Session.AsciiArrows { if c.Session.AsciiArrows {
deleteChar = "X " deleteChar = "X "
@ -294,19 +385,25 @@ func (c *Client) updateAuthorizationState(update *client.UpdateAuthorizationStat
} }
} }
// clean uploaded files
func (c *Client) updateMessageSendSucceeded(update *client.UpdateMessageSendSucceeded) { 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) 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 { 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()) 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) file, _ := c.contentToFile(update.Message.Content)
if file != nil && file.Local != nil { if file != nil && file.Local != nil {
c.cleanTempFile(file.Local.Path) c.cleanTempFile(file.Local.Path)
} }
} }
func (c *Client) updateMessageSendFailed(update *client.UpdateMessageSendFailed) { func (c *Client) updateMessageSendFailed(update *client.UpdateMessageSendFailed) {
// clean uploaded files
file, _ := c.contentToFile(update.Message.Content) file, _ := c.contentToFile(update.Message.Content)
if file != nil && file.Local != nil { if file != nil && file.Local != nil {
c.cleanTempFile(file.Local.Path) c.cleanTempFile(file.Local.Path)
@ -328,3 +425,7 @@ func (c *Client) updateChatTitle(update *client.UpdateChatTitle) {
chat.Title = update.Title chat.Title = update.Title
} }
} }
func (c *Client) updateChatReadOutbox(update *client.UpdateChatReadOutbox) {
c.sendMarker(update.ChatId, update.LastReadOutboxMessageId, gateway.MarkerTypeDisplayed)
}

View file

@ -16,6 +16,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"dev.narayana.im/narayana/telegabber/telegram/cache" "dev.narayana.im/narayana/telegabber/telegram/cache"
"dev.narayana.im/narayana/telegabber/telegram/formatter" "dev.narayana.im/narayana/telegabber/telegram/formatter"
@ -36,13 +37,21 @@ type VCardInfo struct {
Info string Info string
} }
type messageStub struct {
MessageId int64
ChatId int64
Sender string
Date int32
Text string
}
var errOffline = errors.New("TDlib instance is offline") var errOffline = errors.New("TDlib instance is offline")
var spaceRegex = regexp.MustCompile(`\s+`) var spaceRegex = regexp.MustCompile(`\s+`)
var replyRegex = regexp.MustCompile("\\A>>? ?([0-9]+)\\n") var replyRegex = regexp.MustCompile("\\A>>? ?([0-9]+)\\n")
const newlineChar string = "\n" const newlineChar string = "\n"
const messageHeaderSeparator string = " | " const messageHeaderSeparator string = " | " // no hrunicode allowed here yet
// GetContactByUsername resolves username to user id retrieves user and chat information // GetContactByUsername resolves username to user id retrieves user and chat information
func (c *Client) GetContactByUsername(username string) (*client.Chat, *client.User, error) { func (c *Client) GetContactByUsername(username string) (*client.Chat, *client.User, error) {
@ -249,15 +258,15 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
presenceType = typ presenceType = typ
} }
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"show": show, "show": show,
"status": status, "status": status,
"presenceType": presenceType, "presenceType": presenceType,
}).Debug("Cached status") }).Debug("Cached status")
} else if user != nil && user.Status != nil { } else if user != nil && user.Status != nil {
show, status, presenceType = c.userStatusToText(user.Status, chatID) show, status, presenceType = c.userStatusToText(user.Status, chatID)
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"show": show, "show": show,
"status": status, "status": status,
"presenceType": presenceType, "presenceType": presenceType,
}).Debug("Status to text") }).Debug("Status to text")
} else { } else {
@ -272,22 +281,17 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
c.cache.SetStatus(chatID, cacheShow, status) c.cache.SetStatus(chatID, cacheShow, status)
newArgs := []args.V{ newArgs := []args.V{
gateway.SPFrom(strconv.FormatInt(chatID, 10)),
gateway.SPShow(show), gateway.SPShow(show),
gateway.SPStatus(status), gateway.SPStatus(status),
gateway.SPPhoto(photo), gateway.SPPhoto(photo),
gateway.SPResource(gateway.Jid.Resource),
gateway.SPImmed(gateway.SPImmed.Get(oldArgs)), gateway.SPImmed(gateway.SPImmed.Get(oldArgs)),
} }
newArgs = gateway.SPAppendFrom(newArgs, chatID)
if presenceType != "" { if presenceType != "" {
newArgs = append(newArgs, gateway.SPType(presenceType)) newArgs = append(newArgs, gateway.SPType(presenceType))
} }
return gateway.SendPresence( return c.sendPresence(newArgs...)
c.xmpp,
c.jid,
newArgs...,
)
} }
func (c *Client) formatContact(chatID int64) string { func (c *Client) formatContact(chatID int64) string {
@ -341,25 +345,74 @@ func (c *Client) formatSender(message *client.Message) string {
return c.formatContact(c.getSenderId(message)) return c.formatContact(c.getSenderId(message))
} }
func (c *Client) getMessageReply(message *client.Message) (reply *gateway.Reply, replyMsg *client.Message) { func (c *Client) messageToStub(message *client.Message, preview bool, text string) *messageStub {
if message.ReplyToMessageId != 0 { if text == "" {
var err error text = c.messageContentToText(message.Content, message.ChatId, preview)
replyMsg, err = c.client.GetMessage(&client.GetMessageRequest{ }
ChatId: message.ChatId, return &messageStub{
MessageId: message.ReplyToMessageId, MessageId: message.Id,
}) ChatId: message.ChatId,
if err != nil { Sender: c.formatSender(message),
log.Errorf("<error fetching message: %s>", err.Error()) Date: message.Date,
return Text: text,
} }
}
replyId, err := gateway.IdsDB.GetByTgIds(c.Session.Login, c.jid, message.ChatId, message.ReplyToMessageId) func (c *Client) getMessageReply(message *client.Message, preview bool, noContent bool) (gatewayReply *gateway.Reply, tgReply *messageStub) {
if err != nil { if message.ReplyTo != nil && message.ReplyTo.MessageReplyToType() == client.TypeMessageReplyToMessage {
replyId = strconv.FormatInt(message.ReplyToMessageId, 10) 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{ if message.ChatId == replyTo.ChatId {
Author: fmt.Sprintf("%v@%s", c.getSenderId(replyMsg), gateway.Jid.Full()), // obtain message from this chat
Id: replyId, replyMsg, err := c.client.GetMessage(&client.GetMessageRequest{
ChatId: message.ChatId,
MessageId: replyTo.MessageId,
})
if err != nil {
log.Errorf("<error fetching message: %s>", 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.getSenderId(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,
}
} }
} }
@ -382,9 +435,16 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess
return "" return ""
} }
return c.formatMessageContent(preview, c.messageToStub(message, preview, ""))
}
func (c *Client) formatMessageContent(preview bool, message *messageStub) string {
var str strings.Builder var str strings.Builder
// add messageid and sender // 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 // add date
if !preview { if !preview {
str.WriteString( str.WriteString(
@ -395,10 +455,7 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess
} }
// text message // text message
var text string text := message.Text
if message.Content != nil {
text = c.messageToText(message, preview)
}
if text != "" { if text != "" {
if !preview { if !preview {
str.WriteString(text) str.WriteString(text)
@ -415,33 +472,33 @@ func (c *Client) formatMessage(chatID int64, messageID int64, preview bool, mess
return str.String() return str.String()
} }
func (c *Client) formatForward(fwd *client.MessageForwardInfo) string { func (c *Client) formatOrigin(origin client.MessageOrigin) string {
switch fwd.Origin.MessageForwardOriginType() { if origin == nil {
case client.TypeMessageForwardOriginUser: return ""
originUser := fwd.Origin.(*client.MessageForwardOriginUser) }
switch origin.MessageOriginType() {
case client.TypeMessageOriginUser:
originUser := origin.(*client.MessageOriginUser)
return c.formatContact(originUser.SenderUserId) return c.formatContact(originUser.SenderUserId)
case client.TypeMessageForwardOriginChat: case client.TypeMessageOriginChat:
originChat := fwd.Origin.(*client.MessageForwardOriginChat) originChat := origin.(*client.MessageOriginChat)
var signature string var signature string
if originChat.AuthorSignature != "" { if originChat.AuthorSignature != "" {
signature = fmt.Sprintf(" (%s)", originChat.AuthorSignature) signature = fmt.Sprintf(" (%s)", originChat.AuthorSignature)
} }
return c.formatContact(originChat.SenderChatId) + signature return c.formatContact(originChat.SenderChatId) + signature
case client.TypeMessageForwardOriginHiddenUser: case client.TypeMessageOriginHiddenUser:
originUser := fwd.Origin.(*client.MessageForwardOriginHiddenUser) originUser := origin.(*client.MessageOriginHiddenUser)
return originUser.SenderName return originUser.SenderName
case client.TypeMessageForwardOriginChannel: case client.TypeMessageOriginChannel:
channel := fwd.Origin.(*client.MessageForwardOriginChannel) channel := origin.(*client.MessageOriginChannel)
var signature string var signature string
if channel.AuthorSignature != "" { if channel.AuthorSignature != "" {
signature = fmt.Sprintf(" (%s)", channel.AuthorSignature) signature = fmt.Sprintf(" (%s)", channel.AuthorSignature)
} }
return c.formatContact(channel.ChatId) + signature return c.formatContact(channel.ChatId) + signature
case client.TypeMessageForwardOriginMessageImport:
originImport := fwd.Origin.(*client.MessageForwardOriginMessageImport)
return originImport.SenderName
} }
return "Unknown forward type" return "Unknown origin type"
} }
func (c *Client) formatFile(file *client.File, compact bool) (string, string) { func (c *Client) formatFile(file *client.File, compact bool) (string, string) {
@ -587,20 +644,24 @@ func (c *Client) messageToText(message *client.Message, preview bool) string {
return "<empty message>" return "<empty message>"
} }
markupFunction := c.getFormatter() return c.messageContentToText(message.Content, message.ChatId, preview)
switch message.Content.MessageContentType() { }
func (c *Client) messageContentToText(content client.MessageContent, chatId int64, preview bool) string {
markupMode := c.getFormatter()
switch content.MessageContentType() {
case client.TypeMessageSticker: case client.TypeMessageSticker:
sticker, _ := message.Content.(*client.MessageSticker) sticker, _ := content.(*client.MessageSticker)
return sticker.Sticker.Emoji return sticker.Sticker.Emoji
case client.TypeMessageAnimatedEmoji: case client.TypeMessageAnimatedEmoji:
animatedEmoji, _ := message.Content.(*client.MessageAnimatedEmoji) animatedEmoji, _ := content.(*client.MessageAnimatedEmoji)
return animatedEmoji.Emoji return animatedEmoji.Emoji
case client.TypeMessageBasicGroupChatCreate, client.TypeMessageSupergroupChatCreate: case client.TypeMessageBasicGroupChatCreate, client.TypeMessageSupergroupChatCreate:
return "has created chat" return "has created chat"
case client.TypeMessageChatJoinByLink: case client.TypeMessageChatJoinByLink:
return "joined chat via invite link" return "joined chat via invite link"
case client.TypeMessageChatAddMembers: case client.TypeMessageChatAddMembers:
addMembers, _ := message.Content.(*client.MessageChatAddMembers) addMembers, _ := content.(*client.MessageChatAddMembers)
text := "invited " text := "invited "
if len(addMembers.MemberUserIds) > 0 { if len(addMembers.MemberUserIds) > 0 {
@ -609,19 +670,19 @@ func (c *Client) messageToText(message *client.Message, preview bool) string {
return text return text
case client.TypeMessageChatDeleteMember: case client.TypeMessageChatDeleteMember:
deleteMember, _ := message.Content.(*client.MessageChatDeleteMember) deleteMember, _ := content.(*client.MessageChatDeleteMember)
return "kicked " + c.formatContact(deleteMember.UserId) return "kicked " + c.formatContact(deleteMember.UserId)
case client.TypeMessagePinMessage: case client.TypeMessagePinMessage:
pinMessage, _ := message.Content.(*client.MessagePinMessage) pinMessage, _ := content.(*client.MessagePinMessage)
return "pinned message: " + c.formatMessage(message.ChatId, pinMessage.MessageId, preview, nil) return "pinned message: " + c.formatMessage(chatId, pinMessage.MessageId, preview, nil)
case client.TypeMessageChatChangeTitle: case client.TypeMessageChatChangeTitle:
changeTitle, _ := message.Content.(*client.MessageChatChangeTitle) changeTitle, _ := content.(*client.MessageChatChangeTitle)
return "chat title set to: " + changeTitle.Title return "chat title set to: " + changeTitle.Title
case client.TypeMessageLocation: case client.TypeMessageLocation:
location, _ := message.Content.(*client.MessageLocation) location, _ := content.(*client.MessageLocation)
return c.formatLocation(location.Location) return c.formatLocation(location.Location)
case client.TypeMessageVenue: case client.TypeMessageVenue:
venue, _ := message.Content.(*client.MessageVenue) venue, _ := content.(*client.MessageVenue)
if preview { if preview {
return venue.Venue.Title return venue.Venue.Title
} else { } else {
@ -633,86 +694,86 @@ func (c *Client) messageToText(message *client.Message, preview bool) string {
) )
} }
case client.TypeMessagePhoto: case client.TypeMessagePhoto:
photo, _ := message.Content.(*client.MessagePhoto) photo, _ := content.(*client.MessagePhoto)
if preview { if preview {
return photo.Caption.Text return photo.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
photo.Caption.Text, photo.Caption.Text,
photo.Caption.Entities, photo.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageAudio: case client.TypeMessageAudio:
audio, _ := message.Content.(*client.MessageAudio) audio, _ := content.(*client.MessageAudio)
if preview { if preview {
return audio.Caption.Text return audio.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
audio.Caption.Text, audio.Caption.Text,
audio.Caption.Entities, audio.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageVideo: case client.TypeMessageVideo:
video, _ := message.Content.(*client.MessageVideo) video, _ := content.(*client.MessageVideo)
if preview { if preview {
return video.Caption.Text return video.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
video.Caption.Text, video.Caption.Text,
video.Caption.Entities, video.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageDocument: case client.TypeMessageDocument:
document, _ := message.Content.(*client.MessageDocument) document, _ := content.(*client.MessageDocument)
if preview { if preview {
return document.Caption.Text return document.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
document.Caption.Text, document.Caption.Text,
document.Caption.Entities, document.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageText: case client.TypeMessageText:
text, _ := message.Content.(*client.MessageText) text, _ := content.(*client.MessageText)
if preview { if preview {
return text.Text.Text return text.Text.Text
} else { } else {
return formatter.Format( return formatter.Format(
text.Text.Text, text.Text.Text,
text.Text.Entities, text.Text.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageVoiceNote: case client.TypeMessageVoiceNote:
voice, _ := message.Content.(*client.MessageVoiceNote) voice, _ := content.(*client.MessageVoiceNote)
if preview { if preview {
return voice.Caption.Text return voice.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
voice.Caption.Text, voice.Caption.Text,
voice.Caption.Entities, voice.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageVideoNote: case client.TypeMessageVideoNote:
return "" return ""
case client.TypeMessageAnimation: case client.TypeMessageAnimation:
animation, _ := message.Content.(*client.MessageAnimation) animation, _ := content.(*client.MessageAnimation)
if preview { if preview {
return animation.Caption.Text return animation.Caption.Text
} else { } else {
return formatter.Format( return formatter.Format(
animation.Caption.Text, animation.Caption.Text,
animation.Caption.Entities, animation.Caption.Entities,
markupFunction, markupMode,
) )
} }
case client.TypeMessageContact: case client.TypeMessageContact:
contact, _ := message.Content.(*client.MessageContact) contact, _ := content.(*client.MessageContact)
if preview { if preview {
return contact.Contact.FirstName + " " + contact.Contact.LastName return contact.Contact.FirstName + " " + contact.Contact.LastName
} else { } else {
@ -730,10 +791,10 @@ func (c *Client) messageToText(message *client.Message, preview bool) string {
) )
} }
case client.TypeMessageDice: case client.TypeMessageDice:
dice, _ := message.Content.(*client.MessageDice) dice, _ := content.(*client.MessageDice)
return fmt.Sprintf("%s 1d6: [%v]", dice.Emoji, dice.Value) return fmt.Sprintf("%s 1d6: [%v]", dice.Emoji, dice.Value)
case client.TypeMessagePoll: case client.TypeMessagePoll:
poll, _ := message.Content.(*client.MessagePoll) poll, _ := content.(*client.MessagePoll)
if preview { if preview {
return poll.Poll.Question return poll.Poll.Question
@ -759,7 +820,7 @@ func (c *Client) messageToText(message *client.Message, preview bool) string {
return strings.Join(rows, "\n") return strings.Join(rows, "\n")
} }
case client.TypeMessageChatSetMessageAutoDeleteTime: case client.TypeMessageChatSetMessageAutoDeleteTime:
ttl, _ := message.Content.(*client.MessageChatSetMessageAutoDeleteTime) ttl, _ := content.(*client.MessageChatSetMessageAutoDeleteTime)
name := c.formatContact(ttl.FromUserId) name := c.formatContact(ttl.FromUserId)
if name == "" { if name == "" {
if ttl.MessageAutoDeleteTime == 0 { if ttl.MessageAutoDeleteTime == 0 {
@ -776,7 +837,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) { func (c *Client) contentToFile(content client.MessageContent) (*client.File, *client.File) {
@ -845,21 +906,23 @@ func (c *Client) contentToFile(content client.MessageContent) (*client.File, *cl
func (c *Client) countCharsInLines(lines *[]string) (count int) { func (c *Client) countCharsInLines(lines *[]string) (count int) {
for _, line := range *lines { for _, line := range *lines {
count += len(line) count += utf8.RuneCountInString(line)
} }
return return
} }
func (c *Client) messageToPrefix(message *client.Message, previewString string, fileString string, replyMsg *client.Message) (string, int, int) { 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, err := c.IsPM(message.ChatId) isPM, err := c.IsPM(message.ChatId)
if err != nil { if err != nil {
log.Errorf("Could not determine if chat is PM: %v", err) log.Errorf("Could not determine if chat is PM: %v", err)
} }
isCarbonsEnabled := gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons
// with carbons, hide for all messages in PM and only for outgoing in group chats // with carbons, hide for all messages in PM and only for outgoing in group chats
hideSender := isCarbonsEnabled && (message.IsOutgoing || isPM) hideSender := c.isCarbonsEnabled() && (message.IsOutgoing || isPM)
var replyStart, replyEnd int
prefix := []string{} prefix := []string{}
// message direction // message direction
var directionChar string var directionChar string
@ -888,20 +951,39 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string,
prefix = append(prefix, sender) prefix = append(prefix, sender)
} }
} }
// reply to // reply to
if message.ReplyToMessageId != 0 { var reply *gateway.Reply
if len(prefix) > 0 { if !suppressReply {
replyStart = c.countCharsInLines(&prefix) + (len(prefix)-1)*len(messageHeaderSeparator) preview := true
} gwReply, tgReply := c.getMessageReply(message, preview, false)
replyLine := "reply: " + c.formatMessage(message.ChatId, message.ReplyToMessageId, true, replyMsg)
prefix = append(prefix, replyLine) if tgReply != nil {
replyEnd = replyStart + len(replyLine) reply = gwReply
if len(prefix) > 0 {
replyEnd += len(messageHeaderSeparator) 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 { if message.ForwardInfo != nil {
prefix = append(prefix, "fwd: "+c.formatForward(message.ForwardInfo)) prefix = append(prefix, "fwd: "+c.formatOrigin(message.ForwardInfo.Origin))
} }
// preview // preview
if previewString != "" { if previewString != "" {
@ -912,7 +994,7 @@ func (c *Client) messageToPrefix(message *client.Message, previewString string,
prefix = append(prefix, "file: "+fileString) 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 { func (c *Client) ensureDownloadFile(file *client.File) *client.File {
@ -931,14 +1013,25 @@ func (c *Client) ensureDownloadFile(file *client.File) *client.File {
return 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 transfers a message to XMPP side and marks it as read on Telegram side // ProcessIncomingMessage transfers a message to XMPP side and marks it as read on Telegram side
func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) { func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) {
isCarbon := gateway.MessageOutgoingPermissionVersion > 0 && c.Session.Carbons && message.IsOutgoing isCarbon := c.isCarbonsEnabled() && message.IsOutgoing
jids := c.getCarbonFullJids(isCarbon, "") jids := c.getCarbonFullJids(isCarbon, "")
var text, oob, auxText string var text, oob, auxText string
var reply *gateway.Reply
reply, replyMsg := c.getMessageReply(message) var replyObtained bool
content := message.Content content := message.Content
if content != nil && content.MessageContentType() == client.TypeMessageChatChangePhoto { if content != nil && content.MessageContentType() == client.TypeMessageChatChangePhoto {
@ -965,56 +1058,72 @@ func (c *Client) ProcessIncomingMessage(chatId int64, message *client.Message) {
fileName, link := c.formatFile(file, false) fileName, link := c.formatFile(file, false)
oob = link oob = link
if c.Session.OOBMode && oob != "" { oobSwap := c.Session.OOBMode && oob != ""
typ := message.Content.MessageContentType()
if typ != client.TypeMessageSticker { var ignorePrefix bool
auxText = text 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 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) newText.WriteString(prefix)
if reply != nil {
reply.Start = uint64(replyStart)
reply.End = uint64(replyEnd)
}
if text != "" { if text != "" {
// \n if it is groupchat and message is not empty
if prefix != "" { if prefix != "" {
if chatId < 0 { newText.WriteString(c.getPrefixSeparator(chatId))
newText.WriteString("\n")
} else if chatId > 0 {
newText.WriteString(" | ")
}
} }
newText.WriteString(text) newText.WriteString(text)
} }
text = newText.String() text = newText.String()
} }
if oobSwap {
if !ignorePrefix {
auxText = text
}
text = oob
}
} }
} }
if !replyObtained {
reply, _ = c.getMessageReply(message, false, true)
}
// mark message as read // mark message as read
c.client.ViewMessages(&client.ViewMessagesRequest{ if !c.Session.Receipts {
ChatId: chatId, c.MarkAsRead(chatId, message.Id)
MessageIds: []int64{message.Id}, }
ForceRead: true,
})
// forward message to XMPP // forward message to XMPP
sId := strconv.FormatInt(message.Id, 10) sId := strconv.FormatInt(message.Id, 10)
sChatId := strconv.FormatInt(chatId, 10) sChatId := strconv.FormatInt(chatId, 10)
for _, jid := range jids { for _, jid := range jids {
gateway.SendMessageWithOOB(jid, sChatId, text, sId, c.xmpp, reply, oob, isCarbon) gateway.SendMessageWithOOB(jid, sChatId, text, sId, c.xmpp, reply, oob, "", isCarbon, c.Session.Receipts)
if auxText != "" { if auxText != "" {
gateway.SendMessage(jid, sChatId, auxText, sId, c.xmpp, reply, isCarbon) gateway.SendMessage(jid, sChatId, auxText, sId, c.xmpp, reply, "", isCarbon, c.Session.Receipts)
} }
} }
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 // PrepareMessageContent creates a simple text message
@ -1115,7 +1224,7 @@ func (c *Client) ProcessOutgoingMessage(chatID int64, text string, returnJid str
tgMessage, err := c.client.SendMessage(&client.SendMessageRequest{ tgMessage, err := c.client.SendMessage(&client.SendMessageRequest{
ChatId: chatID, ChatId: chatID,
ReplyToMessageId: reply, ReplyTo: &client.InputMessageReplyToMessage{MessageId: reply},
InputMessageContent: content, InputMessageContent: content,
}) })
if err != nil { if err != nil {
@ -1212,7 +1321,7 @@ func (c *Client) roster(resource string) {
c.ProcessStatusUpdate(chat, "", "") 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) c.addResource(resource)
} }
@ -1313,9 +1422,7 @@ func (c *Client) GetChatDescription(chat *client.Chat) string {
// subscribe to a Telegram ID // subscribe to a Telegram ID
func (c *Client) subscribeToID(id int64, chat *client.Chat) { func (c *Client) subscribeToID(id int64, chat *client.Chat) {
var args []args.V args := gateway.SimplePresence(id, "subscribe")
args = append(args, gateway.SPFrom(strconv.FormatInt(id, 10)))
args = append(args, gateway.SPType("subscribe"))
if chat == nil { if chat == nil {
chat, _, _ = c.GetContactByID(id, nil) chat, _, _ = c.GetContactByID(id, nil)
@ -1326,11 +1433,11 @@ func (c *Client) subscribeToID(id int64, chat *client.Chat) {
gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), chat.Title, c.xmpp) gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), chat.Title, c.xmpp)
} }
gateway.SendPresence( c.sendPresence(args...)
c.xmpp, }
c.jid,
args..., func (c *Client) sendPresence(args ...args.V) error {
) return gateway.SendPresence(c.xmpp, c.jid, args...)
} }
func (c *Client) prepareDiskSpace(size uint64) { func (c *Client) prepareDiskSpace(size uint64) {
@ -1379,9 +1486,9 @@ func (c *Client) UpdateChatNicknames() {
chat, ok := c.cache.GetChat(id) chat, ok := c.cache.GetChat(id)
if ok { if ok {
newArgs := []args.V{ newArgs := []args.V{
gateway.SPFrom(strconv.FormatInt(id, 10)),
gateway.SPNickname(chat.Title), gateway.SPNickname(chat.Title),
} }
newArgs = gateway.SPAppendFrom(newArgs, id)
cachedStatus, ok := c.cache.GetStatus(id) cachedStatus, ok := c.cache.GetStatus(id)
if ok { if ok {
@ -1392,17 +1499,34 @@ func (c *Client) UpdateChatNicknames() {
} }
} }
gateway.SendPresence( c.sendPresence(newArgs...)
c.xmpp,
c.jid,
newArgs...,
)
gateway.SetNickname(c.jid, strconv.FormatInt(id, 10), chat.Title, c.xmpp) 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 // AddToOutbox remembers the resource from which a message with given ID was sent
func (c *Client) AddToOutbox(xmppId, resource string) { func (c *Client) AddToOutbox(xmppId, resource string) {
c.locks.outboxLock.Lock() c.locks.outboxLock.Lock()
@ -1411,14 +1535,12 @@ func (c *Client) AddToOutbox(xmppId, resource string) {
c.outbox[xmppId] = resource c.outbox[xmppId] = resource
} }
func (c *Client) popFromOutbox(xmppId string) string { func (c *Client) getFromOutbox(xmppId string) string {
c.locks.outboxLock.Lock() c.locks.outboxLock.Lock()
defer c.locks.outboxLock.Unlock() defer c.locks.outboxLock.Unlock()
resource, ok := c.outbox[xmppId] resource, ok := c.outbox[xmppId]
if ok { if !ok {
delete(c.outbox, xmppId)
} else {
log.Warnf("No %v xmppId in outbox", xmppId) log.Warnf("No %v xmppId in outbox", xmppId)
} }
return resource return resource
@ -1493,8 +1615,23 @@ func (c *Client) hasLastMessageHashChanged(chatId, messageId int64, content clie
return !ok || oldHash != newHash return !ok || oldHash != newHash
} }
func (c *Client) getFormatter() func(*client.TextEntity) (*formatter.Insertion, *formatter.Insertion) { func (c *Client) UpdateLastChatMessageId(chatId int64, messageId string) {
return formatter.EntityToXEP0393 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 { func (c *Client) usernamesToString(usernames []string) string {

View file

@ -431,20 +431,17 @@ func TestMessageToPrefix1(t *testing.T) {
Id: 42, Id: 42,
IsOutgoing: true, IsOutgoing: true,
ForwardInfo: &client.MessageForwardInfo{ ForwardInfo: &client.MessageForwardInfo{
Origin: &client.MessageForwardOriginHiddenUser{ Origin: &client.MessageOriginHiddenUser{
SenderName: "ziz", 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" { if prefix != "➡ 42 | fwd: ziz" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 0 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) t.Errorf("Reply is not nil: %v", gatewayReply)
}
if replyEnd != 0 {
t.Errorf("Wrong replyEnd: %v", replyEnd)
} }
} }
@ -452,20 +449,17 @@ func TestMessageToPrefix2(t *testing.T) {
message := client.Message{ message := client.Message{
Id: 56, Id: 56,
ForwardInfo: &client.MessageForwardInfo{ ForwardInfo: &client.MessageForwardInfo{
Origin: &client.MessageForwardOriginChannel{ Origin: &client.MessageOriginChannel{
AuthorSignature: "zaz", 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" { if prefix != "⬅ 56 | fwd: (zaz) | preview: y.jpg" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 0 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) t.Errorf("Reply is not nil: %v", gatewayReply)
}
if replyEnd != 0 {
t.Errorf("Wrong replyEnd: %v", replyEnd)
} }
} }
@ -473,20 +467,17 @@ func TestMessageToPrefix3(t *testing.T) {
message := client.Message{ message := client.Message{
Id: 56, Id: 56,
ForwardInfo: &client.MessageForwardInfo{ ForwardInfo: &client.MessageForwardInfo{
Origin: &client.MessageForwardOriginChannel{ Origin: &client.MessageOriginChannel{
AuthorSignature: "zuz", 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" { if prefix != "< 56 | fwd: (zuz) | file: a.jpg" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 0 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) t.Errorf("Reply is not nil: %v", gatewayReply)
}
if replyEnd != 0 {
t.Errorf("Wrong replyEnd: %v", replyEnd)
} }
} }
@ -495,15 +486,12 @@ func TestMessageToPrefix4(t *testing.T) {
Id: 23, Id: 23,
IsOutgoing: true, 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" { if prefix != "> 23" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 0 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) t.Errorf("Reply is not nil: %v", gatewayReply)
}
if replyEnd != 0 {
t.Errorf("Wrong replyEnd: %v", replyEnd)
} }
} }
@ -511,46 +499,95 @@ func TestMessageToPrefix5(t *testing.T) {
message := client.Message{ message := client.Message{
Id: 560, Id: 560,
ForwardInfo: &client.MessageForwardInfo{ ForwardInfo: &client.MessageForwardInfo{
Origin: &client.MessageForwardOriginChat{ Origin: &client.MessageOriginChat{
AuthorSignature: "zyz", 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" { if prefix != "< 560 | fwd: (zyz) | preview: h.jpg | file: a.jpg" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 0 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) t.Errorf("Reply is not nil: %v", gatewayReply)
}
if replyEnd != 0 {
t.Errorf("Wrong replyEnd: %v", replyEnd)
} }
} }
func TestMessageToPrefix6(t *testing.T) { func TestMessageToPrefix6(t *testing.T) {
message := client.Message{ message := client.Message{
Id: 23, Id: 23,
IsOutgoing: true, ChatId: 25,
ReplyToMessageId: 42, IsOutgoing: true,
} ReplyTo: &client.MessageReplyToMessage{
reply := client.Message{ ChatId: 41,
Id: 42, Quote: &client.TextQuote{
Content: &client.MessageText{ Text: &client.FormattedText{
Text: &client.FormattedText{ Text: "tist\nuz\niz",
Text: "tist", },
},
Origin: &client.MessageOriginHiddenUser{
SenderName: "ziz",
}, },
}, },
} }
prefix, replyStart, replyEnd := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", &reply) prefix, gatewayReply := (&Client{Session: &persistence.Session{AsciiArrows: true}}).messageToPrefix(&message, "", "", false)
if prefix != "> 23 | reply: 42 | | tist" { if prefix != "> 23 | reply: ziz @ unknown contact: TDlib instance is offline | tist uz iz" {
t.Errorf("Wrong prefix: %v", prefix) t.Errorf("Wrong prefix: %v", prefix)
} }
if replyStart != 4 { if gatewayReply != nil {
t.Errorf("Wrong replyStart: %v", replyStart) 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)
} }
} }

View file

@ -3,12 +3,14 @@ package gateway
import ( import (
"encoding/xml" "encoding/xml"
"github.com/pkg/errors" "github.com/pkg/errors"
"strconv"
"strings" "strings"
"sync" "sync"
"dev.narayana.im/narayana/telegabber/badger" "dev.narayana.im/narayana/telegabber/badger"
"dev.narayana.im/narayana/telegabber/xmpp/extensions" "dev.narayana.im/narayana/telegabber/xmpp/extensions"
"github.com/google/uuid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/soheilhy/args" "github.com/soheilhy/args"
"gosrc.io/xmpp" "gosrc.io/xmpp"
@ -22,6 +24,18 @@ type Reply struct {
End uint64 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 NSNick string = "http://jabber.org/protocol/nick"
// Queue stores presences to send later // Queue stores presences to send later
@ -42,26 +56,42 @@ var DirtySessions = false
var MessageOutgoingPermissionVersion = 0 var MessageOutgoingPermissionVersion = 0
// SendMessage creates and sends a message stanza // SendMessage creates and sends a message stanza
func SendMessage(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, isCarbon bool) { func SendMessage(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, replaceId string, isCarbon, requestReceipt bool) {
sendMessageWrapper(to, from, body, id, component, reply, "", isCarbon) sendMessageWrapper(to, from, body, id, component, reply, nil, "", replaceId, isCarbon, requestReceipt)
} }
// SendServiceMessage creates and sends a simple message stanza from transport // SendServiceMessage creates and sends a simple message stanza from transport
func SendServiceMessage(to string, body string, component *xmpp.Component) { func SendServiceMessage(to string, body string, component *xmpp.Component) {
sendMessageWrapper(to, "", body, "", component, nil, "", false) var id string
if uuid, err := uuid.NewRandom(); err == nil {
id = uuid.String()
}
sendMessageWrapper(to, "", body, id, component, nil, nil, "", "", false, false)
} }
// SendTextMessage creates and sends a simple message stanza // SendTextMessage creates and sends a simple message stanza
func SendTextMessage(to string, from string, body string, component *xmpp.Component) { func SendTextMessage(to string, from string, body string, component *xmpp.Component) {
sendMessageWrapper(to, from, body, "", component, nil, "", false) var id string
if uuid, err := uuid.NewRandom(); err == nil {
id = uuid.String()
}
sendMessageWrapper(to, from, body, id, component, nil, nil, "", "", false, false)
} }
// SendMessageWithOOB creates and sends a message stanza with OOB URL // SendMessageWithOOB creates and sends a message stanza with OOB URL
func SendMessageWithOOB(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, oob string, isCarbon bool) { func SendMessageWithOOB(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, oob, replaceId string, isCarbon, requestReceipt bool) {
sendMessageWrapper(to, from, body, id, component, reply, oob, isCarbon) sendMessageWrapper(to, from, body, id, component, reply, nil, oob, replaceId, isCarbon, requestReceipt)
} }
func sendMessageWrapper(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, oob string, isCarbon bool) { // 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,
}, "", "", false, false)
}
func sendMessageWrapper(to string, from string, body string, id string, component *xmpp.Component, reply *Reply, marker *marker, oob, replaceId string, isCarbon, requestReceipt bool) {
toJid, err := stanza.NewJid(to) toJid, err := stanza.NewJid(to)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
@ -119,9 +149,23 @@ func sendMessageWrapper(to string, from string, body string, id string, componen
message.Extensions = append(message.Extensions, extensions.NewReplyFallback(reply.Start, reply.End)) message.Extensions = append(message.Extensions, extensions.NewReplyFallback(reply.Start, reply.End))
} }
} }
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 !isCarbon && toJid.Resource != "" { if !isCarbon && toJid.Resource != "" {
message.Extensions = append(message.Extensions, stanza.HintNoCopy{}) message.Extensions = append(message.Extensions, stanza.HintNoCopy{})
} }
if requestReceipt {
message.Extensions = append(message.Extensions, stanza.Markable{})
}
if replaceId != "" {
message.Extensions = append(message.Extensions, extensions.Replace{Id: replaceId})
}
if isCarbon { if isCarbon {
carbonMessage := extensions.ClientMessage{ carbonMessage := extensions.ClientMessage{
@ -343,6 +387,20 @@ func SendPresence(component *xmpp.Component, to string, args ...args.V) error {
return nil 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 // ResumableSend tries to resume the connection once and sends the packet again
func ResumableSend(component *xmpp.Component, packet stanza.Packet) error { func ResumableSend(component *xmpp.Component, packet stanza.Packet) error {
err := component.Send(packet) err := component.Send(packet)

View file

@ -199,10 +199,13 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
if err != nil { if err != nil {
log.Errorf("Failed to replace id %v with %v %v", replace.Id, msg.Id, tgMessageId) 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 { } else {
err = gateway.IdsDB.Set(session.Session.Login, bare, toID, tgMessageId, msg.Id) err = gateway.IdsDB.Set(session.Session.Login, bare, toID, tgMessageId, 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, tgMessageId, msg.Id) log.Errorf("Failed to save ids %v/%v %v", toID, tgMessageId, msg.Id)
} }
} }
@ -252,6 +255,30 @@ func HandleMessage(s xmpp.Sender, p stanza.Packet) {
gateway.MessageOutgoingPermissionVersion = 2 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" { if msg.Type == "error" {
@ -458,6 +485,8 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ) {
_, ok := toToID(iq.To) _, ok := toToID(iq.To)
if ok { if ok {
disco.AddIdentity("", "account", "registered") disco.AddIdentity("", "account", "registered")
disco.AddFeatures(stanza.NSMsgChatMarkers)
disco.AddFeatures(stanza.NSMsgReceipts)
} else { } else {
disco.AddIdentity("Telegram Gateway", "gateway", "telegram") disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
disco.AddFeatures("jabber:iq:register") disco.AddFeatures("jabber:iq:register")
@ -624,35 +653,35 @@ func iqAnswerSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code
switch code { switch code {
case 400: case 400:
answer.Error = &stanza.Err{ answer.Error = &stanza.Err{
Code: code, Code: code,
Type: stanza.ErrorTypeModify, Type: stanza.ErrorTypeModify,
Reason: "bad-request", Reason: "bad-request",
} }
case 405: case 405:
answer.Error = &stanza.Err{ answer.Error = &stanza.Err{
Code: code, Code: code,
Type: stanza.ErrorTypeCancel, Type: stanza.ErrorTypeCancel,
Reason: "not-allowed", Reason: "not-allowed",
Text: "Logging out is dangerous. If you are sure you would be able to receive the authentication code again, issue the /logout command to the transport", Text: "Logging out is dangerous. If you are sure you would be able to receive the authentication code again, issue the /logout command to the transport",
} }
case 406: case 406:
answer.Error = &stanza.Err{ answer.Error = &stanza.Err{
Code: code, Code: code,
Type: stanza.ErrorTypeModify, Type: stanza.ErrorTypeModify,
Reason: "not-acceptable", Reason: "not-acceptable",
Text: "Phone number already provided, chat with the transport for further instruction", Text: "Phone number already provided, chat with the transport for further instruction",
} }
case 500: case 500:
answer.Error = &stanza.Err{ answer.Error = &stanza.Err{
Code: code, Code: code,
Type: stanza.ErrorTypeWait, Type: stanza.ErrorTypeWait,
Reason: "internal-server-error", Reason: "internal-server-error",
} }
default: default:
log.Error("Unknown error code, falling back with empty reason") log.Error("Unknown error code, falling back with empty reason")
answer.Error = &stanza.Err{ answer.Error = &stanza.Err{
Code: code, Code: code,
Type: stanza.ErrorTypeCancel, Type: stanza.ErrorTypeCancel,
Reason: "undefined-condition", Reason: "undefined-condition",
} }
} }