7e75c8ef83
The only exception is emojis. Also changed direct download encoding field names to be more generic when used in mixed manner depending on peer type. Direct downloads are still somewhat inefficient as they require an API round trip to succeed but we can cache things in the database if needed.
505 lines
16 KiB
Go
505 lines
16 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
package connector
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/gotd/td/tg"
|
|
"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/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
|
|
}
|