From 386cfa4cfbcb1809d5779c8388a204546bea1dcf Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 10 Jan 2025 21:17:10 +0200 Subject: [PATCH] capabilities: update to new format --- go.mod | 8 +- go.sum | 12 +-- pkg/connector/capabilities.go | 187 ++++++++++++++++++++++++++++++++++ pkg/connector/chatinfo.go | 30 +++++- pkg/connector/client.go | 19 +--- pkg/connector/connector.go | 6 -- pkg/connector/matrix.go | 4 + pkg/connector/metadata.go | 7 +- pkg/connector/telegram.go | 17 ++++ 9 files changed, 254 insertions(+), 36 deletions(-) create mode 100644 pkg/connector/capabilities.go diff --git a/go.mod b/go.mod index 356db58c..2e6d7685 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module go.mau.fi/mautrix-telegram go 1.22.0 -toolchain go1.23.3 +toolchain go1.23.4 require ( github.com/gorilla/mux v1.8.0 @@ -10,12 +10,12 @@ require ( github.com/gotd/td v0.111.0 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 - go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a + go.mau.fi/util v0.8.4-0.20250110124612-64d4dbbec957 go.mau.fi/zerozap v0.1.1 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 - golang.org/x/net v0.33.0 - maunium.net/go/mautrix v0.22.2-0.20250109192415-9748015309bd + golang.org/x/net v0.34.0 + maunium.net/go/mautrix v0.22.2-0.20250110154103-bbcb1904e268 ) require ( diff --git a/go.sum b/go.sum index f74d66c7..5975db60 100644 --- a/go.sum +++ b/go.sum @@ -76,8 +76,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a h1:D9RCHBFjxah9F/YB7amvRJjT2IEOFWcz8jpcEY8dBV0= -go.mau.fi/util v0.8.4-0.20250106152331-30b8c95e7d7a/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= +go.mau.fi/util v0.8.4-0.20250110124612-64d4dbbec957 h1:tsLt3t6ARc55niz+JMgJy6U4sL210Z0K/nyxF09xT0E= +go.mau.fi/util v0.8.4-0.20250110124612-64d4dbbec957/go.mod h1:MOfGTs1CBuK6ERTcSL4lb5YU7/ujz09eOPVEDckuazY= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= go.mau.fi/zerozap v0.1.1 h1:mxE/dW4wtkqBYOXOEEzXldk5qKB+ahsZXjoTGnvEhZQ= @@ -98,8 +98,8 @@ golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= -golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= @@ -119,8 +119,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M= maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA= -maunium.net/go/mautrix v0.22.2-0.20250109192415-9748015309bd h1:v83tY7Tp8vF1/dOH8nP762eDwm5cAuijnj3sjrjYzvc= -maunium.net/go/mautrix v0.22.2-0.20250109192415-9748015309bd/go.mod h1:FmwzK7RSzrd1OfGDgJzFWXl7nYmYm8/P0Y77sy/A1Uw= +maunium.net/go/mautrix v0.22.2-0.20250110154103-bbcb1904e268 h1:p+3TofdhqiVYIkLjgzidayg2XriGUEbj+nbWs3/UQbk= +maunium.net/go/mautrix v0.22.2-0.20250110154103-bbcb1904e268/go.mod h1:07i96D7BALyuAqxFhRzvaId8FC9NABgRQBPY5HWndf4= nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y= nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go new file mode 100644 index 00000000..4c10edbc --- /dev/null +++ b/pkg/connector/capabilities.go @@ -0,0 +1,187 @@ +// mautrix-telegram - A Matrix-Telegram puppeting bridge. +// Copyright (C) 2025 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package connector + +import ( + "context" + "crypto/sha256" + "encoding/hex" + + "go.mau.fi/util/ptr" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/event" +) + +func (tg *TelegramConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { + return &bridgev2.NetworkGeneralCapabilities{ + DisappearingMessages: true, + } +} + +func (tg *TelegramConnector) GetBridgeInfoVersion() (info, capabilities int) { + return 1, 1 +} + +// TODO get these from getConfig instead of hardcoding? + +const MaxTextLength = 4096 +const MaxCaptionLength = 1024 +const MaxFileSize = 2 * 1024 * 1024 * 1024 + +var formattingCaps = event.FormattingFeatureMap{ + event.FmtBold: event.CapLevelFullySupported, + event.FmtItalic: event.CapLevelFullySupported, + event.FmtUnderline: event.CapLevelFullySupported, + event.FmtStrikethrough: event.CapLevelFullySupported, + event.FmtInlineCode: event.CapLevelFullySupported, + event.FmtCodeBlock: event.CapLevelFullySupported, + event.FmtSyntaxHighlighting: event.CapLevelFullySupported, + event.FmtBlockquote: event.CapLevelFullySupported, + event.FmtInlineLink: event.CapLevelFullySupported, + event.FmtUserLink: event.CapLevelFullySupported, + // TODO support room links and event links (convert to appropriate t.me links) + event.FmtUnorderedList: event.CapLevelPartialSupport, + event.FmtOrderedList: event.CapLevelPartialSupport, + event.FmtListStart: event.CapLevelPartialSupport, + event.FmtListJumpValue: event.CapLevelDropped, + // TODO support custom emojis in messages + event.FmtCustomEmoji: event.CapLevelDropped, + event.FmtSpoiler: event.CapLevelFullySupported, + event.FmtSpoilerReason: event.CapLevelDropped, + event.FmtHeaders: event.CapLevelPartialSupport, +} + +var fileCaps = event.FileFeatureMap{ + event.MsgImage: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/jpeg": event.CapLevelFullySupported, + "image/png": event.CapLevelPartialSupport, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: 10 * 1024 * 1024, + }, + event.MsgVideo: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "video/mp4": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: MaxFileSize, + }, + event.MsgAudio: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/mpeg": event.CapLevelFullySupported, + "audio/mp4": event.CapLevelFullySupported, + // TODO some other formats are probably supported too + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: MaxFileSize, + }, + event.MsgFile: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "*/*": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: MaxFileSize, + }, + event.CapMsgGIF: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/gif": event.CapLevelPartialSupport, + "video/mp4": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: MaxFileSize, + }, + event.CapMsgSticker: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "image/webp": event.CapLevelFullySupported, + // TODO + //"image/lottie+json": event.CapLevelFullySupported, + //"video/webm": event.CapLevelFullySupported, + }, + }, + event.CapMsgVoice: { + MimeTypes: map[string]event.CapabilitySupportLevel{ + "audio/ogg": event.CapLevelFullySupported, + "audio/mpeg": event.CapLevelFullySupported, + "audio/mp4": event.CapLevelFullySupported, + }, + Caption: event.CapLevelFullySupported, + MaxCaptionLength: MaxCaptionLength, + MaxSize: MaxFileSize, + }, +} +var premiumFileCaps event.FileFeatureMap + +func init() { + premiumFileCaps = make(event.FileFeatureMap, len(fileCaps)) + for k, v := range fileCaps { + cloned := ptr.Clone(v) + if k == event.MsgFile || k == event.MsgVideo || k == event.MsgAudio { + cloned.MaxSize *= 2 + } + cloned.MaxCaptionLength *= 2 + premiumFileCaps[k] = cloned + } +} + +func hashEmojiList(emojis []string) string { + hasher := sha256.New() + for _, emoji := range emojis { + hasher.Write([]byte(emoji)) + } + return hex.EncodeToString(hasher.Sum(nil))[:8] +} + +func (t *TelegramClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *event.RoomFeatures { + baseID := "fi.mau.telegram.capabilities.2025_01_10" + feat := &event.RoomFeatures{ + Formatting: formattingCaps, + File: fileCaps, + MaxTextLength: MaxTextLength, + LocationMessage: event.CapLevelFullySupported, + Reply: event.CapLevelFullySupported, + Edit: event.CapLevelFullySupported, + Delete: event.CapLevelFullySupported, + Reaction: event.CapLevelFullySupported, + ReadReceipts: true, + TypingNotifications: true, + } + // TODO non-admins can only edit messages within 48 hours + + reactions := portal.Metadata.(*PortalMetadata).AllowedReactions + if reactions == nil { + baseID += "+reactions_any" + feat.AllowedReactions, feat.CustomEmojiReactions = t.getAvailableReactionsForCapability(ctx) + } else if len(reactions) == 0 { + baseID += "+reactions_none" + feat.Reaction = event.CapLevelRejected + } else { + baseID += "+reactions_" + hashEmojiList(reactions) + feat.AllowedReactions = reactions + } + if t.isPremiumCache.Load() { + baseID += "+premium" + feat.File = premiumFileCaps + } + feat.ID = baseID + return feat +} diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 8edb7f90..daec48a6 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "fmt" + "slices" "time" "github.com/gotd/td/tg" @@ -124,7 +125,34 @@ func (t *TelegramClient) getGroupChatInfo(fullChat *tg.MessagesChatFull, chatID }, CanBackfill: true, ExtraUpdates: func(ctx context.Context, p *bridgev2.Portal) bool { - return p.Metadata.(*PortalMetadata).SetIsSuperGroup(isMegagroup) + meta := p.Metadata.(*PortalMetadata) + changed := meta.SetIsSuperGroup(isMegagroup) + + if reactions, ok := fullChat.FullChat.GetAvailableReactions(); ok { + switch typedReactions := reactions.(type) { + case *tg.ChatReactionsAll: + changed = meta.AllowedReactions != nil + meta.AllowedReactions = nil + case *tg.ChatReactionsNone: + changed = meta.AllowedReactions == nil || len(meta.AllowedReactions) > 0 + meta.AllowedReactions = []string{} + case *tg.ChatReactionsSome: + allowedReactions := make([]string, 0, len(typedReactions.Reactions)) + for _, react := range typedReactions.Reactions { + emoji, ok := react.(*tg.ReactionEmoji) + if ok { + allowedReactions = append(allowedReactions, emoji.Emoticon) + } + } + slices.Sort(allowedReactions) + if !slices.Equal(meta.AllowedReactions, allowedReactions) { + changed = true + meta.AllowedReactions = allowedReactions + } + } + } + + return changed }, } diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 11a1898d..5d26d5b4 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -58,6 +59,8 @@ type TelegramClient struct { availableReactions map[string]struct{} availableReactionsHash int availableReactionsFetched time.Time + availableReactionsList []string + isPremiumCache atomic.Bool telegramFmtParams *telegramfmt.FormatParams matrixParser *matrixfmt.HTMLParser @@ -717,22 +720,6 @@ func (t *TelegramClient) IsThisUser(ctx context.Context, userID networkid.UserID return userID == networkid.UserID(t.userLogin.ID) } -func (t *TelegramClient) GetCapabilities(ctx context.Context, portal *bridgev2.Portal) *bridgev2.NetworkRoomCapabilities { - return &bridgev2.NetworkRoomCapabilities{ - FormattedText: true, - UserMentions: true, - RoomMentions: true, // TODO? - LocationMessages: true, - Captions: true, - Threads: false, // TODO - Replies: true, - Edits: true, - Deletes: true, - ReadReceipts: true, - Reactions: true, - } -} - func (t *TelegramClient) mySender() bridgev2.EventSender { return bridgev2.EventSender{ IsFromMe: true, diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 8523ab81..ab696ad5 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -68,9 +68,3 @@ func (tg *TelegramConnector) GetName() bridgev2.BridgeName { DefaultCommandPrefix: "!tg", } } - -func (tg *TelegramConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities { - return &bridgev2.NetworkGeneralCapabilities{ - DisappearingMessages: true, - } -} diff --git a/pkg/connector/matrix.go b/pkg/connector/matrix.go index d0d13281..569eae41 100644 --- a/pkg/connector/matrix.go +++ b/pkg/connector/matrix.go @@ -106,6 +106,10 @@ func (t *TelegramClient) transferMediaToTelegram(ctx context.Context, content *e attributes = append(attributes, &tg.DocumentAttributeImageSize{W: content.Info.Width, H: content.Info.Height}) } + if content.Info != nil && content.Info.MauGIF { + attributes = append(attributes, &tg.DocumentAttributeAnimated{}) + } + if sticker { attributes = append(attributes, &tg.DocumentAttributeSticker{ Alt: content.Body, diff --git a/pkg/connector/metadata.go b/pkg/connector/metadata.go index 08835031..ef938531 100644 --- a/pkg/connector/metadata.go +++ b/pkg/connector/metadata.go @@ -29,9 +29,10 @@ type GhostMetadata struct { } type PortalMetadata struct { - IsSuperGroup bool `json:"is_supergroup,omitempty"` - ReadUpTo int `json:"read_up_to,omitempty"` - MessagesTTL int `json:"messages_ttl,omitempty"` + IsSuperGroup bool `json:"is_supergroup,omitempty"` + ReadUpTo int `json:"read_up_to,omitempty"` + MessagesTTL int `json:"messages_ttl,omitempty"` + AllowedReactions []string `json:"allowed_reactions"` } func (pm *PortalMetadata) SetIsSuperGroup(isSupergroup bool) (changed bool) { diff --git a/pkg/connector/telegram.go b/pkg/connector/telegram.go index 2480162d..d805d1bd 100644 --- a/pkg/connector/telegram.go +++ b/pkg/connector/telegram.go @@ -898,6 +898,14 @@ func (t *TelegramClient) getAppConfigCached(ctx context.Context) (map[string]any return t.appConfig, nil } +func (t *TelegramClient) getAvailableReactionsForCapability(ctx context.Context) ([]string, bool) { + _, err := t.getAvailableReactions(ctx) + if err != nil { + zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to get available reactions for capability listing") + } + return t.availableReactionsList, t.isPremiumCache.Load() +} + func (t *TelegramClient) getAvailableReactions(ctx context.Context) (map[string]struct{}, error) { log := zerolog.Ctx(ctx).With().Str("handler", "get_available_reactions").Logger() t.availableReactionsLock.Lock() @@ -929,6 +937,15 @@ func (t *TelegramClient) getAvailableReactions(ctx context.Context) (map[string] } t.availableReactionsHash = availableReactions.Hash + if myGhost.Metadata.(*GhostMetadata).IsPremium { + // All reactions are allowed via the unicodemojipack feature + t.availableReactionsList = nil + t.isPremiumCache.Store(true) + } else { + t.availableReactionsList = maps.Keys(t.availableReactions) + t.isPremiumCache.Store(false) + slices.Sort(t.availableReactionsList) + } case *tg.MessagesAvailableReactionsNotModified: log.Debug().Msg("Available reactions not modified") default: