Compare commits

..

25 Commits

Author SHA1 Message Date
Igor Artamonov ccb349f3d2 obfuscated2: only XOR bytes actually delivered on Read
Go / Lint (old) (push) Failing after 4m40s
Go / Lint (latest) (push) Failing after 4m39s
Go / Lint (old) (pull_request) Failing after 4m41s
Go / Lint (latest) (pull_request) Failing after 4m40s
Read called XORKeyStream(b, b) — XOR-ing the entire caller buffer even
when the underlying transport returned fewer bytes. AES-CTR's keystream
position is then advanced by len(b), but the peer only consumed n
bytes' worth of keystream. After a single short read the two
keystreams diverge for the lifetime of the connection, every
subsequent MTProto message decrypts to garbage, and the engine fails
with "consume message: decrypt: msg_key is invalid".

The faketls layer makes short reads routine: each Read returns at most
one TLS Application record's payload, regardless of how big the caller
buffer is. So in practice the stream desynced almost immediately on
high-traffic clients (active supergroups, post-relogin catch-up) and
intermittently on quiet ones.

Match the upstream gotd/td fix and only XOR the n bytes that came out
of the transport. Add a regression test (chunkConn delivers ciphertext
in 7-byte chunks; client reads through Obfuscated2.Read with a 128-byte
buffer; plaintext must round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:33:44 +03:00
Igor Artamonov 4768065e72 faketls: skip ChangeCipherSpec records on read
Go / Lint (old) (push) Failing after 4m40s
Go / Lint (latest) (push) Failing after 4m40s
Go / Lint (old) (pull_request) Failing after 4m42s
Go / Lint (latest) (pull_request) Failing after 4m39s
The Read path treated every TLS record's payload as application data
and wrote it into readBuf — including the 1-byte payload (0x01) of
ChangeCipherSpec records. mtg sends those records intermittently as a
TLS-compat keep-alive; once one arrived inside the data stream it
desynced the obfuscated2 CTR keystream by one byte. From that point
on every MTProto message decrypted to garbage and the engine failed
with "decrypt: msg_key is invalid", forcibly closed the connection,
and looped.

The Go switch cases for ChangeCipherSpec and Application were both
empty (no fallthrough, no continue), so control reached the
o.readBuf.Write(rec.Data) call below the switch for both — exactly
the wrong behaviour for CCS.

Reshape the loop so that:
  - ChangeCipherSpec records are silently dropped
  - Application records are written to readBuf and returned
  - Handshake / unsupported types still error out

This matches tdlib's TlsTransport (CCS is skipped at the TLS framing
layer and never reaches the MTProto decoder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:17:27 +03:00
Igor Artamonov 64bf6bfe90 faketls: emit GREASE bytes and a real padding extension
Go / Lint (old) (push) Failing after 4m39s
Go / Lint (latest) (push) Failing after 4m39s
Go / Lint (old) (pull_request) Failing after 4m39s
Go / Lint (latest) (pull_request) Failing after 4m42s
The ClientHello builder used a closure G(n) that was supposed to insert
two random GREASE bytes (RFC 8701, 0x?A?A pattern) at known positions
but expanded the buffer by zero. Every grease slot was therefore
omitted, and the trailing padding extension was written as a bare ext
id 0x0015 followed by raw zeros — its length field was never set.

Concretely, the old output looked structurally invalid to mtg's faketls
validator: the cipher list was off by two, supported_groups declared a
list_length larger than its body, and what should have been the padding
extension parsed as a stream of empty server_name extensions. mtg
responded with a fatal TLS Alert (description 50, decode_error) and
shut the connection.

Fix:
- generate seven distinct GREASE bytes per ClientHello, with the
  tdlib constraint grease[3] != grease[4]
- thread an io.Reader through writeClientHello so generation is
  deterministic in tests and keyed off the FakeTLS rand source in prod
- replace the trailing zero-pad with a proper padding extension whose
  length field is computed so the ClientHello is exactly 517 bytes

Add a regression test (structure_test.go) that feeds the result to
crypto/tls.Server: it must not return decode_error / malformed /
syntax errors. The previous output failed this; the new output passes.

The TestTLS golden vector is regenerated for the new layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:15:03 +03:00
Igor Artamonov aab48f0dbe faketls: include real record type and peek bytes on handshake errors
Go / Lint (old) (push) Failing after 4m43s
Go / Lint (latest) (push) Failing after 4m40s
Go / Lint (old) (pull_request) Failing after 4m40s
Go / Lint (latest) (pull_request) Failing after 4m43s
The previous error path used errors.Wrap(err, "unexpected record type")
inside type-mismatch branches where err was already nil. With
go-faster/errors that produced a wrapError with no cause and no detail,
making the user-visible message "unexpected record type" useless for
diagnostics — there was no way to tell what mtg actually sent.

Switch to errors.Errorf with the actual received byte and a 32-byte
hex peek of the read buffer. Also wrap the read-error path with the
same peek so a partial response is visible.

This is a diagnostic-only change; the parser semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:51:50 +03:00
Igor Artamonov b00e2d8955 connector: hex-decode mtproxy secret
Go / Lint (old) (push) Failing after 5m14s
Go / Lint (latest) (push) Failing after 5m19s
Go / Lint (old) (pull_request) Failing after 5m14s
Go / Lint (latest) (pull_request) Failing after 4m40s
dcs.MTProxy expects raw secret bytes. Carrying them verbatim through a
YAML string field is impossible: real secrets contain bytes >= 0x80
(faketls starts with 0xee, secured with 0xdd) which cannot survive a
unicode string round-trip, so the value reached the bridge corrupted or
empty (gotd then logged "invalid secret").

Accept the standard hex form printed by mtg/MTProxy tooling
(e.g. "ee" + 16-byte secret + cloak domain hex) and decode it before
handing the bytes to gotd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 10:14:58 +03:00
Tulir Asokan e3bb26aee1 handlematrix: allow bridging cached custom emoji reactions with any scheme 2026-04-30 16:55:03 +03:00
Tulir Asokan 7c2c72bbde imagepack: implement listing interface 2026-04-30 15:49:10 +03:00
Tulir Asokan 2ffbde7448 .github: add another item to bug report template 2026-04-30 13:23:26 +03:00
Tulir Asokan 2a0da7801a imagepack: move emoji shortcodes to go-util 2026-04-30 12:24:08 +03:00
Tulir Asokan eaf387abfe imagepack: switch to bridgev2 API for importing 2026-04-29 18:01:36 +03:00
Tulir Asokan 64d80c3d1d imagepack: populate cache when importing pack 2026-04-29 16:18:52 +03:00
Tulir Asokan c78b1abd2d imagepack: use emoji shortcode as fallback when importing packs 2026-04-29 14:51:38 +03:00
Tulir Asokan 12f900a7bd dependencies: update mautrix-go 2026-04-29 09:10:02 +03:00
Tulir Asokan cdb77f938a tomatrix: include external_url field in messages 2026-04-28 22:01:54 +03:00
Tulir Asokan 5a1a478992 matrixfmt: convert matrix.to links in other direction too 2026-04-28 21:46:07 +03:00
Tulir Asokan d2a06ebbbe capabilities: mark lottie and webm as allowed sticker formats 2026-04-28 16:09:13 +03:00
Tulir Asokan e6243d8935 imagepack: switch to new shared metadata field 2026-04-27 20:24:10 +03:00
Tulir Asokan 9e1c42a992 matrixfmt: fix trimming all-space entity string 2026-04-27 20:24:10 +03:00
Tulir Asokan 6eacf38d74 tomatrix: use extra field in info for custom fields 2026-04-27 20:24:10 +03:00
Gerardo Rodriguez 65fcf712d3 client: treat pool.ErrConnDead as transient in onPing (#1066) 2026-04-24 13:58:43 +03:00
Tulir Asokan 8512cfe6a6 commands/imagepack: include pack metadata in sticker info 2026-04-23 14:26:52 +03:00
Tulir Asokan 7a6d1bf17a dependencies: update mautrix-go 2026-04-20 23:22:06 +03:00
Tulir Asokan 18f831553d changelog: update 2026-04-20 16:51:51 +03:00
Tulir Asokan dce0c4dbe1 handletelegram: add support for updateBotMessageReaction
Fixes #1064
2026-04-19 17:30:20 +03:00
Tulir Asokan ac2a2c2980 legacymigrate: fix mx_room_state migration on sqlite 2026-04-16 23:11:15 +03:00
27 changed files with 1033 additions and 227 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ type: Bug
### Checklist
<!-- Both items below are mandatory. Issues not following the rules may be closed without comment. -->
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch.
+7
View File
@@ -1,3 +1,10 @@
# unreleased
* Added support for bridging message reactions from Telegram when logged in as
a bot.
* Fixed `mx_room_state` table not being migrated correctly from the Python
bridge in SQLite databases.
# v26.04
* Rewrote bridge in Go using bridgev2 architecture.
+3
View File
@@ -262,6 +262,9 @@ CREATE TABLE new_mx_room_state (
INSERT INTO new_mx_room_state (room_id, encryption, power_levels, create_event, members_fetched)
SELECT room_id, encryption, power_levels, create_event, COALESCE(has_full_member_list, false)
FROM mx_room_state;
DROP TABLE mx_room_state;
ALTER TABLE new_mx_room_state RENAME TO mx_room_state;
-- end only sqlite
ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea;
+2 -2
View File
@@ -27,7 +27,7 @@ require (
github.com/rs/zerolog v1.35.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
go.mau.fi/util v0.9.8
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5
go.mau.fi/webp v0.2.0
go.mau.fi/zerozap v0.1.2
go.opentelemetry.io/otel v1.42.0
@@ -42,7 +42,7 @@ require (
golang.org/x/sync v0.20.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014
rsc.io/qr v0.2.0
)
+4 -4
View File
@@ -112,8 +112,8 @@ github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5 h1:cNm4gkt7j907g1Q4XvyNKW8tTM8BaU91Kbfa5GGyiCs=
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
@@ -236,7 +236,7 @@ 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.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014 h1:KwXGBWwUHYJKVTYWgbZEFcaM6uYLMvfjzHJg/TLwvKc=
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014/go.mod h1:4fZ0M0xB5ZtueQI65RilX28J/3794BeK+LaCg4U61Jk=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+4 -3
View File
@@ -36,6 +36,7 @@ func (tc *TelegramConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilit
return &bridgev2.NetworkGeneralCapabilities{
DisappearingMessages: true,
Provisioning: bridgev2.ProvisioningCapabilities{
ImagePackImport: true,
ResolveIdentifier: bridgev2.ResolveIdentifierCapabilities{
CreateDM: true,
LookupPhone: true,
@@ -145,9 +146,9 @@ var fileCaps = event.FileFeatureMap{
// These are converted to webp
"image/jpeg": event.CapLevelPartialSupport,
"image/png": event.CapLevelPartialSupport,
// TODO
//"video/lottie+json": event.CapLevelFullySupported,
//"video/webm": event.CapLevelFullySupported,
// These will only go through if they're from an imported Telegram pack
"video/lottie+json": event.CapLevelPartialSupport,
"video/webm": event.CapLevelPartialSupport,
},
},
event.CapMsgVoice: {
+15 -29
View File
@@ -40,13 +40,13 @@ import (
"maunium.net/go/mautrix/bridgev2/networkid"
"maunium.net/go/mautrix/bridgev2/simplevent"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector/humanise"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
"go.mau.fi/mautrix-telegram/pkg/connector/matrixfmt"
"go.mau.fi/mautrix-telegram/pkg/connector/store"
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
"go.mau.fi/mautrix-telegram/pkg/gotd/pool"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/auth"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/updates"
@@ -117,6 +117,10 @@ type TelegramClient struct {
prevReactionPoll map[networkid.PortalKey]time.Time
prevReactionPollLock sync.Mutex
stickerPacksByName map[string]*stickerPackCache
stickerPacksByID map[int64]*stickerPackCache
stickerPackCacheLock sync.Mutex
}
var _ bridgev2.NetworkAPI = (*TelegramClient)(nil)
@@ -171,7 +175,9 @@ func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridge
takeoutAccepted: exsync.NewEvent(),
prevReactionPoll: map[networkid.PortalKey]time.Time{},
prevReactionPoll: map[networkid.PortalKey]time.Time{},
stickerPacksByName: map[string]*stickerPackCache{},
stickerPacksByID: map[int64]*stickerPackCache{},
recentMessageRooms: exsync.NewRingBuffer[networkid.MessageID, networkid.PortalKey](32),
@@ -341,29 +347,9 @@ func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridge
},
}
client.matrixParser = &matrixfmt.HTMLParser{
Store: tc.Store,
GetGhostDetails: func(ctx context.Context, portal *bridgev2.Portal, ui id.UserID) (networkid.UserID, string, int64, bool) {
userID, ok := tc.Bridge.Matrix.ParseGhostMXID(ui)
if !ok {
user, err := tc.Bridge.GetExistingUserByMXID(ctx, ui)
if err != nil || user == nil {
return "", "", 0, false
} else if login, _, _ := portal.FindPreferredLogin(ctx, user, false); login != nil {
userID = ids.UserLoginIDToUserID(login.ID)
} else {
return "", "", 0, false
}
}
if peerType, telegramUserID, err := ids.ParseUserID(userID); err != nil {
return "", "", 0, false
} else if accessHash, err := client.ScopedStore.GetAccessHash(ctx, peerType, telegramUserID); err != nil || accessHash == 0 {
return "", "", 0, false
} else if username, err := client.main.Store.Username.Get(ctx, peerType, telegramUserID); err != nil {
return "", "", 0, false
} else {
return userID, username, accessHash, true
}
},
Store: tc.Store,
Bridge: tc.Bridge,
ScopedStore: client.ScopedStore,
}
return &client, err
@@ -418,12 +404,12 @@ func (tc *TelegramClient) onPing() {
me, err := tc.client.Self(ctx)
if auth.IsUnauthorized(err) {
tc.onAuthError(err)
} else if errors.Is(err, syscall.EPIPE) {
// This is a pipe error, try disconnecting which will force the
// updatesManager to fail and cause the client to reconnect.
} else if errors.Is(err, syscall.EPIPE) || errors.Is(err, pool.ErrConnDead) {
// Connectivity error — connection died during the Self() call.
// Keep as transient; gotd's backoff will reconnect.
tc.userLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateTransientDisconnect,
Error: "pipe-error",
Error: "connectivity-error",
Message: humanise.Error(err),
})
} else if err != nil {
+1 -1
View File
@@ -222,7 +222,7 @@ var cmdEmojiPack = &commands.FullHandler{
Name: "emoji-pack",
Aliases: []string{"pack", "sticker-pack", "emojipack", "stickerpack"},
Help: commands.HelpMeta{
Section: commands.HelpSectionChats,
Section: commands.HelpSectionMisc,
Description: "Bridge emoji packs between Matrix and Telegram.",
Args: "<upload/download/list/help> [args...]",
},
+2
View File
@@ -61,6 +61,8 @@ proxy:
# Proxy IP address/domain name and port.
address: "127.0.0.1:1080"
# Proxy authentication (optional). Put MTProxy secret in password field.
# For mtproxy, the secret must be hex-encoded (the same form mtg/MTProxy
# tools print, e.g. "ee" + 16-byte secret + cloak domain hex for faketls).
username:
password:
+36 -13
View File
@@ -54,9 +54,11 @@ import (
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector/media"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/message"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/uploader"
"go.mau.fi/mautrix-telegram/pkg/gotd/tg"
"go.mau.fi/mautrix-telegram/pkg/gotd/tgerr"
"go.mau.fi/mautrix-telegram/pkg/connector/emojis"
"go.mau.fi/mautrix-telegram/pkg/connector/humanise"
@@ -242,10 +244,17 @@ func (tc *TelegramClient) pollSponsoredMessage(ctx context.Context, portal *brid
return nil
}
func (tc *TelegramClient) transferMediaToTelegram(ctx context.Context, content *event.MessageEventContent, sticker, forceDocument bool) (tg.InputMediaClass, error) {
func (tc *TelegramClient) transferMediaToTelegram(ctx context.Context, content *event.MessageEventContent, sticker, forceRetry, forceDocument bool) (tg.InputMediaClass, error) {
var upload tg.InputFileClass
filename := getMediaFilename(content)
info := content.GetInfo()
if sticker {
if origFile, err := tc.findOriginalStickerDocument(ctx, info.BridgedSticker, forceRetry); err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to find original sticker document, falling back to reupload")
} else if origFile != nil {
return origFile, nil
}
}
err := tc.main.Bridge.Bot.DownloadMediaToFile(ctx, content.URL, content.File, false, func(f *os.File) (err error) {
uploadFilename := f.Name()
if sticker && (info.MimeType == "image/png" || info.MimeType == "image/jpeg") {
@@ -267,10 +276,17 @@ func (tc *TelegramClient) transferMediaToTelegram(ctx context.Context, content *
} else if sticker && (info.MimeType != "video/webm" && info.MimeType != "application/x-tgsticker") {
uploadFilename, err = ffmpeg.ConvertPath(ctx, uploadFilename, ".webp", []string{}, []string{}, false)
if err != nil {
return fmt.Errorf("failed to convert sticker to webm: %+w", err)
return fmt.Errorf("failed to convert sticker to webm: %w", err)
}
defer os.Remove(uploadFilename)
info.MimeType = "image/webp"
} else if sticker && info.MimeType == "video/lottie+json" {
uploadFilename, err = media.CompressGZip(f)
if err != nil {
return fmt.Errorf("failed to compress lottie sticker: %w", err)
}
defer os.Remove(uploadFilename)
info.MimeType = "application/x-tgsticker"
} else if cfg, _, err := image.DecodeConfig(f); err != nil {
forceDocument = true
} else if fileInfo, err := f.Stat(); err != nil {
@@ -458,19 +474,26 @@ func (tc *TelegramClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2
var updates tg.UpdatesClass
if msg.Event.Type == event.EventSticker {
var media tg.InputMediaClass
media, err = tc.transferMediaToTelegram(ctx, msg.Content, true, false)
if err != nil {
return nil, err
}
updates, err = tc.client.API().MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
mediaReq := &tg.MessagesSendMediaRequest{
Peer: peer,
Message: message,
Entities: entities,
Media: media,
ReplyTo: replyTo,
RandomID: randomID,
})
}
mediaReq.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, true, false, false)
if err != nil {
return nil, err
}
updates, err = tc.client.API().MessagesSendMedia(ctx, mediaReq)
if tgerr.Is(err, tg.ErrFileReferenceExpired) {
zerolog.Ctx(ctx).Debug().AnErr("send_error", err).Msg("Trying to refetch sticker pack")
mediaReq.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, true, true, false)
if err != nil {
return nil, err
}
updates, err = tc.client.API().MessagesSendMedia(ctx, mediaReq)
}
} else {
switch msg.Content.MsgType {
case event.MsgText, event.MsgNotice, event.MsgEmote:
@@ -485,7 +508,7 @@ func (tc *TelegramClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2
case event.MsgImage, event.MsgFile, event.MsgAudio, event.MsgVideo:
var media tg.InputMediaClass
forceDocument, _ := msg.Event.Content.Raw["fi.mau.telegram.force_document"].(bool)
media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, forceDocument)
media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, false, forceDocument)
if err != nil {
return nil, err
}
@@ -650,7 +673,7 @@ func (tc *TelegramClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.Ma
} else {
log.Info().Msg("media URI changed, re-uploading media")
forceDocument, _ := msg.Event.Content.Raw["fi.mau.telegram.force_document"].(bool)
req.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, forceDocument)
req.Media, err = tc.transferMediaToTelegram(ctx, msg.Content, false, false, forceDocument)
if err != nil {
return err
}
@@ -746,7 +769,7 @@ func (tc *TelegramClient) PreHandleMatrixReaction(ctx context.Context, msg *brid
keyNoVariation := variationselector.Remove(msg.Content.RelatesTo.Key)
emojiID := ids.MakeEmojiIDFromEmoticon(msg.Content.RelatesTo.Key)
if strings.HasPrefix(msg.Content.RelatesTo.Key, "mxc://") {
if strings.Contains(msg.Content.RelatesTo.Key, "://") {
if file, err := tc.main.Store.TelegramFile.GetByMXC(ctx, id.ContentURIString(msg.Content.RelatesTo.Key)); err != nil {
return resp, err
} else if file == nil {
+74
View File
@@ -923,6 +923,8 @@ func (tc *TelegramClient) onUpdate(ctx context.Context, e tg.Entities, upd tg.Up
return tc.onMessageEdit(ctx, update)
case *tg.UpdateMessageReactions:
return tc.onMessageReactions(ctx, update)
case *tg.UpdateBotMessageReaction:
return tc.onBotMessageReaction(ctx, update)
case *tg.UpdateUserTyping:
return tc.handleTyping(tc.makePortalKeyFromID(ids.PeerTypeUser, update.UserID, 0), tc.senderForUserID(update.UserID), update.Action)
case *tg.UpdateChatUserTyping:
@@ -964,6 +966,78 @@ func (tc *TelegramClient) onMessageReactions(ctx context.Context, update *tg.Upd
return tc.handleTelegramReactions(ctx, update.Peer, update.TopMsgID, update.MsgID, update.Reactions, "updateMessageReactions")
}
func (tc *TelegramClient) onBotMessageReaction(ctx context.Context, update *tg.UpdateBotMessageReaction) error {
wrappedMessageID := ids.MakeMessageID(update.Peer, update.MsgID)
var portalKey networkid.PortalKey
var ok bool
if portalKey, ok = tc.recentMessageRooms.Get(wrappedMessageID); ok {
// key found in cache
} else if parts, err := tc.main.Bridge.DB.Message.GetAllPartsByID(ctx, tc.loginID, wrappedMessageID); err != nil {
return err
} else if len(parts) > 0 {
portalKey = parts[0].Room
} else {
// This won't work for topics, but hopefully the cases above will cover most messages
portalKey = tc.makePortalKeyFromPeer(update.Peer, 0)
}
var eventSender bridgev2.EventSender
switch update.Actor.(type) {
case *tg.PeerUser, *tg.PeerChannel:
eventSender = tc.getPeerSender(update.Actor)
default:
zerolog.Ctx(ctx).Warn().
Type("actor_type", update.Actor).
Msg("Unexpected actor type in bot message reaction")
return nil
}
var customEmojiIDs []int64
for _, reaction := range update.NewReactions {
if e, ok := reaction.(*tg.ReactionCustomEmoji); ok {
customEmojiIDs = append(customEmojiIDs, e.DocumentID)
}
}
customEmojis, err := tc.transferEmojisToMatrix(ctx, customEmojiIDs)
if err != nil {
return fmt.Errorf("failed to transfer custom emojis for bot message reaction: %w", err)
}
reactions := make([]*bridgev2.BackfillReaction, 0, len(update.NewReactions))
for _, reaction := range update.NewReactions {
emojiID, emoji, err := computeEmojiAndID(reaction, customEmojis)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to compute emoji and ID for reaction")
continue
}
reactions = append(reactions, &bridgev2.BackfillReaction{
Timestamp: time.Unix(int64(update.Date), 0),
Sender: eventSender,
EmojiID: emojiID,
Emoji: emoji,
})
}
return resultToError(tc.main.Bridge.QueueRemoteEvent(tc.userLogin, &simplevent.ReactionSync{
EventMeta: simplevent.EventMeta{
Type: bridgev2.RemoteEventReactionSync,
LogContext: func(c zerolog.Context) zerolog.Context {
return c.
Int("message_id", update.MsgID).
Any("peer_id", update.Peer).
Str("sync_source", "updateBotMessageReaction")
},
PortalKey: portalKey,
},
TargetMessage: wrappedMessageID,
Reactions: &bridgev2.ReactionSyncData{
Users: map[networkid.UserID]*bridgev2.ReactionSyncUser{
eventSender.Sender: {
Reactions: reactions,
HasAllReactions: true,
},
},
},
}))
}
func (tc *TelegramClient) onMessageEdit(ctx context.Context, update IGetMessage) error {
msg, ok := update.GetMessage().(*tg.Message)
if !ok {
+280 -80
View File
@@ -31,6 +31,8 @@ import (
"strings"
"time"
"github.com/rs/zerolog"
"go.mau.fi/util/emojishortcodes"
"go.mau.fi/util/exmaps"
"go.mau.fi/util/ffmpeg"
"go.mau.fi/util/variationselector"
@@ -448,89 +450,20 @@ func (tc *TelegramClient) fnDownloadEmojiPack(ce *commands.Event) {
ce.Reply("Can't bridge image packs if personal filtering spaces are disabled")
return
}
var input tg.InputStickerSetClass
if match := addStickersRegex.FindStringSubmatch(ce.Args[0]); match != nil {
input = &tg.InputStickerSetShortName{ShortName: match[1]}
} else if packShortcodeRegex.MatchString(ce.Args[0]) {
input = &tg.InputStickerSetShortName{ShortName: ce.Args[0]}
} else {
ce.Reply("Invalid pack shortcode or link")
return
}
rawSet, err := tc.client.API().MessagesGetStickerSet(ce.Ctx, &tg.MessagesGetStickerSetRequest{Stickerset: input})
if err != nil {
ce.Reply("Failed to get sticker set: %v", err)
return
}
set, ok := rawSet.(*tg.MessagesStickerSet)
if !ok {
ce.Reply("Unexpected response type: %T", rawSet)
return
}
linkType := "addstickers"
usage := event.ImagePackUsageSticker
if set.Set.Emojis {
linkType = "addemoji"
usage = event.ImagePackUsageEmoji
}
pack := &event.ImagePackEventContent{
Images: make(map[string]*event.ImagePackImage, len(set.Documents)),
Metadata: event.ImagePackMetadata{
DisplayName: set.Set.Title,
AvatarURL: "",
Usage: []event.ImagePackUsage{usage},
Attribution: fmt.Sprintf("Imported from https://t.me/%s/%s", linkType, set.Set.ShortName),
},
}
keywords := make(map[int64][]string)
emojis := make(map[int64][]string)
for _, kw := range set.Keywords {
keywords[kw.DocumentID] = kw.Keyword
}
for _, emojiPack := range set.Packs {
emoji := variationselector.Add(emojiPack.Emoticon)
for _, doc := range emojiPack.Documents {
emojis[doc] = append(emojis[doc], emoji)
}
}
evtID := ce.React("\u23f3\ufe0f")
defer redactReaction(ce, evtID)
for i, rawDoc := range set.Documents {
mxc, _, info, err := media.NewTransferer(tc.client.API()).
WithStickerConfig(tc.main.Config.AnimatedSticker).
WithForceWebmStickerConvert(set.Set.Emojis).
WithDocument(rawDoc, false).
Transfer(ce.Ctx, tc.main.Store, tc.main.Bridge.Bot)
if err != nil {
ce.Log.Err(err).Msg("Failed to transfer image in pack")
ce.Reply("Failed to transfer document `%d`: %v", rawDoc.GetID(), err)
return
}
kws := keywords[rawDoc.GetID()]
imageEmojis := emojis[rawDoc.GetID()]
var key string
for _, kw := range kws {
_, alreadySet := pack.Images[kw]
if alreadySet {
continue
}
key = kw
break
}
if key == "" {
key = fmt.Sprintf("%s_img%d", set.Set.ShortName, i+1)
}
body := key
if len(imageEmojis) > 0 {
body = imageEmojis[0]
}
pack.Images[key] = &event.ImagePackImage{
URL: mxc,
Body: body,
Info: info,
}
pack, err := tc.DownloadImagePack(ce.Ctx, ce.Args[0])
if err != nil {
ce.Reply("Failed to import pack: %v", err)
return
}
_, err = tc.main.Bridge.Bot.SendState(ce.Ctx, spaceRoom, event.StateUnstableImagePack, set.Set.ShortName, &event.Content{Parsed: pack}, time.Now())
if pack.Shortcode == "" && pack.Content.Metadata.BridgedPack != nil {
pack.Shortcode = pack.Content.Metadata.BridgedPack.URL
}
_, err = tc.main.Bridge.Bot.SendState(ce.Ctx, spaceRoom, event.StateUnstableImagePack, pack.Shortcode, &event.Content{
Parsed: pack.Content,
Raw: pack.Extra,
}, time.Now())
if err != nil {
ce.Reply("Failed to send image pack to space: %v", err)
} else {
@@ -540,3 +473,270 @@ func (tc *TelegramClient) fnDownloadEmojiPack(ce *commands.Event) {
spaceRoom.URI(tc.main.Bridge.Matrix.ServerName()).MatrixToURL()))
}
}
func (tc *TelegramClient) ListImagePacks(ctx context.Context) ([]*event.ImagePackMetadata, error) {
resp, err := tc.client.API().MessagesGetAllStickers(ctx, 0)
if err != nil {
return nil, err
}
casted, ok := resp.(*tg.MessagesAllStickers)
if !ok {
return nil, fmt.Errorf("unexpected response type: %T", resp)
}
packs := make([]*event.ImagePackMetadata, len(casted.Sets))
for i, set := range casted.Sets {
packs[i] = tc.makeImagePackMetadata(ctx, set)
}
return packs, nil
}
func (tc *TelegramClient) makeImagePackMetadata(ctx context.Context, pack tg.StickerSet) *event.ImagePackMetadata {
linkType := "addstickers"
usage := event.ImagePackUsageSticker
if pack.Emojis {
linkType = "addemoji"
usage = event.ImagePackUsageEmoji
}
packURL := fmt.Sprintf("https://t.me/%s/%s", linkType, pack.ShortName)
return &event.ImagePackMetadata{
DisplayName: pack.Title,
AvatarURL: "", // TODO
Usage: []event.ImagePackUsage{usage},
Attribution: fmt.Sprintf("Imported from %s", packURL),
BridgedPack: &event.BridgedStickerPack{
Network: StickerSourceID,
URL: packURL,
},
}
}
func (tc *TelegramClient) DownloadImagePack(ctx context.Context, url string) (*bridgev2.ImportedImagePack, error) {
var shortName string
if match := addStickersRegex.FindStringSubmatch(url); match != nil {
shortName = match[1]
} else if packShortcodeRegex.MatchString(url) {
shortName = url
} else {
return nil, fmt.Errorf("invalid pack shortcode or link: %s", url)
}
rawSet, err := tc.client.API().MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: &tg.InputStickerSetShortName{ShortName: shortName}})
if err != nil {
return nil, err
}
set, ok := rawSet.(*tg.MessagesStickerSet)
if !ok {
return nil, fmt.Errorf("unexpected response type: %T", rawSet)
}
tc.addStickerPackToCache(set, true)
pack := &event.ImagePackEventContent{
Images: make(map[string]*event.ImagePackImage, len(set.Documents)),
Metadata: *tc.makeImagePackMetadata(ctx, set.Set),
}
topLevelExtra := map[string]any{
"fi.mau.telegram.stickerpack": map[string]any{
"id": strconv.FormatInt(set.Set.ID, 10),
"short_name": set.Set.ShortName,
"emoji_pack": set.Set.Emojis,
},
}
keywords := make(map[int64][]string)
emojiLists := make(map[int64][]string)
for _, kw := range set.Keywords {
keywords[kw.DocumentID] = kw.Keyword
}
for _, emojiPack := range set.Packs {
emoji := variationselector.Add(emojiPack.Emoticon)
for _, doc := range emojiPack.Documents {
emojiLists[doc] = append(emojiLists[doc], emoji)
}
}
for i, rawDoc := range set.Documents {
// TODO use direct media
mxc, _, info, err := media.NewTransferer(tc.client.API()).
WithStickerConfig(tc.main.Config.AnimatedSticker).
WithForceWebmStickerConvert(set.Set.Emojis).
WithDocument(rawDoc, false).
Transfer(ctx, tc.main.Store, tc.main.Bridge.Bot)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("Failed to transfer image in pack")
return nil, fmt.Errorf("failed to transfer document %d: %w", rawDoc.GetID(), err)
}
kws := keywords[rawDoc.GetID()]
imageEmojis := emojiLists[rawDoc.GetID()]
var key string
for _, kw := range kws {
_, alreadySet := pack.Images[kw]
if alreadySet {
continue
}
key = kw
break
}
var firstShortcode string
if key == "" {
for _, emoji := range imageEmojis {
shortcode := emojishortcodes.Get(emoji)
if shortcode == "" {
continue
}
shortcode = fmt.Sprintf("%s_%s", set.Set.ShortName, shortcode)
if firstShortcode == "" {
firstShortcode = shortcode
}
_, alreadySet := pack.Images[shortcode]
if alreadySet {
continue
}
key = shortcode
break
}
}
if key == "" && firstShortcode != "" {
for i := 2; i < 10000; i++ {
kw := fmt.Sprintf("%s%d", firstShortcode, i)
_, alreadySet := pack.Images[kw]
if alreadySet {
continue
}
key = kw
}
}
if key == "" {
key = fmt.Sprintf("%s_img%d", set.Set.ShortName, i+1)
}
var emoji string
if len(imageEmojis) > 0 {
emoji = imageEmojis[0]
}
if !set.Set.Emojis {
// Stickers need extra info in each sticker so they can be accurately bridged back to Telegram
// Custom emojis don't have space for such info and can be used with just the document ID
info.BridgedSticker = &event.BridgedSticker{
Network: StickerSourceID,
ID: strconv.FormatInt(rawDoc.GetID(), 10),
PackURL: StickerPackURLPrefix + set.Set.ShortName,
Emoji: emoji,
}
}
pack.Images[key] = &event.ImagePackImage{
URL: mxc,
Body: cmp.Or(emoji, key),
Info: info,
}
}
return &bridgev2.ImportedImagePack{
Content: pack,
Extra: topLevelExtra,
Shortcode: set.Set.ShortName,
}, nil
}
const StickerSourceID = "telegram"
const StickerPackURLPrefix = "https://t.me/addstickers/"
func (tc *TelegramClient) stickerSourceFromAttribute(ctx context.Context, documentID int64, attr *tg.DocumentAttributeSticker) *event.BridgedSticker {
var shortName string
switch set := attr.Stickerset.(type) {
case *tg.InputStickerSetID:
pack, err := tc.GetCachedStickerPack(ctx, "", set, false)
if err != nil {
zerolog.Ctx(ctx).Debug().Err(err).
Int64("pack_id", set.ID).
Msg("Failed to get sticker pack by ID to fill info")
return nil
}
shortName = pack.meta.ShortName
case *tg.InputStickerSetShortName:
shortName = set.ShortName
default:
return nil
}
return &event.BridgedSticker{
Network: StickerSourceID,
ID: strconv.FormatInt(documentID, 10),
Emoji: attr.Alt,
PackURL: StickerPackURLPrefix + shortName,
}
}
type stickerPackCache struct {
docs map[int64]*tg.Document
meta tg.StickerSet
}
func (tc *TelegramClient) GetCachedStickerPack(ctx context.Context, shortName string, id *tg.InputStickerSetID, forceClearCache bool) (*stickerPackCache, error) {
tc.stickerPackCacheLock.Lock()
defer tc.stickerPackCacheLock.Unlock()
cacheName := strings.ToLower(shortName)
cache, ok := tc.stickerPacksByName[cacheName]
if !ok {
cache, ok = tc.stickerPacksByID[id.GetID()]
}
if !ok || forceClearCache {
var inputSet tg.InputStickerSetClass = id
if id == nil {
inputSet = &tg.InputStickerSetShortName{ShortName: shortName}
}
resp, err := tc.client.API().MessagesGetStickerSet(ctx, &tg.MessagesGetStickerSetRequest{Stickerset: inputSet})
if err != nil {
if tgerr.Is(err, tg.ErrStickersetInvalid) {
if cacheName != "" {
tc.stickerPacksByName[cacheName] = nil
}
if id != nil {
tc.stickerPacksByID[id.GetID()] = nil
}
}
return nil, fmt.Errorf("failed to get sticker set: %w", err)
}
set, ok := resp.AsModified()
if !ok {
if cacheName != "" {
tc.stickerPacksByName[cacheName] = nil
}
if id != nil {
tc.stickerPacksByID[id.GetID()] = nil
}
return nil, fmt.Errorf("unexpected response type for MessagesGetStickerSet: %T", resp)
}
cache = tc.addStickerPackToCache(set, false)
}
return cache, nil
}
func (tc *TelegramClient) addStickerPackToCache(set *tg.MessagesStickerSet, lock bool) *stickerPackCache {
if lock {
tc.stickerPackCacheLock.Lock()
defer tc.stickerPackCacheLock.Unlock()
}
cache := &stickerPackCache{
docs: set.MapDocuments().DocumentToMap(),
meta: set.Set,
}
tc.stickerPacksByName[strings.ToLower(set.Set.ShortName)] = cache
tc.stickerPacksByID[set.Set.ID] = cache
return cache
}
func (tc *TelegramClient) findOriginalStickerDocument(ctx context.Context, meta *event.BridgedSticker, forceClearCache bool) (tg.InputMediaClass, error) {
if meta == nil || !strings.HasPrefix(meta.PackURL, StickerPackURLPrefix) {
return nil, nil
}
shortName := strings.TrimPrefix(meta.PackURL, StickerPackURLPrefix)
if shortName == "" {
return nil, nil
}
idNum, err := strconv.ParseInt(meta.ID, 10, 64)
if err != nil {
return nil, nil
}
cache, err := tc.GetCachedStickerPack(ctx, shortName, nil, forceClearCache)
if err != nil {
return nil, err
}
stickerDoc, ok := cache.docs[idNum]
if !ok {
return nil, nil
}
return &tg.InputMediaDocument{ID: stickerDoc.AsInput()}, nil
}
+58 -8
View File
@@ -24,6 +24,7 @@ import (
"strconv"
"strings"
"github.com/rs/zerolog"
"golang.org/x/net/html"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/networkid"
@@ -31,6 +32,7 @@ import (
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector/emojis"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
"go.mau.fi/mautrix-telegram/pkg/connector/store"
"go.mau.fi/mautrix-telegram/pkg/connector/telegramfmt"
)
@@ -108,6 +110,10 @@ func (es *EntityString) TrimSpace() *EntityString {
}
break
}
if cutStart == len(es.String) {
DebugLog(" -> ALLSPACE\n")
return &EntityString{}
}
for cutEnd = len(es.String) - 1; cutEnd >= 0; cutEnd-- {
switch es.String[cutEnd] {
case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0:
@@ -254,8 +260,9 @@ func (ctx Context) WithIncrementedListDepth() Context {
// HTMLParser is a somewhat customizable Matrix HTML parser.
type HTMLParser struct {
GetGhostDetails func(context.Context, *bridgev2.Portal, id.UserID) (networkid.UserID, string, int64, bool)
Store *store.Container
Bridge *bridgev2.Bridge
Store *store.Container
ScopedStore *store.ScopedStore
}
// TaggedString is a string that also contains a HTML tag.
@@ -369,13 +376,38 @@ func (parser *HTMLParser) headerToString(node *html.Node, ctx Context) *EntitySt
return NewEntityString(prefix).Append(parser.nodeToString(node.FirstChild, ctx)).Format(telegramfmt.Style{Type: telegramfmt.StyleBold})
}
func (parser *HTMLParser) getGhostDetails(ctx context.Context, portal *bridgev2.Portal, ui id.UserID) (networkid.UserID, string, int64, bool) {
userID, ok := parser.Bridge.Matrix.ParseGhostMXID(ui)
if !ok {
user, err := parser.Bridge.GetExistingUserByMXID(ctx, ui)
if err != nil || user == nil {
return "", "", 0, false
} else if login, _, _ := portal.FindPreferredLogin(ctx, user, false); login != nil {
userID = ids.UserLoginIDToUserID(login.ID)
} else {
return "", "", 0, false
}
}
if peerType, telegramUserID, err := ids.ParseUserID(userID); err != nil {
return "", "", 0, false
} else if accessHash, err := parser.ScopedStore.GetAccessHash(ctx, peerType, telegramUserID); err != nil || accessHash == 0 {
return "", "", 0, false
} else if username, err := parser.Store.Username.Get(ctx, peerType, telegramUserID); err != nil {
return "", "", 0, false
} else {
return userID, username, accessHash, true
}
}
func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) *EntityString {
str := parser.nodeToTagAwareString(node.FirstChild, ctx)
href := parser.getAttribute(node, "href")
if len(href) == 0 {
return str
}
ent := NewEntityString(str.String.String())
linkText := str.String.String()
linkTextEnt := NewEntityString(linkText)
isRawLink := linkText == href
parsedMatrix, err := id.ParseMatrixURIOrMatrixToURL(href)
if err == nil && parsedMatrix != nil && parsedMatrix.Sigil1 == '@' {
@@ -384,19 +416,37 @@ func (parser *HTMLParser) linkToString(node *html.Node, ctx Context) *EntityStri
// Mention not allowed, use name as-is
return str
}
userID, username, accessHash, ok := parser.GetGhostDetails(ctx.Ctx, ctx.Portal, mxid)
userID, username, accessHash, ok := parser.getGhostDetails(ctx.Ctx, ctx.Portal, mxid)
if !ok {
return str
} else if username == "" {
return ent.Format(telegramfmt.Mention{UserID: userID, AccessHash: accessHash})
return linkTextEnt.Format(telegramfmt.Mention{UserID: userID, AccessHash: accessHash})
} else {
return NewEntityString("@" + username).Format(telegramfmt.Mention{UserID: userID, Username: username})
}
}
if str.String.String() == href {
return ent.Format(telegramfmt.Style{Type: telegramfmt.StyleURL, URL: href})
if parsedMatrix != nil && parsedMatrix.Sigil1 == '!' && parsedMatrix.Sigil2 == '$' {
msg, err := parser.Bridge.DB.Message.GetPartByMXID(ctx.Ctx, parsedMatrix.EventID())
if err != nil {
zerolog.Ctx(ctx.Ctx).Err(err).Msg("Failed to get message for event ID in link")
} else if msg != nil {
_, chatID, topicID, _ := ids.ParsePortalID(msg.Room.ID)
_, msgID, _ := ids.ParseMessageID(msg.ID)
if msgID != 0 && chatID != 0 {
href = fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
if topicID > 0 {
href = fmt.Sprintf("https://t.me/c/%d/%d/%d", chatID, topicID, msgID)
}
if isRawLink {
linkTextEnt = NewEntityString(href)
}
}
}
}
if isRawLink {
return linkTextEnt.Format(telegramfmt.Style{Type: telegramfmt.StyleURL, URL: href})
} else {
return ent.Format(telegramfmt.Style{Type: telegramfmt.StyleTextURL, URL: href})
return linkTextEnt.Format(telegramfmt.Style{Type: telegramfmt.StyleTextURL, URL: href})
}
}
+24
View File
@@ -110,6 +110,30 @@ func (c *AnimatedStickerConfig) convertWebm(ctx context.Context, src *os.File) *
}
}
func CompressGZip(src *os.File) (replPath string, err error) {
tempFile, err := os.CreateTemp("", "telegram-sticker-gzip-*.tgs")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
writer := gzip.NewWriter(tempFile)
defer func() {
_ = tempFile.Close()
_ = writer.Close()
if replPath == "" {
_ = os.Remove(tempFile.Name())
}
}()
_, err = io.Copy(writer, src)
if err != nil {
return "", fmt.Errorf("failed to compress lottie gzip: %w", err)
}
err = writer.Close()
if err != nil {
return "", fmt.Errorf("failed to close gzip writer: %w", err)
}
return tempFile.Name(), nil
}
func extractGZip(src *os.File) (*ConvertedSticker, error) {
reader, err := gzip.NewReader(src)
if err != nil {
+10
View File
@@ -144,6 +144,11 @@ func (t *Transferer) WithStickerConfig(cfg AnimatedStickerConfig) *Transferer {
return t
}
func (t *Transferer) WithStickerMetadata(meta *event.BridgedSticker) *Transferer {
t.fileInfo.BridgedSticker = meta
return t
}
func (t *Transferer) WithForceWebmStickerConvert(force bool) *Transferer {
if force {
t.animatedStickerConfig.ConvertFromWebm = true
@@ -197,6 +202,11 @@ func (t *Transferer) WithVideo(attr *tg.DocumentAttributeVideo) *Transferer {
return t
}
func (t *Transferer) WithAudio(attr *tg.DocumentAttributeAudio) *Transferer {
t.fileInfo.Duration = attr.Duration * 1000
return t
}
func (t *Transferer) WithImageSize(attr *tg.DocumentAttributeImageSize) *Transferer {
t.fileInfo.Width, t.fileInfo.Height = attr.W, attr.H
t.adjustStickerSize()
+23 -1
View File
@@ -17,13 +17,31 @@
package connector
import (
"encoding/hex"
"fmt"
"strings"
"golang.org/x/net/proxy"
"go.mau.fi/mautrix-telegram/pkg/gotd/telegram/dcs"
)
// decodeMTProxySecret parses an MTProxy secret string into raw bytes.
// MTProxy secrets are binary (faketls secrets begin with 0xEE, secured with 0xDD)
// and cannot be carried verbatim in a YAML string field, so we accept the standard
// hex encoding (optionally prefixed with "ee"/"dd") used by mtg/MTProxy tooling.
func decodeMTProxySecret(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, fmt.Errorf("mtproxy secret is empty")
}
b, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("mtproxy secret must be hex-encoded: %w", err)
}
return b, nil
}
func GetProxyDialFunc(cfg ProxyConfig) (dcs.DialFunc, error) {
switch cfg.Type {
// we can't proxy HTTP through mtproxy
@@ -54,7 +72,11 @@ func GetProxyResolver(cfg ProxyConfig) (dcs.Resolver, error) {
resolver := dcs.Plain(dcs.PlainOptions{Dial: dialer})
return resolver, nil
case "mtproxy":
return dcs.MTProxy(cfg.Address, []byte(cfg.Password), dcs.MTProxyOptions{})
secret, err := decodeMTProxySecret(cfg.Password)
if err != nil {
return nil, err
}
return dcs.MTProxy(cfg.Address, secret, dcs.MTProxyOptions{})
default:
return nil, fmt.Errorf("unsupported proxy type %s", cfg.Type)
}
+37
View File
@@ -0,0 +1,37 @@
package connector
import (
"bytes"
"testing"
)
func TestDecodeMTProxySecret(t *testing.T) {
// faketls secret: 0xee + 16 bytes + cloak domain ("working-name.ru" = 15 bytes)
hexSecret := "ee971746d927f4c0138b18447bfe1269bc70312e776f726b696e672d6e616d652e7275"
want := []byte{
0xee,
0x97, 0x17, 0x46, 0xd9, 0x27, 0xf4, 0xc0, 0x13,
0x8b, 0x18, 0x44, 0x7b, 0xfe, 0x12, 0x69, 0xbc,
0x70, 0x31, 0x2e, 0x77, 0x6f, 0x72, 0x6b, 0x69,
0x6e, 0x67, 0x2d, 0x6e, 0x61, 0x6d, 0x65, 0x2e,
0x72, 0x75,
}
got, err := decodeMTProxySecret(hexSecret)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("decoded bytes mismatch:\n got=%x\nwant=%x", got, want)
}
if _, err := decodeMTProxySecret(" " + hexSecret + "\n"); err != nil {
t.Fatalf("whitespace should be tolerated: %v", err)
}
if _, err := decodeMTProxySecret(""); err == nil {
t.Fatal("expected error for empty secret")
}
if _, err := decodeMTProxySecret("not-hex!!"); err == nil {
t.Fatal("expected error for non-hex secret")
}
}
@@ -0,0 +1,40 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2026 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 <https://www.gnu.org/licenses/>.
package upgrades
import (
"context"
"go.mau.fi/util/dbutil"
)
func init() {
Table.Register(-1, 9, 2, "Fix bug in legacy migration", dbutil.TxnModeOn, func(ctx context.Context, db *dbutil.Database) error {
if db.Dialect != dbutil.SQLite {
return nil
}
exists, err := db.TableExists(ctx, "new_mx_room_state")
if !exists || err != nil {
return err
}
_, err = db.Exec(ctx, `
DROP TABLE mx_room_state;
ALTER TABLE new_mx_room_state RENAME TO mx_room_state;
`)
return err
})
}
+36 -26
View File
@@ -257,6 +257,12 @@ func (tc *TelegramClient) convertToMatrix(
log.Warn().Type("reply_to", replyTo).Msg("unhandled reply to type")
}
}
if cm.Parts[0].Extra == nil {
cm.Parts[0].Extra = make(map[string]any)
}
if externalURL := getMessageLink(msg); externalURL != "" {
cm.Parts[0].Extra["external_url"] = externalURL
}
if len(cm.Parts) > 1 {
log.Warn().Int("part_count", len(cm.Parts)).Msg("Message has multiple parts")
for i, part := range cm.Parts[1:] {
@@ -274,6 +280,23 @@ func (tc *TelegramClient) convertToMatrix(
return
}
func getMessageLink(msg *tg.Message) string {
var chatID int64
switch peer := msg.PeerID.(type) {
case *tg.PeerChat:
chatID = peer.ChatID
case *tg.PeerChannel:
chatID = peer.ChannelID
default: // also PeerUser
return ""
}
topicID := rawGetTopicID(msg.ReplyTo)
if topicID > 0 {
return fmt.Sprintf("https://t.me/c/%d/%d/%d", chatID, topicID, msg.ID)
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msg.ID)
}
func (tc *TelegramClient) addForwardHeader(ctx context.Context, part *bridgev2.ConvertedMessagePart, fwd tg.MessageFwdHeader) error {
var fwdFromText, fwdFromHTML string
switch from := fwd.FromID.(type) {
@@ -584,11 +607,10 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
if a.RoundMessage {
extraInfo["fi.mau.telegram.round_message"] = a.RoundMessage
}
extraInfo["duration"] = int(a.Duration * 1000)
case *tg.DocumentAttributeAudio:
if content.MsgType != event.MsgVideo {
content.MsgType = event.MsgAudio
extraInfo["duration"] = int(a.Duration * 1000) // only set the duration is not already set by the video handling logic
transferer = transferer.WithAudio(a) // only set the duration is not already set by the video handling logic
}
content.MSC1767Audio = &event.MSC1767Audio{
Duration: a.Duration * 1000,
@@ -606,7 +628,7 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
}
case *tg.DocumentAttributeImageSize:
transferer = transferer.WithImageSize(a)
if content.MsgType == event.MsgFile {
if content.MsgType == event.MsgFile && !isSticker {
content.MsgType = event.MsgImage
extra["fi.mau.telegram.force_document"] = true
defaultFileName = "image_document"
@@ -619,20 +641,9 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
content.FileName = content.Body
content.Body = a.Alt
}
stickerInfo := map[string]any{"alt": a.Alt, "id": strconv.FormatInt(document.ID, 10)}
if setID, ok := a.Stickerset.(*tg.InputStickerSetID); ok {
stickerInfo["pack"] = map[string]any{
"id": strconv.FormatInt(setID.ID, 10),
"access_hash": strconv.FormatInt(setID.AccessHash, 10),
}
} else if shortName, ok := a.Stickerset.(*tg.InputStickerSetShortName); ok {
stickerInfo["pack"] = map[string]any{
"short_name": shortName.ShortName,
}
}
extraInfo["fi.mau.telegram.sticker"] = stickerInfo
transferer = transferer.WithStickerConfig(tc.main.Config.AnimatedSticker)
transferer = transferer.
WithStickerConfig(tc.main.Config.AnimatedSticker).
WithStickerMetadata(tc.stickerSourceFromAttribute(ctx, document.ID, a))
case *tg.DocumentAttributeAnimated:
isVideoGif = true
extraInfo["fi.mau.telegram.gif"] = true
@@ -662,14 +673,6 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
}
}
if isVideoGif {
extraInfo["fi.mau.gif"] = true
extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true
extraInfo["fi.mau.hide_controls"] = true
extraInfo["fi.mau.no_audio"] = true
}
if _, ok := document.GetThumbs(); ok && eventType != event.EventSticker {
var thumbnailURL id.ContentURIString
var thumbnailFile *event.EncryptedFileInfo
@@ -751,6 +754,13 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
content.FileName = content.FileName + exmime.ExtensionFromMimetype(content.Info.MimeType)
}
}
if isVideoGif {
content.Info.MauGIF = true
extraInfo["fi.mau.loop"] = true
extraInfo["fi.mau.autoplay"] = true
extraInfo["fi.mau.hide_controls"] = true
extraInfo["fi.mau.no_audio"] = true
}
// Handle spoilers
// See: https://github.com/matrix-org/matrix-spec-proposals/pull/3725
@@ -762,7 +772,7 @@ func (tc *TelegramClient) convertMediaRequiringUpload(
extraInfo["fi.mau.telegram.spoiler"] = true
}
if len(extraInfo) > 0 {
extra["info"] = extraInfo
content.Info.Extra = extraInfo
}
converted = &bridgev2.ConvertedMessagePart{
+65
View File
@@ -0,0 +1,65 @@
package faketls
import (
"bytes"
"io"
"testing"
"github.com/stretchr/testify/require"
)
// TestReadSkipsChangeCipherSpec ensures interleaved ChangeCipherSpec records
// do not pollute the Application-data stream. Earlier behaviour wrote the
// 1-byte CCS payload into readBuf, which desynced the obfuscated2 CTR
// keystream and produced "msg_key is invalid" on decrypted MTProto messages.
func TestReadSkipsChangeCipherSpec(t *testing.T) {
a := require.New(t)
wire := bytes.NewBuffer(nil)
// CCS record (1 byte = 0x01)
_, err := writeRecord(wire, record{
Type: RecordTypeChangeCipherSpec,
Version: Version12Bytes,
Data: []byte{0x01},
})
a.NoError(err)
// Application record carrying our payload
payload := []byte("hello-mtproto-bytes")
_, err = writeRecord(wire, record{
Type: RecordTypeApplication,
Version: Version12Bytes,
Data: payload,
})
a.NoError(err)
// Another CCS in the middle
_, err = writeRecord(wire, record{
Type: RecordTypeChangeCipherSpec,
Version: Version12Bytes,
Data: []byte{0x01},
})
a.NoError(err)
// Second application record
more := []byte("second-payload")
_, err = writeRecord(wire, record{
Type: RecordTypeApplication,
Version: Version12Bytes,
Data: more,
})
a.NoError(err)
tls := NewFakeTLS(zeroReader{}, &readonly{r: wire})
got, err := io.ReadAll(io.LimitReader(tls, int64(len(payload)+len(more))))
a.NoError(err)
a.Equal(append(append([]byte(nil), payload...), more...), got)
}
// readonly adapts an io.Reader to io.ReadWriter (NewFakeTLS demands one).
type readonly struct{ r io.Reader }
func (r *readonly) Read(p []byte) (int, error) { return r.r.Read(p) }
func (r *readonly) Write(p []byte) (int, error) { return len(p), nil }
+41 -6
View File
@@ -14,7 +14,26 @@ import (
const clientHelloLength = 517
func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32]byte) (randomOffset int) {
// generateGrease produces seven GREASE bytes following the TLS spec
// (RFC 8701) and tdlib's TlsInit.cpp constraints used by MTProxy faketls
// validators: each byte has the form 0x?A (low nibble 0x0A), and grease[3]
// must differ from grease[4].
func generateGrease(rng io.Reader) ([7]byte, error) {
var raw [7]byte
if _, err := io.ReadFull(rng, raw[:]); err != nil {
return raw, errors.Wrap(err, "read grease entropy")
}
var g [7]byte
for i, r := range raw {
g[i] = (r & 0xF0) | 0x0A
}
if g[3] == g[4] {
g[3] ^= 0x10
}
return g, nil
}
func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32]byte, grease [7]byte) (randomOffset int) {
S := func(s string) {
b.Buf = append(b.Buf, s...)
}
@@ -22,8 +41,9 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32
randomOffset = len(b.Buf)
b.Expand(n)
}
G := func(_ int) {
b.Expand(0)
G := func(n int) {
v := grease[n]
b.Buf = append(b.Buf, v, v)
}
R := func() {
b.Buf = append(b.Buf, sessionID[:]...)
@@ -83,9 +103,18 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32
G(3)
S("\x00\x01\x00\x00\x15")
if pad := clientHelloLength - b.Len(); pad > 0 {
b.Expand(pad)
// Padding extension (id 0x0015 already written above): write its
// length so the resulting ClientHello is exactly clientHelloLength
// bytes, then fill the body with zeros.
padLen := clientHelloLength - b.Len() - 2
if padLen < 0 {
padLen = 0
}
lenPos := b.Len()
b.Expand(2)
binary.BigEndian.PutUint16(b.Buf[lenPos:lenPos+2], uint16(padLen))
b.Expand(padLen)
return randomOffset
}
@@ -94,15 +123,21 @@ func createClientHello(b *bin.Buffer, sessionID [32]byte, domain string, key [32
// See https://tools.ietf.org/html/rfc5246#section-7.4.1.1.
func writeClientHello(
w io.Writer,
rng io.Reader,
now clock.Clock,
sessionID [32]byte,
domain string,
secret []byte,
) (r [32]byte, err error) {
grease, err := generateGrease(rng)
if err != nil {
return [32]byte{}, err
}
b := &bin.Buffer{
Buf: make([]byte, 0, 576),
}
randomOffset := createClientHello(b, sessionID, domain, [32]byte{})
randomOffset := createClientHello(b, sessionID, domain, [32]byte{}, grease)
// https://github.com/tdlib/td/blob/27d3fdd09d90f6b77ecbcce50b1e86dc4b3dd366/td/mtproto/TlsInit.cpp#L380-L384
mac := hmac.New(sha256.New, secret)
+22 -15
View File
@@ -46,7 +46,7 @@ func (o *FakeTLS) Handshake(protocol [4]byte, dc int, s mtproxy.Secret) error {
return errors.Wrap(err, "generate sessionID")
}
clientDigest, err := writeClientHello(o.conn, o.clock, sessionID, s.CloakHost, s.Secret)
clientDigest, err := writeClientHello(o.conn, o.rand, o.clock, sessionID, s.CloakHost, s.Secret)
if err != nil {
return errors.Wrap(err, "send ClientHello")
}
@@ -93,20 +93,27 @@ func (o *FakeTLS) Read(b []byte) (n int, err error) {
return o.readBuf.Read(b)
}
rec, err := readRecord(o.conn)
if err != nil {
return 0, errors.Wrap(err, "read TLS record")
}
// Skip ChangeCipherSpec records — they are TLS-level keep-alive /
// compatibility markers (one-byte payload 0x01) and must NOT be
// passed up to the obfuscated2 layer, otherwise the CTR keystream
// position desyncs and subsequent MTProto messages decrypt to
// garbage (`msg_key is invalid`).
for {
rec, err := readRecord(o.conn)
if err != nil {
return 0, errors.Wrap(err, "read TLS record")
}
switch rec.Type {
case RecordTypeChangeCipherSpec:
case RecordTypeApplication:
case RecordTypeHandshake:
return 0, errors.New("unexpected record type handshake")
default:
return 0, errors.Errorf("unsupported record type %v", rec.Type)
switch rec.Type {
case RecordTypeChangeCipherSpec:
continue
case RecordTypeApplication:
o.readBuf.Write(rec.Data)
return o.readBuf.Read(b)
case RecordTypeHandshake:
return 0, errors.New("unexpected record type handshake")
default:
return 0, errors.Errorf("unsupported record type %v", rec.Type)
}
}
o.readBuf.Write(rec.Data)
return o.readBuf.Read(b)
}
+42 -33
View File
@@ -10,6 +10,15 @@ import (
"github.com/gotd/neo"
)
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 0
}
return len(p), nil
}
func TestTLS(t *testing.T) {
a := require.New(t)
secret := [32]byte{}
@@ -17,42 +26,42 @@ func TestTLS(t *testing.T) {
c := neo.NewTime(time.Date(2010, 10, 10, 1, 1, 1, 0, time.UTC))
b := bytes.NewBuffer(nil)
_, err := writeClientHello(b, c, sessionID, "google.com", secret[:])
_, err := writeClientHello(b, zeroReader{}, c, sessionID, "google.com", secret[:])
a.NoError(err)
testVector := []byte{
0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xf9, 0x75, 0x5f, 0xdd, 0xb9,
0xe3, 0x46, 0x57, 0x5a, 0x26, 0x71, 0xfa, 0x29, 0x7f, 0xab, 0xf0, 0xa1, 0xf3, 0x69, 0x4f, 0x72,
0xe0, 0xc3, 0x8f, 0x62, 0x77, 0x5c, 0x8f, 0x5a, 0xf8, 0xa2, 0xa9, 0x20, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x13, 0x01,
0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30, 0xcc, 0xa9, 0xcc, 0xa8,
0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0x01, 0x00, 0x01, 0x93,
0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x0a,
0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00,
0x00, 0x23, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c, 0x02, 0x68, 0x32, 0x08, 0x68, 0x74,
0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04, 0x01, 0x05, 0x03, 0x08, 0x05, 0x05,
0x01, 0x08, 0x06, 0x06, 0x01, 0x00, 0x12, 0x00, 0x00, 0x00, 0x33, 0x00, 0x2b, 0x00, 0x29, 0x00,
0x01, 0x00, 0x00, 0x1d, 0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d, 0x00, 0x02, 0x01, 0x01, 0x00, 0x2b, 0x00, 0x0b,
0x0a, 0x03, 0x04, 0x03, 0x03, 0x03, 0x02, 0x03, 0x01, 0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02,
0x00, 0x01, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc, 0x03, 0x03, 0xa9, 0xa8, 0x7f, 0x37, 0x9b,
0x09, 0x80, 0x6a, 0xf3, 0xff, 0x78, 0x4a, 0x6c, 0x4e, 0xbd, 0xdd, 0x94, 0x31, 0x8e, 0x7c, 0x09,
0x36, 0x63, 0x77, 0x1d, 0x36, 0xf4, 0xcb, 0x6d, 0x3e, 0x13, 0x83, 0x20, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x0a, 0x0a,
0x13, 0x01, 0x13, 0x02, 0x13, 0x03, 0xc0, 0x2b, 0xc0, 0x2f, 0xc0, 0x2c, 0xc0, 0x30, 0xcc, 0xa9,
0xcc, 0xa8, 0xc0, 0x13, 0xc0, 0x14, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0x01, 0x00,
0x01, 0x93, 0x0a, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, 0x00, 0x0d, 0x00, 0x00, 0x0a, 0x67,
0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x63, 0x6f, 0x6d, 0x00, 0x17, 0x00, 0x00, 0xff, 0x01, 0x00,
0x01, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x0a, 0x0a, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18,
0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x23, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0e, 0x00, 0x0c,
0x02, 0x68, 0x32, 0x08, 0x68, 0x74, 0x74, 0x70, 0x2f, 0x31, 0x2e, 0x31, 0x00, 0x05, 0x00, 0x05,
0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x03, 0x08, 0x04, 0x04,
0x01, 0x05, 0x03, 0x08, 0x05, 0x05, 0x01, 0x08, 0x06, 0x06, 0x01, 0x00, 0x12, 0x00, 0x00, 0x00,
0x33, 0x00, 0x2b, 0x00, 0x29, 0x0a, 0x0a, 0x00, 0x01, 0x00, 0x00, 0x1d, 0x00, 0x20, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d,
0x00, 0x02, 0x01, 0x01, 0x00, 0x2b, 0x00, 0x0b, 0x0a, 0x0a, 0x0a, 0x03, 0x04, 0x03, 0x03, 0x03,
0x02, 0x03, 0x01, 0x00, 0x1b, 0x00, 0x03, 0x02, 0x00, 0x02, 0x1a, 0x1a, 0x00, 0x01, 0x00, 0x00,
0x15, 0x00, 0xd2, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
}
a.Equal(testVector, b.Bytes())
+16 -4
View File
@@ -4,11 +4,20 @@ import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"github.com/go-faster/errors"
)
// peekDump returns up to n bytes from the start of buf as a hex string for diagnostics.
func peekDump(buf []byte, n int) string {
if len(buf) < n {
n = len(buf)
}
return hex.EncodeToString(buf[:n])
}
// readServerHello reads faketls ServerHello.
func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
packetBuf := bytes.NewBuffer(nil)
@@ -16,10 +25,11 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
handshake, err := readRecord(r)
if err != nil {
return errors.Wrap(err, "handshake record")
return errors.Wrapf(err, "handshake record (peek=%s)", peekDump(packetBuf.Bytes(), 32))
}
if handshake.Type != RecordTypeHandshake {
return errors.Wrap(err, "unexpected record type")
return errors.Errorf("unexpected handshake record type: got 0x%02x, want 0x%02x (peek=%s)",
byte(handshake.Type), byte(RecordTypeHandshake), peekDump(packetBuf.Bytes(), 32))
}
changeCipher, err := readRecord(r)
@@ -27,7 +37,8 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
return errors.Wrap(err, "change cipher record")
}
if changeCipher.Type != RecordTypeChangeCipherSpec {
return errors.Wrap(err, "unexpected record type")
return errors.Errorf("unexpected change cipher record type: got 0x%02x, want 0x%02x",
byte(changeCipher.Type), byte(RecordTypeChangeCipherSpec))
}
cert, err := readRecord(r)
@@ -35,7 +46,8 @@ func readServerHello(r io.Reader, clientRandom [32]byte, secret []byte) error {
return errors.Wrap(err, "cert record")
}
if cert.Type != RecordTypeApplication {
return errors.Wrap(err, "unexpected record type")
return errors.Errorf("unexpected application record type: got 0x%02x, want 0x%02x",
byte(cert.Type), byte(RecordTypeApplication))
}
// `$record_header = type 1 byte + version 2 bytes + payload_length 2 bytes = 5 bytes`
+101
View File
@@ -0,0 +1,101 @@
package faketls
import (
"crypto/tls"
"net"
"strings"
"testing"
"time"
"go.mau.fi/mautrix-telegram/pkg/gotd/bin"
)
// TestClientHelloStructure verifies that what we generate is a syntactically
// valid TLS ClientHello — i.e., the Go crypto/tls server can parse it without
// returning a "decode_error"-like syntax error. We don't care that the TLS
// handshake then fails (it will, since we're using a fake cert / random data);
// we only care that parsing succeeds.
func TestClientHelloStructure(t *testing.T) {
// Render with deterministic rng + sessionID + key.
var session [32]byte
for i := range session {
session[i] = byte(i)
}
var key [32]byte
for i := range key {
key[i] = 0xAA
}
var grease [7]byte
for i := range grease {
grease[i] = byte(0x0A + i*0x10)
}
if grease[3] == grease[4] {
grease[3] ^= 0x10
}
b := &bin.Buffer{Buf: make([]byte, 0, 576)}
createClientHello(b, session, "example.com", key, grease)
if len(b.Buf) != clientHelloLength {
t.Fatalf("expected %d bytes, got %d", clientHelloLength, len(b.Buf))
}
// Wire it through a real TLS server. The server reads bytes from
// our pipe; if it accepts ClientHello but fails on cert/MAC, we get
// a non-syntax error. If it returns "decode_error", we know we're
// still busted.
clientConn, serverConn := net.Pipe()
defer clientConn.Close()
defer serverConn.Close()
go func() {
clientConn.Write(b.Buf)
// keep the pipe open until done
}()
cfg := &tls.Config{
Certificates: []tls.Certificate{generateSelfSigned(t)},
}
srv := tls.Server(serverConn, cfg)
srv.SetDeadline(time.Now().Add(2 * time.Second))
err := srv.Handshake()
if err == nil {
return // unexpectedly succeeded — fine for our purpose
}
t.Logf("server handshake error (expected non-syntax): %v", err)
msg := err.Error()
for _, marker := range []string{"decode_error", "syntax", "malformed", "bad ClientHello"} {
if strings.Contains(msg, marker) {
t.Fatalf("structural parse failure (%q) — ClientHello is malformed: %v", marker, err)
}
}
}
// generateSelfSigned builds a throwaway cert for the test TLS server.
func generateSelfSigned(t *testing.T) tls.Certificate {
cert, err := tls.X509KeyPair(testCertPEM, testKeyPEM)
if err != nil {
t.Fatal(err)
}
return cert
}
// Generated with `go run filippo.io/mkcert@latest -ecdsa example.com`-ish.
// Embedded here for deterministic test environment.
var testCertPEM = []byte(`-----BEGIN CERTIFICATE-----
MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw
DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow
EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d
7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B
5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr
BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1
NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l
Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc
6MF9+Yw1Yy0t
-----END CERTIFICATE-----`)
var testKeyPEM = []byte(`-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49
AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q
EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==
-----END EC PRIVATE KEY-----`)
+6 -1
View File
@@ -53,7 +53,12 @@ func (o *Obfuscated2) Read(b []byte) (int, error) {
return n, err
}
if n > 0 {
o.decrypt.XORKeyStream(b, b)
// IMPORTANT: only XOR the n bytes that were actually read.
// XOR-ing the full b advances the CTR keystream past where the
// server is and permanently desyncs the stream — every later
// MTProto message decrypts to garbage and the engine fails
// with "msg_key is invalid".
o.decrypt.XORKeyStream(b[:n], b[:n])
}
return n, err
}
@@ -0,0 +1,82 @@
package obfuscated2
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"io"
"testing"
"github.com/stretchr/testify/require"
)
// chunkConn delivers data from buf in chunks of at most chunkSize bytes.
type chunkConn struct {
buf *bytes.Buffer
chunkSize int
}
func (c *chunkConn) Read(p []byte) (int, error) {
if c.buf.Len() == 0 {
return 0, io.EOF
}
want := len(p)
if want > c.chunkSize {
want = c.chunkSize
}
return c.buf.Read(p[:want])
}
func (c *chunkConn) Write(p []byte) (int, error) { return len(p), nil }
// TestShortReadKeepsKeystreamAligned ensures that when the underlying
// transport returns fewer bytes than the caller asked for, the CTR
// keystream is only advanced by the bytes actually delivered.
//
// The previous implementation called XORKeyStream(b, b) instead of
// XORKeyStream(b[:n], b[:n]); after a single short read the client and
// server keystreams diverged and every subsequent MTProto message
// failed integrity (msg_key invalid).
func TestShortReadKeepsKeystreamAligned(t *testing.T) {
a := require.New(t)
key := bytes.Repeat([]byte{0x11}, 32)
iv := bytes.Repeat([]byte{0x22}, 16)
enc, err := aes.NewCipher(key)
a.NoError(err)
dec, err := aes.NewCipher(key)
a.NoError(err)
encStream := cipher.NewCTR(enc, iv)
decStream := cipher.NewCTR(dec, iv)
plaintext := bytes.Repeat([]byte("Hello, MTProxy! "), 50)
ciphertext := make([]byte, len(plaintext))
encStream.XORKeyStream(ciphertext, plaintext)
wire := &chunkConn{buf: bytes.NewBuffer(append([]byte(nil), ciphertext...)), chunkSize: 7}
o := &Obfuscated2{
conn: wire,
keys: keys{decrypt: decStream},
}
got := make([]byte, len(plaintext))
off := 0
for off < len(plaintext) {
end := off + 128
if end > len(got) {
end = len(got)
}
n, err := o.Read(got[off:end])
if err != nil && err != io.EOF {
t.Fatalf("read at off %d: %v", off, err)
}
if n == 0 {
t.Fatalf("zero-length read at off %d", off)
}
off += n
}
a.Equal(plaintext, got, "short reads must not desync the keystream")
}