diff --git a/go.mod b/go.mod index e198db5a..6ea66032 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/rs/zerolog v1.34.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 - go.mau.fi/util v0.9.4-0.20251203150954-89222cf416b4 + go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 go.mau.fi/webp v0.2.0 go.mau.fi/zerozap v0.1.2 go.opentelemetry.io/otel v1.38.0 @@ -41,7 +41,7 @@ require ( golang.org/x/sync v0.18.0 golang.org/x/tools v0.39.0 gopkg.in/yaml.v3 v3.0.1 - maunium.net/go/mautrix v0.26.1-0.20251206105112-4efa4bdac5e3 + maunium.net/go/mautrix v0.26.1-0.20251207175222-00c58efc5906 rsc.io/qr v0.2.0 ) diff --git a/go.sum b/go.sum index d12a1127..25be22f4 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -go.mau.fi/util v0.9.4-0.20251203150954-89222cf416b4 h1:UrHJ6O9Nr+KxuAFNKyTZdGExhtUq09WcYpUlqMwBOic= -go.mau.fi/util v0.9.4-0.20251203150954-89222cf416b4/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= +go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0 h1:ESebxPGULuuxxcZigjcBFyyU62tiyY6ivtX17P4BkvY= +go.mau.fi/util v0.9.4-0.20251206205611-85e6fd6551e0/go.mod h1:viDmhBOAFfcqDdKSk53EPJV3N4Mi8Jst5/ahGJ/vwsA= 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= @@ -237,7 +237,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.26.1-0.20251206105112-4efa4bdac5e3 h1:llPUQswvRVaWkWqwH1P6T51wkj3fWCXu4rxewN0RLsY= -maunium.net/go/mautrix v0.26.1-0.20251206105112-4efa4bdac5e3/go.mod h1:NaesYcOQWFDbixVYywCVS+Twlzab9hOUpFNlCBlvciE= +maunium.net/go/mautrix v0.26.1-0.20251207175222-00c58efc5906 h1:5Y4XfVK3QRi/LIPO0yb0oBoEiba3NAh3ayUmP1byzUY= +maunium.net/go/mautrix v0.26.1-0.20251207175222-00c58efc5906/go.mod h1:pzwIT42s+BhBjEYovmcOt69VlNW2RkJ6pCyZjYQHKIc= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/connector/backfill.go b/pkg/connector/backfill.go index 42249d74..3e159ca9 100644 --- a/pkg/connector/backfill.go +++ b/pkg/connector/backfill.go @@ -185,6 +185,9 @@ func (t *TelegramClient) stopTakeout(ctx context.Context) error { } func (t *TelegramClient) FetchMessages(ctx context.Context, fetchParams bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error) { + if t.metadata.IsBot { + return nil, fmt.Errorf("bots cannot backfill messages") + } log := zerolog.Ctx(ctx).With().Str("method", "FetchMessages").Logger() ctx = log.WithContext(ctx) diff --git a/pkg/connector/chatinfo.go b/pkg/connector/chatinfo.go index 3a608520..80a851b0 100644 --- a/pkg/connector/chatinfo.go +++ b/pkg/connector/chatinfo.go @@ -105,7 +105,7 @@ func (t *TelegramClient) getDMChatInfo(ctx context.Context, userID int64) (*brid MemberMap: map[networkid.UserID]bridgev2.ChatMember{}, PowerLevels: t.getDMPowerLevels(ghost), }, - CanBackfill: true, + CanBackfill: !t.metadata.IsBot, ExtraUpdates: updatePortalLastSyncAt, } chatInfo.Members.MemberMap.Add(bridgev2.ChatMember{EventSender: t.mySender()}) @@ -142,7 +142,7 @@ type memberFetchMeta struct { func (t *TelegramClient) wrapChatInfo(portalID networkid.PortalID, rawChat tg.ChatClass) (*bridgev2.ChatInfo, *memberFetchMeta, error) { info := bridgev2.ChatInfo{ Type: ptr.Ptr(database.RoomTypeDefault), - CanBackfill: true, + CanBackfill: !t.metadata.IsBot, Members: &bridgev2.ChatMemberList{ ExcludeChangesFromTimeline: true, MemberMap: bridgev2.ChatMemberMap{}, diff --git a/pkg/connector/client.go b/pkg/connector/client.go index 75978b56..d0c1d5cc 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -711,8 +711,7 @@ func (t *TelegramClient) FillBridgeState(state status.BridgeState) status.Bridge if state.Info == nil { state.Info = make(map[string]any) } - meta := t.userLogin.Metadata.(*UserLoginMetadata) - state.Info["is_bot"] = meta.IsBot - state.Info["login_method"] = meta.LoginMethod + state.Info["is_bot"] = t.metadata.IsBot + state.Info["login_method"] = t.metadata.LoginMethod return state } diff --git a/pkg/connector/handlematrix.go b/pkg/connector/handlematrix.go index d1d7f118..f5bb7094 100644 --- a/pkg/connector/handlematrix.go +++ b/pkg/connector/handlematrix.go @@ -555,11 +555,9 @@ func (t *TelegramClient) PreHandleMatrixReaction(ctx context.Context, msg *bridg return resp, fmt.Errorf("failed to get available reactions: %w", err) } else if _, ok := availableReactions[keyNoVariation]; ok { log.Debug().Msg("Not using custom emoji reaction since the emoji is available") - } else { - if documentID, ok := emojis.GetEmojiDocumentID(keyNoVariation); ok { - log.Debug().Msg("Using custom emoji reaction") - emojiID = ids.MakeEmojiIDFromDocumentID(documentID) - } + } else if documentID, ok := emojis.GetEmojiDocumentID(keyNoVariation); ok && !t.metadata.IsBot { + log.Debug().Msg("Using custom emoji reaction") + emojiID = ids.MakeEmojiIDFromDocumentID(documentID) } log.Debug().Str("emoji_id", string(emojiID)).Msg("Pre-handled reaction") @@ -684,10 +682,15 @@ func (t *TelegramClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridg var readMentionsErr, readReactionsErr, readMessagesErr error var wg sync.WaitGroup + isBot := t.metadata.IsBot + // Read mentions wg.Add(1) go func() { defer wg.Done() + if isBot { + return + } _, readMentionsErr = t.client.API().MessagesReadMentions(ctx, &tg.MessagesReadMentionsRequest{ Peer: inputPeer, TopMsgID: topicID, @@ -698,6 +701,9 @@ func (t *TelegramClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridg wg.Add(1) go func() { defer wg.Done() + if isBot { + return + } _, readMentionsErr = t.client.API().MessagesReadReactions(ctx, &tg.MessagesReadReactionsRequest{ Peer: inputPeer, TopMsgID: topicID, @@ -709,6 +715,10 @@ func (t *TelegramClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridg go func() { defer wg.Done() + if isBot { + return + } + message := msg.ExactMessage if message == nil { message, readMessagesErr = t.main.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, time.Now()) diff --git a/pkg/connector/handletelegram.go b/pkg/connector/handletelegram.go index 56cb1d3d..d226f6f4 100644 --- a/pkg/connector/handletelegram.go +++ b/pkg/connector/handletelegram.go @@ -1035,7 +1035,9 @@ func (t *TelegramClient) getAvailableReactionsForCapability(ctx context.Context) } func (t *TelegramClient) getAvailableReactions(ctx context.Context) (map[string]struct{}, error) { - if !t.IsLoggedIn() { + if t.metadata.IsBot { + return nil, nil + } else if !t.IsLoggedIn() { return nil, errors.New("you must be logged in to get available reactions") } diff --git a/pkg/connector/login.go b/pkg/connector/login.go index 5ddf1f73..9cf116ba 100644 --- a/pkg/connector/login.go +++ b/pkg/connector/login.go @@ -41,7 +41,7 @@ import ( const ( LoginFlowIDPhone = "phone" LoginFlowIDQR = "qr" - LoginFlowIDBotToken = "bot_token" + LoginFlowIDBotToken = "bot" LoginStepIDComplete = "fi.mau.telegram.login.complete" ) @@ -76,6 +76,11 @@ func (tg *TelegramConnector) GetLoginFlows() []bridgev2.LoginFlow { Description: "Login by scanning a QR code from your phone", ID: LoginFlowIDQR, }, + { + Name: "Bot token", + Description: "Log in as a bot using the bot token provided by BotFather.", + ID: LoginFlowIDBotToken, + }, } } @@ -86,6 +91,8 @@ func (tg *TelegramConnector) CreateLogin(ctx context.Context, user *bridgev2.Use flowID: flowID, } switch flowID { + case LoginFlowIDBotToken: + return &BotLogin{baseLogin: bl}, nil case LoginFlowIDPhone: return &PhoneLogin{baseLogin: bl}, nil case LoginFlowIDQR: @@ -205,6 +212,9 @@ func (bl *baseLogin) finalizeLogin( client := ul.Client.(*TelegramClient) go func() { + if metadata.IsBot { + return + } log := ul.Log.With().Str("action", "post-login sync").Logger() err := client.clientInitialized.Wait(ctx) if err != nil { @@ -215,6 +225,9 @@ func (bl *baseLogin) finalizeLogin( }() go func() { + if metadata.IsBot { + return + } log := ul.Log.With().Str("component", "post-login takeout").Logger() client.takeoutLock.Lock() defer client.takeoutLock.Unlock() diff --git a/pkg/connector/loginbot.go b/pkg/connector/loginbot.go new file mode 100644 index 00000000..89852785 --- /dev/null +++ b/pkg/connector/loginbot.go @@ -0,0 +1,109 @@ +// 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" + "encoding/json" + "fmt" + "net/http" + + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" +) + +const ( + LoginStepIDBotToken = "fi.mau.telegram.login.bot_token" +) + +type BotLogin struct { + *baseLogin +} + +func (bl *BotLogin) StartWithOverride(ctx context.Context, override *bridgev2.UserLogin) (*bridgev2.LoginStep, error) { + meta := override.Metadata.(*UserLoginMetadata) + if !meta.IsBot { + return nil, fmt.Errorf("can't re-login to a non-bot account with bot token") + } + return bl.Start(ctx) +} + +func (bl *BotLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) { + return &bridgev2.LoginStep{ + Type: bridgev2.LoginStepTypeUserInput, + StepID: LoginStepIDBotToken, + Instructions: "Please enter the bot token you want to log in as", + UserInputParams: &bridgev2.LoginUserInputParams{ + Fields: []bridgev2.LoginInputDataField{{ + Type: bridgev2.LoginInputFieldTypeToken, + ID: LoginStepIDBotToken, + Name: "Bot token", + Pattern: `^\d+:[A-Za-z0-9_-]{35}$`, + }}, + }, + }, nil +} + +func (bl *BotLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) { + log := zerolog.Ctx(ctx).With().Str("component", "telegram bot login").Logger() + ctx = log.WithContext(ctx) + + botToken := input[LoginStepIDBotToken] + err := logoutBotAPI(ctx, botToken) + if err != nil { + return nil, fmt.Errorf("failed to logout from bot API: %w", err) + } + + err = bl.makeClient(ctx, nil) + if err != nil { + return nil, err + } + authorization, err := bl.client.Auth().Bot(ctx, botToken) + if err != nil { + bl.Cancel() + return nil, err + } + return bl.finalizeLogin(ctx, authorization, &UserLoginMetadata{IsBot: true}) +} + +type botAPIResponse struct { + OK bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +func logoutBotAPI(ctx context.Context, token string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.telegram.org/bot"+token+"/logOut", nil) + if err != nil { + return fmt.Errorf("failed to prepare request: %w", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + var respData botAPIResponse + err = json.NewDecoder(resp.Body).Decode(&respData) + _ = resp.Body.Close() + if err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } else if !respData.OK && respData.Description != "Logged out" { + return fmt.Errorf("response error %d: %s", respData.ErrorCode, respData.Description) + } + return nil +} + +var _ bridgev2.LoginProcessUserInput = (*BotLogin)(nil) diff --git a/pkg/connector/reactions.go b/pkg/connector/reactions.go index 8edeccc5..ed19b58c 100644 --- a/pkg/connector/reactions.go +++ b/pkg/connector/reactions.go @@ -55,11 +55,9 @@ func (t *TelegramClient) computeReactionsList(ctx context.Context, peer tg.PeerC if len(reactionsList) < totalCount { if user, ok := peer.(*tg.PeerUser); ok { reactionsList = splitDMReactionCounts(msgReactions.Results, user.UserID, t.telegramUserID) - - // TODO - // } else if t.isBot { - // // Can't fetch exact reaction senders as a bot - // return + } else if t.metadata.IsBot { + // Can't fetch exact reaction senders as a bot + return // TODO remove redundant peer roundtrip, just add a peer -> input peer helper } else if peer, _, err := t.inputPeerForPortalID(ctx, t.makePortalKeyFromPeer(peer, 0).ID); err != nil { @@ -209,7 +207,7 @@ func (t *TelegramClient) getReactionLimit(ctx context.Context, sender networkid. func (t *TelegramClient) maybePollForReactions(ctx context.Context, portal *bridgev2.Portal) error { // Only poll for reactions in supergroups - if portal == nil || !portal.Metadata.(*PortalMetadata).IsSuperGroup || portal.RoomType == database.RoomTypeSpace { + if t.metadata.IsBot || portal == nil || !portal.Metadata.(*PortalMetadata).IsSuperGroup || portal.RoomType == database.RoomTypeSpace { return nil }