mirror of
https://github.com/mautrix/telegram.git
synced 2026-05-17 07:25:46 +03:00
1441 lines
47 KiB
Go
1441 lines
47 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 (
|
|
"bytes"
|
|
"cmp"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/jpeg"
|
|
_ "image/jpeg"
|
|
_ "image/png"
|
|
"io"
|
|
"math"
|
|
"math/rand/v2"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"go.mau.fi/util/exsync"
|
|
"go.mau.fi/util/ffmpeg"
|
|
"go.mau.fi/util/jsontime"
|
|
"go.mau.fi/util/variationselector"
|
|
"go.mau.fi/webp"
|
|
"golang.org/x/exp/maps"
|
|
_ "golang.org/x/image/webp"
|
|
"golang.org/x/net/html"
|
|
"maunium.net/go/mautrix/bridgev2"
|
|
"maunium.net/go/mautrix/bridgev2/database"
|
|
"maunium.net/go/mautrix/bridgev2/networkid"
|
|
"maunium.net/go/mautrix/bridgev2/simplevent"
|
|
"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"
|
|
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
|
|
"go.mau.fi/mautrix-telegram/pkg/connector/matrixfmt"
|
|
"go.mau.fi/mautrix-telegram/pkg/connector/waveform"
|
|
)
|
|
|
|
var (
|
|
_ bridgev2.EditHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.ReactionHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.RedactionHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.ReadReceiptHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.TypingHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.DisappearTimerChangingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.MuteHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.TagHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.ChatViewingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.DeleteChatHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.RoomNameHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.RoomAvatarHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
_ bridgev2.MembershipHandlingNetworkAPI = (*TelegramClient)(nil)
|
|
)
|
|
|
|
func getMediaFilename(content *event.MessageEventContent) (filename string) {
|
|
if content.FileName != "" {
|
|
filename = content.FileName
|
|
} else {
|
|
filename = content.Body
|
|
}
|
|
if filename == "" {
|
|
return "image.jpg" // Assume it's a JPEG image
|
|
}
|
|
if content.MsgType == event.MsgImage && (!strings.HasSuffix(filename, ".jpg") && !strings.HasSuffix(filename, ".jpeg") && !strings.HasSuffix(filename, ".png")) {
|
|
if content.Info != nil && content.Info.MimeType != "" {
|
|
return filename + "." + strings.TrimPrefix(content.Info.MimeType, "image/")
|
|
}
|
|
return filename + ".jpg" // Assume it's a JPEG
|
|
}
|
|
return filename
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixViewingChat(ctx context.Context, msg *bridgev2.MatrixViewingChat) error {
|
|
if msg.Portal == nil {
|
|
return nil
|
|
}
|
|
_, _, topicID, _ := ids.ParsePortalID(msg.Portal.PortalKey.ID)
|
|
// TODO sync topic parent space
|
|
meta := msg.Portal.Metadata.(*PortalMetadata)
|
|
if (topicID == 0 && !meta.FullSynced) || meta.LastSync.Add(24*time.Hour).Before(time.Now()) {
|
|
tc.userLogin.QueueRemoteEvent(&simplevent.ChatResync{
|
|
EventMeta: simplevent.EventMeta{
|
|
Type: bridgev2.RemoteEventChatResync,
|
|
PortalKey: msg.Portal.PortalKey,
|
|
},
|
|
GetChatInfoFunc: tc.GetChatInfo,
|
|
})
|
|
}
|
|
err := tc.maybePollForReactions(ctx, msg.Portal)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = tc.pollSponsoredMessage(ctx, msg.Portal)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tc *TelegramClient) pollSponsoredMessage(ctx context.Context, portal *bridgev2.Portal) error {
|
|
if tc.metadata.IsBot {
|
|
return nil
|
|
}
|
|
meta := portal.Metadata.(*PortalMetadata)
|
|
peerType, id, topicID, err := ids.ParsePortalID(portal.ID)
|
|
if err != nil {
|
|
return err
|
|
} else if peerType != ids.PeerTypeChannel || meta.IsSuperGroup || topicID != 0 {
|
|
return nil
|
|
}
|
|
meta.sponsoredMessageLock.Lock()
|
|
defer meta.sponsoredMessageLock.Unlock()
|
|
if time.Since(meta.SponsoredMessagePollTS.Time) < 5*time.Minute {
|
|
return nil
|
|
}
|
|
latestMessage, err := tc.main.Bridge.DB.Message.GetLastNonFakePartAtOrBeforeTime(ctx, portal.PortalKey, time.Now())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get latest message for portal: %w", err)
|
|
} else if latestMessage != nil && latestMessage.ID == meta.LastMessageOnSponsorFetch {
|
|
meta.SponsoredMessagePollTS = jsontime.UnixNow()
|
|
return nil
|
|
}
|
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := tc.client.API().MessagesGetSponsoredMessages(ctx, &tg.MessagesGetSponsoredMessagesRequest{
|
|
Peer: &tg.InputPeerChannel{ChannelID: id, AccessHash: accessHash},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get sponsored messages: %w", err)
|
|
}
|
|
meta.SponsoredMessagePollTS = jsontime.UnixNow()
|
|
if latestMessage != nil {
|
|
meta.LastMessageOnSponsorFetch = latestMessage.ID
|
|
}
|
|
msgs, ok := resp.(*tg.MessagesSponsoredMessages)
|
|
if !ok || len(msgs.Messages) == 0 || (len(msgs.Messages) == 1 && bytes.Equal(msgs.Messages[0].RandomID, meta.SponsoredMessageRandomID)) {
|
|
err = portal.Save(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save portal after polling sponsored messages: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
if meta.sponsoredMessageSeen == nil {
|
|
meta.sponsoredMessageSeen = exsync.NewSet[int64]()
|
|
} else {
|
|
meta.sponsoredMessageSeen.Clear()
|
|
}
|
|
msg := msgs.Messages[0]
|
|
if bytes.Equal(msg.RandomID, meta.SponsoredMessageRandomID) && len(msgs.Messages) > 1 {
|
|
msg = msgs.Messages[1]
|
|
}
|
|
meta.SponsoredMessageRandomID = msg.RandomID
|
|
content := tc.parseBodyAndHTML(ctx, msg.Message, msg.Entities)
|
|
content.MsgType = event.MsgNotice
|
|
content.EnsureHasHTML()
|
|
extra := map[string]any{
|
|
"external_url": msg.URL,
|
|
"fi.mau.telegram.sponsored": map[string]any{
|
|
"random_id": msg.RandomID,
|
|
"url": msg.URL,
|
|
"button_text": msg.ButtonText,
|
|
"title": msg.Title,
|
|
"content": content.FormattedBody,
|
|
"sponsor_info": msg.SponsorInfo,
|
|
"additional_info": msg.AdditionalInfo,
|
|
"recommended": msg.Recommended,
|
|
},
|
|
}
|
|
var fromStr string
|
|
if msg.SponsorInfo != "" {
|
|
fromStr = fmt.Sprintf(" from %s", html.EscapeString(msg.SponsorInfo))
|
|
}
|
|
prefix := "Ad"
|
|
if msg.Recommended {
|
|
prefix = "Recommended"
|
|
}
|
|
content.FormattedBody = fmt.Sprintf(
|
|
`<strong>%s: %s</strong><blockquote>%s</blockquote><p>Sponsored message%s - <a href="%s">%s</a></p>`,
|
|
prefix, html.EscapeString(msg.Title), content.FormattedBody, fromStr, msg.URL, msg.ButtonText,
|
|
)
|
|
sendResp, err := tc.main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventMessage, &event.Content{
|
|
Raw: extra,
|
|
Parsed: content,
|
|
}, &bridgev2.MatrixSendExtra{Timestamp: time.Now()})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send sponsored message: %w", err)
|
|
}
|
|
oldSponsoredMessageMXID := meta.SponsoredMessageEventID
|
|
if oldSponsoredMessageMXID != "" {
|
|
_, err = tc.main.Bridge.Bot.SendMessage(ctx, portal.MXID, event.EventRedaction, &event.Content{
|
|
Parsed: &event.RedactionEventContent{
|
|
Reason: "new sponsored message sent",
|
|
Redacts: oldSponsoredMessageMXID,
|
|
},
|
|
Raw: map[string]any{
|
|
"com.beeper.dont_render_redacted_placeholder": true,
|
|
},
|
|
}, &bridgev2.MatrixSendExtra{Timestamp: time.Now()})
|
|
if err != nil {
|
|
zerolog.Ctx(ctx).Warn().Err(err).Msg("Failed to redact old sponsored message after sending new one")
|
|
}
|
|
}
|
|
meta.SponsoredMessageEventID = sendResp.EventID
|
|
zerolog.Ctx(ctx).Debug().
|
|
Stringer("event_id", sendResp.EventID).
|
|
Str("random_id", base64.StdEncoding.EncodeToString(msg.RandomID)).
|
|
Msg("Sent sponsored message to Matrix")
|
|
err = portal.Save(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to save portal after sending sponsored messages: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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") {
|
|
tempFile, err := os.CreateTemp("", "telegram-sticker-*.webp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempFile.Name())
|
|
}()
|
|
if img, _, err := image.Decode(f); err != nil {
|
|
return fmt.Errorf("failed to decode sticker image: %w", err)
|
|
} else if err := webp.Encode(tempFile, img, nil); err != nil {
|
|
return fmt.Errorf("failed to encode sticker webp image: %w", err)
|
|
}
|
|
uploadFilename = tempFile.Name()
|
|
info.MimeType = "image/webp"
|
|
} 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)
|
|
}
|
|
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 {
|
|
return err
|
|
} else {
|
|
// Telegram restricts photos in the following ways according to:
|
|
// https://core.telegram.org/tdlib/docs/classtd_1_1td__api_1_1input_message_photo.html#ae1229ec5026a0b29dc398d87211bf572
|
|
//
|
|
// * The photo must be at most 10 MB in size.
|
|
// * The photo's width and height must not exceed 10,000 in total
|
|
// * Width and height ratio must be at most 20.
|
|
//
|
|
// We also have the image_as_file_pixels configuration threshold to
|
|
// prevent Telegram from compressing the file.
|
|
aspectRatio := float64(max(cfg.Height, cfg.Width)) / float64(min(cfg.Height, cfg.Width))
|
|
forceDocument = forceDocument ||
|
|
cfg.Height*cfg.Width > tc.main.Config.ImageAsFilePixels ||
|
|
fileInfo.Size() > int64(10*1024*1024) ||
|
|
aspectRatio > 20 ||
|
|
cfg.Height+cfg.Width > 10000
|
|
}
|
|
if !forceDocument && !sticker && content.MsgType == event.MsgImage {
|
|
_, err = f.Seek(0, io.SeekStart)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempFile, err := os.CreateTemp("", "telegram-nonsticker-*.jpeg")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = tempFile.Close()
|
|
_ = os.Remove(tempFile.Name())
|
|
}()
|
|
if img, _, err := image.Decode(f); err != nil {
|
|
return fmt.Errorf("failed to decode non-sticker webp image: %w", err)
|
|
} else if err := jpeg.Encode(tempFile, img, nil); err != nil {
|
|
return fmt.Errorf("failed to encode non-sticker jpeg image: %w", err)
|
|
}
|
|
uploadFilename = tempFile.Name()
|
|
filename += ".jpeg"
|
|
info.MimeType = "image/jpeg"
|
|
}
|
|
|
|
upload, err = uploader.NewUploader(tc.client.API()).FromPath(ctx, uploadFilename, filename)
|
|
return
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to download media from Matrix and upload media to Telegram: %w", err)
|
|
}
|
|
|
|
if !forceDocument && content.MsgType == event.MsgImage && (info.MimeType == "image/jpeg" || info.MimeType == "image/png") {
|
|
return &tg.InputMediaUploadedPhoto{File: upload}, nil
|
|
}
|
|
|
|
var attributes []tg.DocumentAttributeClass
|
|
attributes = append(attributes, &tg.DocumentAttributeFilename{FileName: filename})
|
|
|
|
if info.Width != 0 && info.Height != 0 && content.MsgType == event.MsgImage {
|
|
attributes = append(attributes, &tg.DocumentAttributeImageSize{W: info.Width, H: info.Height})
|
|
}
|
|
|
|
if info.MauGIF {
|
|
attributes = append(attributes, &tg.DocumentAttributeAnimated{})
|
|
}
|
|
|
|
if sticker {
|
|
attributes = append(attributes, &tg.DocumentAttributeSticker{
|
|
Alt: content.Body,
|
|
Stickerset: &tg.InputStickerSetEmpty{},
|
|
})
|
|
} else if content.MsgType == event.MsgAudio {
|
|
audioAttr := &tg.DocumentAttributeAudio{
|
|
Voice: content.MSC3245Voice != nil,
|
|
Duration: info.Duration / 1000,
|
|
}
|
|
if content.MSC1767Audio != nil && len(content.MSC1767Audio.Waveform) > 0 {
|
|
audioAttr.Waveform = waveform.Encode(content.MSC1767Audio.Waveform)
|
|
}
|
|
attributes = append(attributes, audioAttr)
|
|
} else if content.MsgType == event.MsgVideo {
|
|
attributes = append(attributes, &tg.DocumentAttributeVideo{
|
|
Duration: float64(info.Duration) / 1000,
|
|
W: info.Width,
|
|
H: info.Height,
|
|
})
|
|
}
|
|
|
|
return &tg.InputMediaUploadedDocument{
|
|
File: upload,
|
|
MimeType: cmp.Or(info.MimeType, "application/octet-stream"),
|
|
Attributes: attributes,
|
|
}, nil
|
|
}
|
|
|
|
func (tc *TelegramClient) humaniseSendError(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
status := bridgev2.WrapErrorInStatus(err).
|
|
WithErrorReason(event.MessageStatusNetworkError).
|
|
WithMessage(humanise.Error(err))
|
|
|
|
switch {
|
|
case tg.IsYouBlockedUser(err),
|
|
tg.IsUserIsBlocked(err),
|
|
tg.IsUserBlocked(err),
|
|
tg.IsUserBannedInChannel(err),
|
|
tg.IsChatAdminRequired(err),
|
|
tg.IsChatRestricted(err),
|
|
tg.IsChatWriteForbidden(err):
|
|
status = status.WithErrorReason(event.MessageStatusNoPermission)
|
|
case tg.IsMessageEmpty(err),
|
|
tg.IsMessageTooLong(err),
|
|
tg.IsEntitiesTooLong(err),
|
|
tg.IsEntityBoundsInvalid(err),
|
|
tg.IsEntityMentionUserInvalid(err):
|
|
status = status.WithErrorReason(event.MessageStatusUnsupported)
|
|
case tg.IsMessageEditTimeExpired(err):
|
|
return status.WithErrorReason(event.MessageStatusUnsupported)
|
|
case tg.IsMessageNotModified(err):
|
|
status = status.WithErrorReason(event.MessageStatusNetworkError)
|
|
default:
|
|
// Return a normal status with the default retriable status
|
|
return status
|
|
}
|
|
return status.WithIsCertain(true).
|
|
WithStatus(event.MessageStatusFail)
|
|
}
|
|
|
|
func (tc *TelegramConnector) GenerateTransactionID(userID id.UserID, roomID id.RoomID, eventType event.Type) networkid.RawTransactionID {
|
|
return networkid.RawTransactionID(strconv.FormatInt(rand.Int64(), 10))
|
|
}
|
|
|
|
func parseRandomID(txnID networkid.RawTransactionID) int64 {
|
|
if txnID != "" {
|
|
if id, err := strconv.ParseInt(string(txnID), 10, 64); err == nil && id > 0 {
|
|
return id
|
|
}
|
|
}
|
|
return rand.Int64()
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (resp *bridgev2.MatrixMessageResponse, err error) {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return nil, fmt.Errorf("can't send messages to space portals")
|
|
}
|
|
// Handle Matrix events only after initial connection has been established to avoid deadlocking gotd
|
|
err = tc.clientInitialized.Wait(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
peer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Stringer("portal_key", msg.Portal.PortalKey).
|
|
Any("peer_id", peer).
|
|
Logger()
|
|
ctx = log.WithContext(ctx)
|
|
|
|
var contentURI id.ContentURIString
|
|
|
|
noWebpage := msg.Content.BeeperLinkPreviews != nil && len(msg.Content.BeeperLinkPreviews) == 0
|
|
|
|
message, entities := matrixfmt.Parse(ctx, tc.matrixParser, msg.Content, msg.Portal)
|
|
|
|
var replyTo tg.InputReplyToClass
|
|
if msg.ReplyTo != nil {
|
|
_, messageID, err := ids.ParseMessageID(msg.ReplyTo.ID)
|
|
if err != nil {
|
|
log.Warn().Msg("failed to parse replied-to message ID")
|
|
return nil, err
|
|
}
|
|
replyTo = &tg.InputReplyToMessage{ReplyToMsgID: messageID}
|
|
}
|
|
if topicID > 0 {
|
|
if replyTo == nil {
|
|
replyTo = &tg.InputReplyToMessage{ReplyToMsgID: topicID}
|
|
} else {
|
|
replyTo.(*tg.InputReplyToMessage).TopMsgID = topicID
|
|
}
|
|
}
|
|
|
|
randomID := parseRandomID(msg.InputTransactionID)
|
|
|
|
var updates tg.UpdatesClass
|
|
if msg.Event.Type == event.EventSticker {
|
|
mediaReq := &tg.MessagesSendMediaRequest{
|
|
Peer: peer,
|
|
Message: message,
|
|
Entities: entities,
|
|
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:
|
|
updates, err = tc.client.API().MessagesSendMessage(ctx, &tg.MessagesSendMessageRequest{
|
|
Peer: peer,
|
|
NoWebpage: noWebpage,
|
|
Message: message,
|
|
Entities: entities,
|
|
ReplyTo: replyTo,
|
|
RandomID: randomID,
|
|
})
|
|
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, false, forceDocument)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updates, err = tc.client.API().MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
|
|
Peer: peer,
|
|
Message: message,
|
|
Entities: entities,
|
|
Media: media,
|
|
ReplyTo: replyTo,
|
|
RandomID: randomID,
|
|
})
|
|
case event.MsgLocation:
|
|
var uri GeoURI
|
|
uri, err = ParseGeoURI(msg.Content.GeoURI)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
message = ""
|
|
if location, ok := msg.Event.Content.Raw["org.matrix.msc3488.location"].(map[string]any); ok {
|
|
if desc, ok := location["description"].(string); ok {
|
|
message = desc
|
|
}
|
|
}
|
|
updates, err = tc.client.API().MessagesSendMedia(ctx, &tg.MessagesSendMediaRequest{
|
|
Peer: peer,
|
|
Message: message,
|
|
Media: &tg.InputMediaGeoPoint{
|
|
GeoPoint: &tg.InputGeoPoint{Lat: uri.Lat, Long: uri.Long},
|
|
},
|
|
ReplyTo: replyTo,
|
|
RandomID: randomID,
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("unsupported message type %s", msg.Content.MsgType)
|
|
}
|
|
}
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to send message to Telegram")
|
|
return nil, tc.humaniseSendError(err)
|
|
}
|
|
|
|
hasher := sha256.New()
|
|
|
|
var tgMessageID, tgDate int
|
|
switch sentMessage := updates.(type) {
|
|
case *tg.UpdateShortSentMessage:
|
|
tgMessageID = sentMessage.ID
|
|
tgDate = sentMessage.Date
|
|
hasher.Write([]byte(msg.Content.Body))
|
|
hasher.Write(mediaHashID(ctx, sentMessage.Media))
|
|
case *tg.Updates:
|
|
var realSentMessage *tg.Message
|
|
for _, u := range sentMessage.Updates {
|
|
switch update := u.(type) {
|
|
case *tg.UpdateMessageID:
|
|
tgMessageID = update.ID
|
|
if update.RandomID != randomID {
|
|
log.Warn().
|
|
Int64("update_random_id", update.RandomID).
|
|
Int64("expected_random_id", randomID).
|
|
Msg("Random ID in response does not match sent random ID")
|
|
}
|
|
case *tg.UpdateNewMessage:
|
|
if realSentMessage != nil {
|
|
log.Warn().
|
|
Int("prev_id", realSentMessage.ID).
|
|
Int("new_id", update.Message.GetID()).
|
|
Msg("Multiple messages in send response")
|
|
}
|
|
realSentMessage = update.Message.(*tg.Message)
|
|
case *tg.UpdateNewChannelMessage:
|
|
if realSentMessage != nil {
|
|
log.Warn().
|
|
Int("prev_id", realSentMessage.ID).
|
|
Int("new_id", update.Message.GetID()).
|
|
Msg("Multiple messages in send response")
|
|
}
|
|
realSentMessage = update.Message.(*tg.Message)
|
|
case *tg.UpdateReadChannelInbox, *tg.UpdateReadHistoryInbox, *tg.UpdateReadMonoForumInbox,
|
|
*tg.UpdateReadHistoryOutbox, *tg.UpdateReadChannelOutbox:
|
|
// ignore
|
|
default:
|
|
log.Warn().Type("update_type", update).Msg("Unexpected update type in send message response")
|
|
}
|
|
}
|
|
if realSentMessage != nil {
|
|
tgDate = realSentMessage.Date
|
|
hasher.Write([]byte(realSentMessage.Message))
|
|
hasher.Write(mediaHashID(ctx, realSentMessage.Media))
|
|
} else {
|
|
hasher.Write([]byte(msg.Content.Body))
|
|
tgDate = sentMessage.Date
|
|
}
|
|
if tgMessageID == 0 {
|
|
return nil, fmt.Errorf("couldn't find update message ID update")
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unknown update from message response %T", updates)
|
|
}
|
|
|
|
messageID := ids.MakeMessageID(msg.Portal.PortalKey, tgMessageID)
|
|
timestamp := time.Unix(int64(tgDate), 0)
|
|
hash := hasher.Sum(nil)
|
|
log.Info().
|
|
Int("tg_message_id", tgMessageID).
|
|
Str("message_id", string(messageID)).
|
|
Time("timestamp", timestamp).
|
|
Str("content_hash", base64.StdEncoding.EncodeToString(hash)).
|
|
Msg("sent message successfully")
|
|
|
|
resp = &bridgev2.MatrixMessageResponse{
|
|
DB: &database.Message{
|
|
ID: messageID,
|
|
SenderID: tc.userID,
|
|
Timestamp: timestamp,
|
|
Metadata: &MessageMetadata{
|
|
ContentHash: hash,
|
|
ContentURI: contentURI,
|
|
},
|
|
},
|
|
StreamOrder: int64(tgMessageID),
|
|
}
|
|
return
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return fmt.Errorf("can't send messages to space portals")
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("conversion_direction", "to_telegram").
|
|
Str("handler", "matrix_edit").
|
|
Logger()
|
|
|
|
peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, targetID, err := ids.ParseMessageID(msg.EditTarget.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
message, entities := matrixfmt.Parse(ctx, tc.matrixParser, msg.Content, msg.Portal)
|
|
|
|
var newContentURI id.ContentURIString
|
|
req := tg.MessagesEditMessageRequest{
|
|
Peer: peer,
|
|
NoWebpage: msg.Content.BeeperLinkPreviews != nil && len(msg.Content.BeeperLinkPreviews) == 0,
|
|
Message: message,
|
|
Entities: entities,
|
|
ID: targetID,
|
|
}
|
|
if msg.Content.MsgType.IsMedia() {
|
|
newContentURI = msg.Content.URL
|
|
if newContentURI == "" {
|
|
newContentURI = msg.Content.File.URL
|
|
}
|
|
if msg.EditTarget.Metadata.(*MessageMetadata).ContentURI == newContentURI {
|
|
log.Info().Msg("media URI unchanged, skipping re-upload, just editing text")
|
|
} 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, false, forceDocument)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if !msg.Content.MsgType.IsText() {
|
|
return fmt.Errorf("editing message type %s is unsupported", msg.Content.MsgType)
|
|
}
|
|
updates, err := tc.client.API().MessagesEditMessage(ctx, &req)
|
|
if err != nil {
|
|
return tc.humaniseSendError(err)
|
|
}
|
|
|
|
hasher := sha256.New()
|
|
|
|
switch sentMessage := updates.(type) {
|
|
case *tg.UpdateShortSentMessage:
|
|
hasher.Write([]byte(msg.Content.Body))
|
|
hasher.Write(mediaHashID(ctx, sentMessage.Media))
|
|
case *tg.Updates:
|
|
var realSentMessage *tg.Message
|
|
for _, u := range sentMessage.Updates {
|
|
switch update := u.(type) {
|
|
case *tg.UpdateNewMessage:
|
|
if realSentMessage != nil {
|
|
log.Warn().
|
|
Int("prev_id", realSentMessage.ID).
|
|
Int("new_id", update.Message.GetID()).
|
|
Msg("Multiple messages in edit response")
|
|
}
|
|
realSentMessage = update.Message.(*tg.Message)
|
|
case *tg.UpdateNewChannelMessage:
|
|
if realSentMessage != nil {
|
|
log.Warn().
|
|
Int("prev_id", realSentMessage.ID).
|
|
Int("new_id", update.Message.GetID()).
|
|
Msg("Multiple messages in edit response")
|
|
}
|
|
realSentMessage = update.Message.(*tg.Message)
|
|
default:
|
|
log.Warn().Type("update_type", update).Msg("Unexpected update type in edit message response")
|
|
}
|
|
}
|
|
if realSentMessage != nil {
|
|
hasher.Write([]byte(realSentMessage.Message))
|
|
hasher.Write(mediaHashID(ctx, realSentMessage.Media))
|
|
} else {
|
|
hasher.Write([]byte(msg.Content.Body))
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown update from message response %T", updates)
|
|
}
|
|
|
|
metadata := msg.EditTarget.Metadata.(*MessageMetadata)
|
|
metadata.ContentHash = hasher.Sum(nil)
|
|
metadata.ContentURI = newContentURI
|
|
return nil
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return fmt.Errorf("can't send messages to space portals")
|
|
} else if dbMsg, err := tc.main.Bridge.DB.Message.GetPartByMXID(ctx, msg.TargetMessage.MXID); err != nil {
|
|
return err
|
|
} else if _, messageID, err := ids.ParseMessageID(dbMsg.ID); err != nil {
|
|
return err
|
|
} else if peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID); err != nil {
|
|
return err
|
|
} else {
|
|
_, err := message.NewSender(tc.client.API()).
|
|
To(peer).
|
|
Revoke().
|
|
Messages(ctx, messageID)
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (tc *TelegramClient) PreHandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error) {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return bridgev2.MatrixReactionPreResponse{}, fmt.Errorf("can't send messages to space portals")
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("conversion_direction", "to_telegram").
|
|
Str("handler", "pre_handle_matrix_reaction").
|
|
Str("key", msg.Content.RelatesTo.Key).
|
|
Logger()
|
|
var resp bridgev2.MatrixReactionPreResponse
|
|
|
|
var maxReactions int
|
|
maxReactions, err := tc.getReactionLimit(ctx, tc.userID)
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
keyNoVariation := variationselector.Remove(msg.Content.RelatesTo.Key)
|
|
emojiID := ids.MakeEmojiIDFromEmoticon(msg.Content.RelatesTo.Key)
|
|
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 {
|
|
return resp, fmt.Errorf("reaction MXC URI %s does not correspond with any known Telegram files", msg.Content.RelatesTo.Key)
|
|
} else if documentID, err := strconv.ParseInt(string(file.LocationID), 10, 64); err != nil {
|
|
return resp, err
|
|
} else {
|
|
emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
|
|
}
|
|
} else if tc.main.Config.AlwaysCustomEmojiReaction {
|
|
// Always use the unicodemoji reaction if available
|
|
if documentID, ok := emojis.GetEmojiDocumentID(keyNoVariation); ok {
|
|
log.Debug().Msg("Using custom emoji reaction")
|
|
emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
|
|
}
|
|
} else if availableReactions, err := tc.getAvailableReactions(ctx); err != nil {
|
|
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 && !tc.metadata.IsBot {
|
|
log.Debug().Msg("Using custom emoji reaction")
|
|
emojiID = ids.MakeEmojiIDFromDocumentID(documentID)
|
|
}
|
|
|
|
log.Debug().Str("emoji_id", string(emojiID)).Msg("Pre-handled reaction")
|
|
|
|
return bridgev2.MatrixReactionPreResponse{
|
|
SenderID: tc.userID,
|
|
EmojiID: emojiID,
|
|
Emoji: variationselector.FullyQualify(msg.Content.RelatesTo.Key),
|
|
MaxReactions: maxReactions,
|
|
}, nil
|
|
}
|
|
|
|
func (tc *TelegramClient) appendEmojiID(reactionList []tg.ReactionClass, emojiID networkid.EmojiID) ([]tg.ReactionClass, error) {
|
|
if documentID, emoticon, err := ids.ParseEmojiID(emojiID); err != nil {
|
|
return nil, err
|
|
} else if documentID > 0 {
|
|
return append(reactionList, &tg.ReactionCustomEmoji{DocumentID: documentID}), nil
|
|
} else {
|
|
return append(reactionList, &tg.ReactionEmoji{Emoticon: emoticon}), nil
|
|
}
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (reaction *database.Reaction, err error) {
|
|
peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, targetMessageID, err := ids.ParseMessageID(msg.TargetMessage.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var newReactions []tg.ReactionClass
|
|
for _, existing := range msg.ExistingReactionsToKeep {
|
|
newReactions, err = tc.appendEmojiID(newReactions, existing.EmojiID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
newReactions, err = tc.appendEmojiID(newReactions, msg.PreHandleResp.EmojiID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
_, err = tc.client.API().MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
|
|
Peer: peer,
|
|
AddToRecent: true,
|
|
MsgID: targetMessageID,
|
|
Reaction: newReactions,
|
|
})
|
|
if tg.IsReactionInvalid(err) {
|
|
return nil, bridgev2.WrapErrorInStatus(err).
|
|
WithErrorReason(event.MessageStatusUnsupported).
|
|
WithIsCertain(true).
|
|
WithMessage("Unsupported reaction")
|
|
}
|
|
return &database.Reaction{}, err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return fmt.Errorf("can't send messages to space portals")
|
|
}
|
|
peer, _, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var newReactions []tg.ReactionClass
|
|
|
|
if maxReactions, err := tc.getReactionLimit(ctx, tc.userID); err != nil {
|
|
return err
|
|
} else if maxReactions > 1 {
|
|
existing, err := tc.main.Bridge.DB.Reaction.GetAllToMessageBySender(ctx, msg.Portal.Receiver, msg.TargetReaction.MessageID, msg.TargetReaction.SenderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, existing := range existing {
|
|
if msg.TargetReaction.EmojiID != existing.EmojiID {
|
|
newReactions, err = tc.appendEmojiID(newReactions, existing.EmojiID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_, messageID, err := ids.ParseMessageID(msg.TargetReaction.MessageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tc.client.API().MessagesSendReaction(ctx, &tg.MessagesSendReactionRequest{
|
|
Peer: peer,
|
|
AddToRecent: true,
|
|
MsgID: messageID,
|
|
Reaction: newReactions,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return nil
|
|
}
|
|
log := zerolog.Ctx(ctx).With().
|
|
Str("action", "handle_matrix_read_receipt").
|
|
Str("portal_id", string(msg.Portal.ID)).
|
|
Bool("is_supergroup", msg.Portal.Metadata.(*PortalMetadata).IsSuperGroup).
|
|
Logger()
|
|
peerType, portalID, topicID, parseErr := ids.ParsePortalID(msg.Portal.ID)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
if msg.Portal.Metadata.(*PortalMetadata).IsForumGeneral {
|
|
topicID = 1
|
|
}
|
|
inputPeer, _, parseErr := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if parseErr != nil {
|
|
return parseErr
|
|
}
|
|
|
|
var readMentionsErr, readReactionsErr, readMessagesErr error
|
|
var wg sync.WaitGroup
|
|
|
|
isBot := tc.metadata.IsBot
|
|
|
|
// Read mentions
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if isBot {
|
|
return
|
|
}
|
|
_, readMentionsErr = tc.client.API().MessagesReadMentions(ctx, &tg.MessagesReadMentionsRequest{
|
|
Peer: inputPeer,
|
|
TopMsgID: topicID,
|
|
})
|
|
}()
|
|
|
|
// Read reactions
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if isBot {
|
|
return
|
|
}
|
|
_, readMentionsErr = tc.client.API().MessagesReadReactions(ctx, &tg.MessagesReadReactionsRequest{
|
|
Peer: inputPeer,
|
|
TopMsgID: topicID,
|
|
})
|
|
}()
|
|
|
|
// Read messages
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
if isBot {
|
|
return
|
|
}
|
|
|
|
message := msg.ExactMessage
|
|
if message == nil {
|
|
message, readMessagesErr = tc.main.Bridge.DB.Message.GetLastPartAtOrBeforeTime(ctx, msg.Portal.PortalKey, time.Now())
|
|
if readMessagesErr != nil {
|
|
return
|
|
} else if message == nil {
|
|
zerolog.Ctx(ctx).Warn().Msg("no message found to read")
|
|
return
|
|
}
|
|
}
|
|
var maxID int
|
|
_, maxID, readMessagesErr = ids.ParseMessageID(message.ID)
|
|
if readMessagesErr != nil {
|
|
return
|
|
}
|
|
|
|
switch peerType {
|
|
case ids.PeerTypeUser, ids.PeerTypeChat:
|
|
_, readMessagesErr = tc.client.API().MessagesReadHistory(ctx, &tg.MessagesReadHistoryRequest{
|
|
Peer: inputPeer,
|
|
MaxID: maxID,
|
|
})
|
|
case ids.PeerTypeChannel:
|
|
var accessHash int64
|
|
accessHash, readMessagesErr = tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, portalID)
|
|
if readMessagesErr != nil {
|
|
return
|
|
}
|
|
_, readMessagesErr = tc.client.API().ChannelsReadHistory(ctx, &tg.ChannelsReadHistoryRequest{
|
|
Channel: &tg.InputChannel{ChannelID: portalID, AccessHash: accessHash},
|
|
MaxID: maxID,
|
|
})
|
|
|
|
meta := msg.Portal.Metadata.(*PortalMetadata)
|
|
randomID := meta.SponsoredMessageRandomID
|
|
if !tc.metadata.IsBot &&
|
|
randomID != nil &&
|
|
time.Since(meta.SponsoredMessagePollTS.Time) < 15*time.Minute &&
|
|
(meta.SponsoredMessageEventID == msg.EventID || msg.Receipt.Timestamp.After(meta.SponsoredMessagePollTS.Time)) &&
|
|
meta.sponsoredMessageSeen.Add(tc.telegramUserID) {
|
|
_, viewSponsoredErr := tc.client.API().MessagesViewSponsoredMessage(ctx, randomID)
|
|
if viewSponsoredErr != nil {
|
|
log.Err(viewSponsoredErr).Msg("Failed to mark sponsored message as viewed after read receipt")
|
|
} else {
|
|
log.Debug().
|
|
Str("random_id", base64.StdEncoding.EncodeToString(randomID)).
|
|
Msg("Marked sponsored message as viewed after read receipt")
|
|
}
|
|
}
|
|
default:
|
|
readMessagesErr = fmt.Errorf("unknown peer type %s", peerType)
|
|
}
|
|
}()
|
|
|
|
// Poll for reactions (non-blocking to avoid deadlock when portal event buffer is disabled)
|
|
go func() {
|
|
err := tc.maybePollForReactions(ctx, msg.Portal)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to poll for reactions after read receipt")
|
|
}
|
|
err = tc.pollSponsoredMessage(ctx, msg.Portal)
|
|
if err != nil {
|
|
log.Err(err).Msg("failed to poll for sponsored message after read receipt")
|
|
}
|
|
}()
|
|
|
|
if peerType == ids.PeerTypeChannel && !msg.Portal.Metadata.(*PortalMetadata).FullSynced {
|
|
log.Debug().Msg("Scheduling chat resync on read receipt because channel has never got a full sync")
|
|
go tc.userLogin.QueueRemoteEvent(&simplevent.ChatResync{
|
|
EventMeta: simplevent.EventMeta{
|
|
Type: bridgev2.RemoteEventChatResync,
|
|
PortalKey: msg.Portal.PortalKey,
|
|
},
|
|
GetChatInfoFunc: tc.GetChatInfo,
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
return errors.Join(readMentionsErr, readReactionsErr, readMessagesErr)
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error {
|
|
if msg.Portal.RoomType == database.RoomTypeSpace {
|
|
return nil
|
|
}
|
|
inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if msg.Portal.Metadata.(*PortalMetadata).IsForumGeneral {
|
|
topicID = 1
|
|
}
|
|
var action tg.SendMessageActionClass
|
|
switch msg.Type {
|
|
case bridgev2.TypingTypeText:
|
|
action = &tg.SendMessageTypingAction{}
|
|
case bridgev2.TypingTypeRecordingMedia:
|
|
// TODO media types?
|
|
action = &tg.SendMessageRecordVideoAction{}
|
|
case bridgev2.TypingTypeUploadingMedia:
|
|
action = &tg.SendMessageUploadVideoAction{}
|
|
}
|
|
if !msg.IsTyping {
|
|
action = &tg.SendMessageCancelAction{}
|
|
}
|
|
_, err = tc.client.API().MessagesSetTyping(ctx, &tg.MessagesSetTypingRequest{
|
|
Peer: inputPeer,
|
|
TopMsgID: topicID,
|
|
Action: action,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixDisappearingTimer(ctx context.Context, msg *bridgev2.MatrixDisappearingTimer) (bool, error) {
|
|
inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return false, err
|
|
} else if topicID > 0 {
|
|
return false, fmt.Errorf("topics can't have their own disappearing timer")
|
|
}
|
|
_, err = tc.client.API().MessagesSetHistoryTTL(ctx, &tg.MessagesSetHistoryTTLRequest{
|
|
Peer: inputPeer,
|
|
Period: int(msg.Content.Timer.Seconds()),
|
|
})
|
|
if err == nil {
|
|
msg.Portal.Disappear = database.DisappearingSetting{
|
|
Type: event.DisappearingTypeAfterSend,
|
|
Timer: msg.Content.Timer.Duration,
|
|
}.Normalize()
|
|
}
|
|
return err == nil, err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMute(ctx context.Context, msg *bridgev2.MatrixMute) error {
|
|
inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
settings := tg.InputPeerNotifySettings{
|
|
Silent: msg.Content.IsMuted(),
|
|
MuteUntil: int(max(0, min(msg.Content.GetMutedUntilTime().Unix(), math.MaxInt32))),
|
|
}
|
|
var peer tg.InputNotifyPeerClass
|
|
if topicID > 0 {
|
|
peer = &tg.InputNotifyForumTopic{Peer: inputPeer, TopMsgID: topicID}
|
|
} else {
|
|
peer = &tg.InputNotifyPeer{Peer: inputPeer}
|
|
}
|
|
|
|
_, err = tc.client.API().AccountUpdateNotifySettings(ctx, &tg.AccountUpdateNotifySettingsRequest{
|
|
Peer: peer,
|
|
Settings: settings,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleRoomTag(ctx context.Context, msg *bridgev2.MatrixRoomTag) error {
|
|
inputPeer, topicID, err := tc.inputPeerForPortalID(ctx, msg.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
} else if topicID > 0 {
|
|
return fmt.Errorf("topics can't be pinned for yourself")
|
|
}
|
|
|
|
_, err = tc.client.API().MessagesToggleDialogPin(ctx, &tg.MessagesToggleDialogPinRequest{
|
|
Pinned: slices.Contains(maps.Keys(msg.Content.Tags), event.RoomTagFavourite),
|
|
Peer: &tg.InputDialogPeer{Peer: inputPeer},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixDeleteChat(ctx context.Context, chat *bridgev2.MatrixDeleteChat) error {
|
|
peerType, id, topicID, err := ids.ParsePortalID(chat.Portal.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch peerType {
|
|
case ids.PeerTypeUser:
|
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tc.client.API().MessagesDeleteHistory(ctx, &tg.MessagesDeleteHistoryRequest{
|
|
Peer: &tg.InputPeerUser{UserID: id, AccessHash: accessHash},
|
|
JustClear: !chat.Content.DeleteForEveryone,
|
|
Revoke: chat.Content.DeleteForEveryone,
|
|
MaxID: 0,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
case ids.PeerTypeChat:
|
|
if !chat.Content.DeleteForEveryone {
|
|
return fmt.Errorf("chats can only be deleted for everyone or left")
|
|
}
|
|
result, err := tc.client.API().MessagesDeleteChat(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !result {
|
|
return fmt.Errorf("failed to delete chat %d", id)
|
|
}
|
|
return nil
|
|
case ids.PeerTypeChannel:
|
|
if !chat.Content.DeleteForEveryone {
|
|
return fmt.Errorf("channels can only be deleted for everyone or left")
|
|
}
|
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if topicID > 0 {
|
|
_, err = tc.client.API().MessagesDeleteTopicHistory(ctx, &tg.MessagesDeleteTopicHistoryRequest{
|
|
Peer: &tg.InputPeerChannel{
|
|
ChannelID: id,
|
|
AccessHash: accessHash,
|
|
},
|
|
TopMsgID: topicID,
|
|
})
|
|
} else {
|
|
_, err = tc.client.API().ChannelsDeleteChannel(ctx, &tg.InputChannel{
|
|
ChannelID: id,
|
|
AccessHash: accessHash,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("unknown peer type %s", peerType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error) {
|
|
peerType, id, topicID, err := ids.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
switch peerType {
|
|
case ids.PeerTypeChat:
|
|
_, err = tc.client.API().MessagesEditChatTitle(ctx, &tg.MessagesEditChatTitleRequest{
|
|
ChatID: id,
|
|
Title: msg.Content.Name,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
case ids.PeerTypeChannel:
|
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if topicID > 0 {
|
|
_, err = tc.client.API().MessagesEditForumTopic(ctx, &tg.MessagesEditForumTopicRequest{
|
|
Peer: &tg.InputPeerChannel{
|
|
ChannelID: id,
|
|
AccessHash: accessHash,
|
|
},
|
|
TopicID: topicID,
|
|
Title: msg.Content.Name,
|
|
})
|
|
} else {
|
|
_, err = tc.client.API().ChannelsEditTitle(ctx, &tg.ChannelsEditTitleRequest{
|
|
Channel: &tg.InputChannel{
|
|
ChannelID: id,
|
|
AccessHash: accessHash,
|
|
},
|
|
Title: msg.Content.Name,
|
|
})
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
default:
|
|
return false, fmt.Errorf("unsupported peer type %s for changing room name", peerType)
|
|
}
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixRoomAvatar(ctx context.Context, msg *bridgev2.MatrixRoomAvatar) (bool, error) {
|
|
peerType, id, topicID, err := ids.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if peerType == ids.PeerTypeUser {
|
|
return false, fmt.Errorf("changing user avatar is not supported")
|
|
} else if topicID > 0 {
|
|
return false, fmt.Errorf("changing group topic avatar is not supported")
|
|
}
|
|
|
|
var photo tg.InputChatPhotoClass
|
|
if msg.Content.URL == "" {
|
|
photo = &tg.InputChatPhotoEmpty{}
|
|
} else {
|
|
data, err := tc.main.Bridge.Bot.DownloadMedia(ctx, msg.Content.URL, nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to download avatar: %w", err)
|
|
}
|
|
upload, err := uploader.NewUploader(tc.client.API()).FromBytes(ctx, "avatar.jpg", data)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to upload avatar: %w", err)
|
|
}
|
|
photo = &tg.InputChatUploadedPhoto{File: upload}
|
|
}
|
|
|
|
switch peerType {
|
|
case ids.PeerTypeChat:
|
|
_, err = tc.client.API().MessagesEditChatPhoto(ctx, &tg.MessagesEditChatPhotoRequest{
|
|
ChatID: id,
|
|
Photo: photo,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// TODO update portal metadata
|
|
return true, nil
|
|
case ids.PeerTypeChannel:
|
|
accessHash, err := tc.ScopedStore.GetAccessHash(ctx, peerType, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
_, err = tc.client.API().ChannelsEditPhoto(ctx, &tg.ChannelsEditPhotoRequest{
|
|
Channel: &tg.InputChannel{
|
|
ChannelID: id,
|
|
AccessHash: accessHash,
|
|
},
|
|
Photo: photo,
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
// TODO update portal metadata
|
|
return true, nil
|
|
default:
|
|
return false, fmt.Errorf("unsupported peer type %s for changing room avatar", peerType)
|
|
}
|
|
}
|
|
|
|
func wrapUnsupportedError(err error) bridgev2.MessageStatus {
|
|
return bridgev2.WrapErrorInStatus(err).
|
|
WithErrorReason(event.MessageStatusUnsupported).
|
|
WithIsCertain(true).
|
|
WithSendNotice(false)
|
|
}
|
|
|
|
func (tc *TelegramClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error) {
|
|
if (msg.Type.IsSelf && msg.OrigSender != nil) || msg.Type == bridgev2.ProfileChange {
|
|
return nil, nil
|
|
}
|
|
chatPeerType, chatID, _, err := ids.ParsePortalID(msg.Portal.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse portal ID: %w", err)
|
|
}
|
|
var inputChannel *tg.InputChannel
|
|
switch chatPeerType {
|
|
case ids.PeerTypeUser:
|
|
return nil, nil
|
|
case ids.PeerTypeChannel:
|
|
if accessHash, err := tc.ScopedStore.GetAccessHash(ctx, ids.PeerTypeChannel, chatID); err != nil {
|
|
return nil, fmt.Errorf("failed to get access hash for channel: %w", err)
|
|
} else {
|
|
inputChannel = &tg.InputChannel{ChannelID: chatID, AccessHash: accessHash}
|
|
}
|
|
case ids.PeerTypeChat:
|
|
// ok
|
|
default:
|
|
return nil, wrapUnsupportedError(fmt.Errorf("unsupported chat peer type %s for membership changes", chatPeerType))
|
|
}
|
|
var targetPeerType ids.PeerType
|
|
var targetUserID int64
|
|
switch target := msg.Target.(type) {
|
|
case *bridgev2.UserLogin:
|
|
targetPeerType = ids.PeerTypeUser
|
|
targetUserID, err = ids.ParseUserLoginID(target.ID)
|
|
case *bridgev2.Ghost:
|
|
targetPeerType, targetUserID, err = ids.ParseUserID(target.ID)
|
|
default:
|
|
return nil, wrapUnsupportedError(fmt.Errorf("unknown membership target type %T", msg.Target))
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse target login ID: %w", err)
|
|
}
|
|
targetAccessHash, err := tc.ScopedStore.GetAccessHash(ctx, targetPeerType, targetUserID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get access hash for target: %w", err)
|
|
}
|
|
var targetInputPeer tg.InputPeerClass
|
|
var targetInputUser *tg.InputUser
|
|
switch targetPeerType {
|
|
case ids.PeerTypeUser:
|
|
targetInputPeer = &tg.InputPeerUser{UserID: targetUserID, AccessHash: targetAccessHash}
|
|
targetInputUser = &tg.InputUser{UserID: targetUserID, AccessHash: targetAccessHash}
|
|
case ids.PeerTypeChannel:
|
|
targetInputPeer = &tg.InputPeerChannel{ChannelID: targetUserID, AccessHash: targetAccessHash}
|
|
default:
|
|
return nil, wrapUnsupportedError(fmt.Errorf("unsupported target peer type %s for membership changes", targetPeerType))
|
|
}
|
|
if chatPeerType == ids.PeerTypeChannel {
|
|
switch msg.Type {
|
|
case bridgev2.Kick, bridgev2.RejectKnock, bridgev2.RevokeInvite, bridgev2.BanLeft, bridgev2.BanJoined, bridgev2.BanInvited, bridgev2.BanKnocked:
|
|
_, err = tc.client.API().ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
|
|
Channel: inputChannel,
|
|
Participant: targetInputPeer,
|
|
BannedRights: tg.ChatBannedRights{ViewMessages: true},
|
|
})
|
|
if err != nil {
|
|
err = tc.humaniseSendError(err)
|
|
} else if msg.Type == bridgev2.Kick {
|
|
// Reset permissions to default (telegram doesn't have a dedicated kick method)
|
|
_, err = tc.client.API().ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
|
|
Channel: inputChannel,
|
|
Participant: targetInputPeer,
|
|
BannedRights: tg.ChatBannedRights{},
|
|
})
|
|
if err != nil {
|
|
err = tc.humaniseSendError(err)
|
|
return nil, fmt.Errorf("failed to unban user to emulate kick: %w", err)
|
|
}
|
|
}
|
|
case bridgev2.Unban:
|
|
_, err = tc.client.API().ChannelsEditBanned(ctx, &tg.ChannelsEditBannedRequest{
|
|
Channel: inputChannel,
|
|
Participant: targetInputPeer,
|
|
BannedRights: tg.ChatBannedRights{},
|
|
})
|
|
err = tc.humaniseSendError(err)
|
|
case bridgev2.Invite, bridgev2.AcceptKnock:
|
|
if targetInputUser == nil {
|
|
return nil, wrapUnsupportedError(fmt.Errorf("can't invite non-user peer type %s", targetPeerType))
|
|
}
|
|
_, err = tc.client.API().ChannelsInviteToChannel(ctx, &tg.ChannelsInviteToChannelRequest{
|
|
Channel: inputChannel,
|
|
Users: []tg.InputUserClass{targetInputUser},
|
|
})
|
|
err = tc.humaniseSendError(err)
|
|
case bridgev2.Join, bridgev2.Knock:
|
|
_, err = tc.client.API().ChannelsJoinChannel(ctx, inputChannel)
|
|
err = tc.humaniseSendError(err)
|
|
case bridgev2.Leave, bridgev2.RetractKnock, bridgev2.RejectInvite:
|
|
_, err = tc.client.API().ChannelsLeaveChannel(ctx, inputChannel)
|
|
err = tc.humaniseSendError(err)
|
|
default:
|
|
err = wrapUnsupportedError(fmt.Errorf("unsupported channel membership change type %s -> %s", msg.Type.From, msg.Type.To))
|
|
}
|
|
} else {
|
|
switch msg.Type {
|
|
case bridgev2.Kick, bridgev2.RejectKnock, bridgev2.RevokeInvite, bridgev2.BanLeft, bridgev2.BanJoined, bridgev2.BanInvited, bridgev2.BanKnocked,
|
|
bridgev2.Leave, bridgev2.RetractKnock, bridgev2.RejectInvite:
|
|
_, err = tc.client.API().MessagesDeleteChatUser(ctx, &tg.MessagesDeleteChatUserRequest{
|
|
ChatID: chatID,
|
|
UserID: targetInputUser,
|
|
})
|
|
err = tc.humaniseSendError(err)
|
|
case bridgev2.Invite, bridgev2.AcceptKnock:
|
|
if targetInputUser == nil {
|
|
return nil, wrapUnsupportedError(fmt.Errorf("can't invite non-user peer type %s", targetPeerType))
|
|
}
|
|
_, err = tc.client.API().MessagesAddChatUser(ctx, &tg.MessagesAddChatUserRequest{
|
|
ChatID: chatID,
|
|
UserID: targetInputUser,
|
|
FwdLimit: 50,
|
|
})
|
|
err = tc.humaniseSendError(err)
|
|
case bridgev2.Join, bridgev2.Knock:
|
|
// TODO could maybe join if there's a saved invite link in the bridge db?
|
|
fallthrough
|
|
case bridgev2.Unban:
|
|
// There's no way to unban in minigroups
|
|
fallthrough
|
|
default:
|
|
err = wrapUnsupportedError(fmt.Errorf("unsupported minigroup membership change type %s -> %s", msg.Type.From, msg.Type.To))
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|