// mautrix-telegram - A Matrix-Telegram puppeting bridge. // Copyright (C) 2025 Sumner Evans // // 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" "fmt" "slices" "time" "github.com/rs/zerolog" "go.mau.fi/util/ptr" "maunium.net/go/mautrix/bridgev2" "maunium.net/go/mautrix/bridgev2/database" "maunium.net/go/mautrix/bridgev2/networkid" "maunium.net/go/mautrix/event" "go.mau.fi/mautrix-telegram/pkg/gotd/tg" "go.mau.fi/mautrix-telegram/pkg/connector/ids" ) var ( anyonePowerLevel = ptr.Ptr(0) modPowerLevel = ptr.Ptr(50) superadminPowerLevel = ptr.Ptr(75) creatorPowerLevel = ptr.Ptr(95) otherPowerLevel = ptr.Ptr(40) anonymousPowerLevel = ptr.Ptr(41) postMessagesPowerLevel = ptr.Ptr(42) editMessagesPowerLevel = ptr.Ptr(43) deleteMessagesPowerLevel = ptr.Ptr(44) postStoriesPowerLevel = ptr.Ptr(45) editStoriesPowerLevel = ptr.Ptr(46) deleteStoriesPowerLevel = ptr.Ptr(47) changeInfoPowerLevel = ptr.Ptr(50) inviteUsersPowerLevel = ptr.Ptr(51) manageCallPowerLevel = ptr.Ptr(52) pinMessagesPowerLevel = ptr.Ptr(53) manageTopicsPowerLevel = ptr.Ptr(54) banUsersPowerLevel = ptr.Ptr(55) addAdminsPowerLevel = ptr.Ptr(60) ) func adminRightsToPowerLevel(rights tg.ChatAdminRights) *int { if rights.AddAdmins { return addAdminsPowerLevel } else if rights.BanUsers { return banUsersPowerLevel } else if rights.ManageTopics { return manageTopicsPowerLevel } else if rights.PinMessages { return pinMessagesPowerLevel } else if rights.ManageCall { return manageCallPowerLevel } else if rights.InviteUsers { return inviteUsersPowerLevel } else if rights.ChangeInfo { return changeInfoPowerLevel } else if rights.DeleteStories { return deleteStoriesPowerLevel } else if rights.EditStories { return editStoriesPowerLevel } else if rights.PostStories { return postStoriesPowerLevel } else if rights.DeleteMessages { return deleteMessagesPowerLevel } else if rights.EditMessages { return editMessagesPowerLevel } else if rights.PostMessages { return postMessagesPowerLevel } else if rights.Anonymous { return anonymousPowerLevel } return otherPowerLevel } func (t *TelegramClient) getDMChatInfo(userID int64) *bridgev2.ChatInfo { chatInfo := bridgev2.ChatInfo{ Type: ptr.Ptr(database.RoomTypeDM), Members: &bridgev2.ChatMemberList{ IsFull: true, MemberMap: map[networkid.UserID]bridgev2.ChatMember{}, }, CanBackfill: true, } chatInfo.Members.MemberMap[ids.MakeUserID(userID)] = bridgev2.ChatMember{EventSender: t.senderForUserID(userID)} chatInfo.Members.MemberMap[t.userID] = bridgev2.ChatMember{EventSender: t.mySender()} if userID == t.telegramUserID { chatInfo.Avatar = &bridgev2.Avatar{ ID: networkid.AvatarID(t.main.Config.SavedMessagesAvatar), Remove: len(t.main.Config.SavedMessagesAvatar) == 0, MXC: t.main.Config.SavedMessagesAvatar, Hash: sha256.Sum256([]byte(t.main.Config.SavedMessagesAvatar)), } chatInfo.Name = ptr.Ptr("Telegram Saved Messages") chatInfo.Topic = ptr.Ptr("Your Telegram cloud storage chat") } return &chatInfo } func (t *TelegramClient) getGroupChatInfo(fullChat *tg.MessagesChatFull, chatID int64) (*bridgev2.ChatInfo, bool, error) { var name *string var isBroadcastChannel, isMegagroup bool for _, c := range fullChat.GetChats() { if c.GetID() == chatID { switch chat := c.(type) { case *tg.Chat: name = &chat.Title case *tg.Channel: name = &chat.Title isBroadcastChannel = chat.Broadcast isMegagroup = chat.Megagroup } break } } chatInfo := bridgev2.ChatInfo{ Name: name, Type: ptr.Ptr(database.RoomTypeDefault), Members: &bridgev2.ChatMemberList{ IsFull: true, MemberMap: map[networkid.UserID]bridgev2.ChatMember{}, }, CanBackfill: true, ExtraUpdates: func(ctx context.Context, p *bridgev2.Portal) bool { 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 }, } if ttl, ok := fullChat.FullChat.GetTTLPeriod(); ok { chatInfo.Disappear = &database.DisappearingSetting{ Type: database.DisappearingTypeAfterSend, Timer: time.Duration(ttl) * time.Second, } } if about := fullChat.FullChat.GetAbout(); about != "" { chatInfo.Topic = &about } return &chatInfo, isBroadcastChannel, nil } func (t *TelegramClient) avatarFromPhoto(ctx context.Context, peerType ids.PeerType, peerID int64, photo tg.PhotoClass) *bridgev2.Avatar { if photo == nil { zerolog.Ctx(ctx).Trace().Msg("Chat photo is nil, returning no avatar") return nil } else if photo.TypeID() != tg.PhotoTypeID { zerolog.Ctx(ctx).Warn().Uint32("type_id", photo.TypeID()).Msg("Chat photo type unknown, returning no avatar") return nil } avatar, err := t.convertPhoto(ctx, peerType, peerID, photo) if err != nil { zerolog.Ctx(ctx).Err(err).Int64("id", photo.GetID()).Msg("Failed to convert avatar") return nil } return avatar } func (t *TelegramClient) filterChannelParticipants(participants []tg.ChannelParticipantClass, limit int) (members []bridgev2.ChatMember) { for _, u := range participants { var userID int64 var powerLevel *int switch participant := u.(type) { case *tg.ChannelParticipant: userID = participant.GetUserID() case *tg.ChannelParticipantSelf: userID = participant.GetUserID() case *tg.ChannelParticipantCreator: userID = participant.GetUserID() powerLevel = creatorPowerLevel case *tg.ChannelParticipantAdmin: userID = participant.GetUserID() powerLevel = adminRightsToPowerLevel(participant.AdminRights) default: continue } members = append(members, bridgev2.ChatMember{ EventSender: t.senderForUserID(userID), PowerLevel: powerLevel, }) if len(members) >= limit { break } } return } func (t *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error) { // FIXME GetFullChat should be avoided. Using only bundled info should be preferred whenever possible // (e.g. when syncing dialogs, only use the data in the dialog list, don't fetch each chat info separately). peerType, id, err := ids.ParsePortalID(portal.ID) if err != nil { return nil, err } switch peerType { case ids.PeerTypeUser: return t.getDMChatInfo(id), nil case ids.PeerTypeChat: fullChat, err := APICallWithUpdates(ctx, t, func() (*tg.MessagesChatFull, error) { return t.client.API().MessagesGetFullChat(ctx, id) }) if err != nil { return nil, err } chatInfo, _, err := t.getGroupChatInfo(fullChat, id) if err != nil { return nil, err } chatFull, ok := fullChat.FullChat.(*tg.ChatFull) if !ok { return nil, fmt.Errorf("full chat is %T not *tg.ChatFull", fullChat.FullChat) } chatInfo.Avatar = t.avatarFromPhoto(ctx, peerType, id, chatFull.ChatPhoto) if chatFull.Participants.TypeID() == tg.ChatParticipantsForbiddenTypeID { chatInfo.Members.IsFull = false return chatInfo, nil } chatParticipants := chatFull.Participants.(*tg.ChatParticipants) if !t.main.Config.ShouldBridge(len(chatParticipants.Participants)) { // TODO change this to a better error whenever that is implemented in mautrix-go return nil, fmt.Errorf("too many participants (%d) in chat %d", len(chatParticipants.Participants), id) } for _, user := range chatParticipants.GetParticipants() { if user.TypeID() == tg.ChannelParticipantBannedTypeID { continue } var powerLevel *int switch user.(type) { case *tg.ChatParticipantCreator: powerLevel = creatorPowerLevel case *tg.ChatParticipantAdmin: powerLevel = modPowerLevel } chatInfo.Members.MemberMap[ids.MakeUserID(user.GetUserID())] = bridgev2.ChatMember{ EventSender: t.senderForUserID(user.GetUserID()), PowerLevel: powerLevel, } if len(chatInfo.Members.MemberMap) >= t.main.Config.MemberList.NormalizedMaxInitialSync() { break } } return chatInfo, nil case ids.PeerTypeChannel: accessHash, err := t.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id) if err != nil { return nil, fmt.Errorf("failed to get channel access hash: %w", err) } inputChannel := &tg.InputChannel{ChannelID: id, AccessHash: accessHash} fullChat, err := APICallWithUpdates(ctx, t, func() (*tg.MessagesChatFull, error) { return t.client.API().ChannelsGetFullChannel(ctx, inputChannel) }) if err != nil { return nil, err } chatInfo, isBroadcastChannel, err := t.getGroupChatInfo(fullChat, id) if err != nil { return nil, err } channelFull, ok := fullChat.FullChat.(*tg.ChannelFull) if !ok { return nil, fmt.Errorf("full chat is %T not *tg.ChannelFull", fullChat.FullChat) } if portal.Metadata.(*PortalMetadata).IsSuperGroup && !t.main.Config.ShouldBridge(channelFull.ParticipantsCount) { // TODO change this to a better error whenever that is implemented in mautrix-go return nil, fmt.Errorf("too many participants (%d) in chat %d", channelFull.ParticipantsCount, id) } chatInfo.Avatar = t.avatarFromPhoto(ctx, peerType, id, channelFull.ChatPhoto) // TODO save available reactions? // TODO save reactions limit? // TODO save emojiset? chatInfo.Members.IsFull = false chatInfo.Members.PowerLevels = t.getGroupChatPowerLevels(ctx, fullChat.GetChats()[0]) if !portal.Metadata.(*PortalMetadata).IsSuperGroup { // Add the channel user sender := ids.MakeChannelUserID(id) chatInfo.Members.MemberMap[sender] = bridgev2.ChatMember{ EventSender: bridgev2.EventSender{Sender: sender}, PowerLevel: superadminPowerLevel, } } // Just return the current user as a member if we can't view the // participants or the max initial sync is 0. if t.main.Config.MemberList.MaxInitialSync == 0 || !channelFull.CanViewParticipants || channelFull.ParticipantsHidden { return chatInfo, nil } // If this is a broadcast channel and we're not syncing broadcast // channels, just return the chat info without all of the participant // info. if isBroadcastChannel && !t.main.Config.MemberList.SyncBroadcastChannels { return chatInfo, nil } limit := t.main.Config.MemberList.NormalizedMaxInitialSync() if limit <= 200 { participants, err := APICallWithUpdates(ctx, t, func() (*tg.ChannelsChannelParticipants, error) { p, err := t.client.API().ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{ Channel: inputChannel, Filter: &tg.ChannelParticipantsRecent{}, Limit: limit, }) if err != nil { return nil, err } participants, ok := p.(*tg.ChannelsChannelParticipants) if !ok { return nil, fmt.Errorf("returned participants is %T not *tg.ChannelsChannelParticipants", p) } else { return participants, nil } }) if err != nil { return nil, err } chatInfo.Members.IsFull = len(participants.Participants) < limit for _, participant := range t.filterChannelParticipants(participants.Participants, limit) { chatInfo.Members.MemberMap[participant.Sender] = participant } } else { remaining := t.main.Config.MemberList.NormalizedMaxInitialSync() var offset int for remaining > 0 { participants, err := APICallWithUpdates(ctx, t, func() (*tg.ChannelsChannelParticipants, error) { p, err := t.client.API().ChannelsGetParticipants(ctx, &tg.ChannelsGetParticipantsRequest{ Channel: inputChannel, Filter: &tg.ChannelParticipantsSearch{}, Limit: min(remaining, 200), Offset: offset, }) if err != nil { return nil, err } participants, ok := p.(*tg.ChannelsChannelParticipants) if !ok { return nil, fmt.Errorf("returned participants is %T not *tg.ChannelsChannelParticipants", p) } else { return participants, nil } }) if err != nil { return nil, err } if len(participants.Participants) == 0 { chatInfo.Members.IsFull = true break } for _, participant := range t.filterChannelParticipants(participants.Participants, limit) { chatInfo.Members.MemberMap[participant.Sender] = participant } offset += len(participants.Participants) remaining -= len(participants.Participants) } } return chatInfo, nil default: panic(fmt.Sprintf("unsupported peer type %s", peerType)) } } func (t *TelegramClient) getDMPowerLevels(ghost *bridgev2.Ghost) *bridgev2.PowerLevelOverrides { var plo bridgev2.PowerLevelOverrides if ghost.Metadata.(*GhostMetadata).Blocked { // Don't allow sending messages to blocked users plo.EventsDefault = superadminPowerLevel } else { plo.EventsDefault = anyonePowerLevel } return &plo } func (t *TelegramClient) getGroupChatPowerLevels(ctx context.Context, entity tg.ChatClass) *bridgev2.PowerLevelOverrides { log := zerolog.Ctx(ctx).With(). Str("action", "get_group_chat_power_levels"). Logger() dbrAble, ok := entity.(interface { GetDefaultBannedRights() (tg.ChatBannedRights, bool) }) var dbr tg.ChatBannedRights if ok { dbr, ok = dbrAble.GetDefaultBannedRights() if !ok { dbr = tg.ChatBannedRights{ InviteUsers: true, ChangeInfo: true, PinMessages: true, SendStickers: false, SendMessages: false, } } } else { log.Error(). Type("entity_type", entity). Msg("couldn't get default banned rights from entity, assuming you don't have any rights") } return t.getPowerLevelOverridesFromBannedRights(entity, dbr) } func (t *TelegramClient) getPowerLevelOverridesFromBannedRights(entity tg.ChatClass, dbr tg.ChatBannedRights) *bridgev2.PowerLevelOverrides { var plo bridgev2.PowerLevelOverrides plo.Ban = banUsersPowerLevel plo.Kick = banUsersPowerLevel plo.Redact = deleteMessagesPowerLevel if dbr.InviteUsers { plo.Invite = inviteUsersPowerLevel } else { plo.Invite = anyonePowerLevel } plo.StateDefault = superadminPowerLevel plo.UsersDefault = anyonePowerLevel if c, ok := entity.(*tg.Channel); (ok && !c.Megagroup) || dbr.SendMessages { plo.EventsDefault = postMessagesPowerLevel } else { plo.EventsDefault = anyonePowerLevel } plo.Events = map[event.Type]int{ event.StateEncryption: 99, event.StateTombstone: 99, event.StatePowerLevels: 85, event.StateHistoryVisibility: 85, } if dbr.ChangeInfo { plo.Events[event.StateRoomName] = *changeInfoPowerLevel plo.Events[event.StateRoomAvatar] = *changeInfoPowerLevel plo.Events[event.StateTopic] = *changeInfoPowerLevel } if dbr.PinMessages { plo.Events[event.StatePinnedEvents] = *pinMessagesPowerLevel } else { plo.Events[event.StatePinnedEvents] = 0 } if dbr.SendStickers { plo.Events[event.EventSticker] = *postMessagesPowerLevel } return &plo }