From 61c06396fcb623ae5d0852783d2e40ae2f0116ed Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 7 Jun 2024 14:39:31 -0600 Subject: [PATCH] msgconv: basic photo support Signed-off-by: Sumner Evans --- main.go | 169 ------------------------------ pkg/connector/client.go | 91 +++++++++------- pkg/connector/msgconv/from-tg.go | 132 ----------------------- pkg/connector/msgconv/msgconv.go | 25 ----- pkg/connector/msgconv/tomatrix.go | 146 ++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 366 deletions(-) delete mode 100644 main.go delete mode 100644 pkg/connector/msgconv/from-tg.go delete mode 100644 pkg/connector/msgconv/msgconv.go create mode 100644 pkg/connector/msgconv/tomatrix.go diff --git a/main.go b/main.go deleted file mode 100644 index d17840fe..00000000 --- a/main.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - - "github.com/gotd/td/session" - "github.com/gotd/td/telegram" - "github.com/gotd/td/telegram/auth" - "github.com/gotd/td/telegram/updates" - updhook "github.com/gotd/td/telegram/updates/hook" - "github.com/gotd/td/tg" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "go.mau.fi/zerozap" - "go.uber.org/zap" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" - - "go.mau.fi/mautrix-telegram/pkg/connector/msgconv" -) - -type FileSession struct{} - -func (s *FileSession) LoadSession(context.Context) ([]byte, error) { - if data, err := os.ReadFile("session"); err != nil { - return nil, session.ErrNotFound - } else { - return data, nil - } -} - -func (s *FileSession) StoreSession(ctx context.Context, data []byte) error { - return os.WriteFile("session", data, 0600) -} - -type Authenticator struct{} - -func (a *Authenticator) Phone(ctx context.Context) (string, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Printf("Phone (include country code with +): ") - raw, err := reader.ReadString('\n') - return strings.TrimSpace(raw), err -} - -func (a *Authenticator) Password(ctx context.Context) (string, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Printf("Password: ") - raw, err := reader.ReadString('\n') - return strings.TrimSpace(raw), err -} - -func (a *Authenticator) AcceptTermsOfService(ctx context.Context, tos tg.HelpTermsOfService) error { - return nil -} - -func (a *Authenticator) SignUp(ctx context.Context) (auth.UserInfo, error) { - panic("not supported") -} - -func (a *Authenticator) Code(ctx context.Context, sentCode *tg.AuthSentCode) (string, error) { - reader := bufio.NewReader(os.Stdin) - fmt.Printf("Code: ") - return reader.ReadString('\n') -} - -type FakePortal struct { -} - -func (*FakePortal) DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) { - return nil, nil -} - -func (*FakePortal) UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) { - return id.ContentURIString("mxc://test"), nil, nil -} - -func main() { - apiID, err := strconv.ParseInt(os.Args[1], 10, 32) - if err != nil { - panic(err) - } - apiHash := os.Args[2] - - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - - zaplog := zap.New(zerozap.New(log.Logger)) - - var authenticator Authenticator - var sessionStorage FileSession - - d := tg.NewUpdateDispatcher() - gaps := updates.New(updates.Config{ - Handler: d, - Logger: zaplog.Named("gaps"), - }) - - // https://core.telegram.org/api/obtaining_api_id - client := telegram.NewClient(int(apiID), apiHash, telegram.Options{ - SessionStorage: &sessionStorage, - Logger: zaplog, - UpdateHandler: gaps, - Middlewares: []telegram.Middleware{ - updhook.UpdateHook(gaps.Handle), - }, - }) - - portal := &FakePortal{} - mc := msgconv.MessageConverter{ - PortalMethods: portal, - Client: client, - } - - // Setup message update handlers. - d.OnNewChannelMessage(func(ctx context.Context, e tg.Entities, update *tg.UpdateNewChannelMessage) error { - log.Info().Any("update", update.Message).Msg("Channel message") - converted := mc.ToMatrix(ctx, update.Message) - fmt.Printf("CONVERTED\n") - fmt.Printf("CONVERTED\n") - for _, part := range converted.Parts { - wrapped := &event.Content{Parsed: part.Content, Raw: part.Extra} - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - enc.Encode(wrapped) - } - fmt.Printf("CONVERTED\n") - fmt.Printf("CONVERTED\n") - return nil - }) - d.OnNewMessage(func(ctx context.Context, e tg.Entities, update *tg.UpdateNewMessage) error { - log.Info().Any("update", update.Message).Msg("Message") - return nil - }) - d.OnEditChannelMessage(func(ctx context.Context, e tg.Entities, update *tg.UpdateEditChannelMessage) error { - fmt.Printf("on edit channel message %v\n", update) - return nil - }) - d.OnEditMessage(func(ctx context.Context, e tg.Entities, update *tg.UpdateEditMessage) error { - fmt.Printf("on edit message %v\n", update) - return nil - }) - - if err := client.Run(context.Background(), func(ctx context.Context) error { - authFlow := auth.NewFlow(&authenticator, auth.SendCodeOptions{}) - err := client.Auth().IfNecessary(ctx, authFlow) - if err != nil { - return err - } - - user, err := client.Self(ctx) - if err != nil { - return fmt.Errorf("error getting self: %w", err) - } - - return gaps.Run(ctx, client.API(), user.ID, updates.AuthOptions{ - OnStart: func(ctx context.Context) { - log.Info().Msg("gaps started") - }, - }) - }); err != nil { - panic(err) - } - // Client is closed. -} diff --git a/pkg/connector/client.go b/pkg/connector/client.go index ceecbb02..56b1a768 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -18,7 +18,8 @@ import ( "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/msgconv" ) type TelegramClient struct { @@ -27,6 +28,7 @@ type TelegramClient struct { userLogin *bridgev2.UserLogin client *telegram.Client clientCancel context.CancelFunc + msgConv *msgconv.MessageConverter } func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridgev2.UserLogin) (*TelegramClient, error) { @@ -47,9 +49,9 @@ func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridge loginID: loginID, userLogin: login, } - dispatcher := tg.NewUpdateDispatcher() dispatcher.OnNewMessage(client.onUpdateNewMessage) + dispatcher.OnNewChannelMessage(client.onUpdateNewChannelMessage) store := tc.store.GetScopedStore(loginID) @@ -69,6 +71,7 @@ func NewTelegramClient(ctx context.Context, tc *TelegramConnector, login *bridge Logger: zaplog, UpdateHandler: updatesManager, }) + client.msgConv = msgconv.NewMessageConverter(client.client) client.clientCancel, err = connectTelegramClient(ctx, client.client) go func() { err = updatesManager.Run(ctx, client.client.API(), loginID, updates.AuthOptions{}) @@ -154,29 +157,21 @@ func (t *TelegramClient) onUpdateNewMessage(ctx context.Context, e tg.Entities, Str("sender_login", string(sender.SenderLogin)). Bool("is_from_me", sender.IsFromMe) }, - ID: makeMessageID(msg.ID), - Sender: sender, - PortalID: makePortalID(msg.PeerID), - Data: msg, - CreatePortal: true, - - ConvertMessageFunc: func(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, data *tg.Message) (*bridgev2.ConvertedMessage, error) { - cm := &bridgev2.ConvertedMessage{ - Timestamp: time.Unix(int64(data.Date), 0), - } - if data.Message != "" { - converted := bridgev2.ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{MsgType: event.MsgText, Body: data.Message}, - } - cm.Parts = append(cm.Parts, &converted) - } - return cm, nil - }, + ID: makeMessageID(msg.ID), + Sender: sender, + PortalID: makePortalID(msg.PeerID), + Data: msg, + CreatePortal: true, + ConvertMessageFunc: t.msgConv.ToMatrix, }) return nil } +func (t *TelegramClient) onUpdateNewChannelMessage(ctx context.Context, e tg.Entities, update *tg.UpdateNewChannelMessage) error { + fmt.Printf("update new channel message %+v\n", update) + return nil +} + func (t *TelegramClient) Connect(ctx context.Context) (err error) { t.clientCancel, err = connectTelegramClient(ctx, t.client) return @@ -186,18 +181,15 @@ func getFullName(user *tg.User) string { return strings.TrimSpace(fmt.Sprintf("%s %s", user.FirstName, user.LastName)) } -func getFullNamePtr(user *tg.User) *string { - fullName := getFullName(user) - return &fullName -} - func (t *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.PortalInfo, error) { fmt.Printf("%+v\n", portal) peerType, id, err := parsePortalID(portal.ID) if err != nil { return nil, err } - isSpace := false + var name, topic string + var members []networkid.UserID + var isSpace, isDM bool switch peerType { case peerTypeUser: @@ -211,20 +203,37 @@ func (t *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Porta if user, ok := users[0].(*tg.User); !ok { return nil, fmt.Errorf("returned user is not *tg.User") } else { - isDM := true - return &bridgev2.PortalInfo{ - Name: getFullNamePtr(user), - // Topic *string - // Avatar *Avatar - - Members: []networkid.UserID{makeUserID(id), makeUserID(t.loginID)}, - IsDirectChat: &isDM, - IsSpace: &isSpace, - }, nil + name = getFullName(user) // TODO gate this behind a config? + members = []networkid.UserID{makeUserID(id), makeUserID(t.loginID)} + isDM = true } + case peerTypeChat: + // TODO get name of chat + chat, err := t.client.API().MessagesGetFullChat(ctx, id) + if err != nil { + return nil, err + } + if len(chat.Users) == 0 { + return nil, fmt.Errorf("no users found in chat %d", id) + } + for _, user := range chat.Users { + members = append(members, makeUserID(user.GetID())) + } + default: + fmt.Printf("%s %d\n", peerType, id) + panic("unimplemented getchatinfo") } - fmt.Printf("%s %d\n", peerType, id) - panic("unimplemented getchatinfo") + + return &bridgev2.PortalInfo{ + Name: &name, + Topic: &topic, // TODO + // TODO + // Avatar *Avatar + + Members: members, + IsDirectChat: &isDM, + IsSpace: &isSpace, + }, nil } func (t *TelegramClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) { @@ -251,9 +260,11 @@ func (t *TelegramClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) identifiers = append(identifiers, fmt.Sprintf("tel:+%s", strings.TrimPrefix(phone, "+"))) } + name := getFullName(user) return &bridgev2.UserInfo{ IsBot: &user.Bot, - Name: getFullNamePtr(user), + Name: &name, + // TODO // Avatar *Avatar Identifiers: identifiers, }, nil diff --git a/pkg/connector/msgconv/from-tg.go b/pkg/connector/msgconv/from-tg.go deleted file mode 100644 index 7cb019b6..00000000 --- a/pkg/connector/msgconv/from-tg.go +++ /dev/null @@ -1,132 +0,0 @@ -package msgconv - -import ( - "context" - "encoding/base64" - "fmt" - - "github.com/gotd/td/tg" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type ConvertedMessage struct { - Parts []*ConvertedMessagePart -} - -type ConvertedMessagePart struct { - Type event.Type - Content *event.MessageEventContent - Extra map[string]any -} - -func getLargestPhotoSize(sizes []tg.PhotoSizeClass) (largest tg.PhotoSizeClass) { - var maxSize int - for _, s := range sizes { - var currentSize int - switch size := s.(type) { - case *tg.PhotoSize: - currentSize = size.GetSize() - case *tg.PhotoCachedSize: - currentSize = max(size.GetW(), size.GetH()) - case *tg.PhotoSizeProgressive: - currentSize = max(size.GetW(), size.GetH()) - case *tg.PhotoPathSize: - currentSize = len(size.GetBytes()) - case *tg.PhotoStrippedSize: - currentSize = len(size.GetBytes()) - } - - if currentSize > maxSize { - maxSize = currentSize - largest = s - } - } - return -} - -func (mc *MessageConverter) ToMatrix(ctx context.Context, msg tg.MessageClass) *ConvertedMessage { - log := mc.getLogger(ctx).With().Str("action", "to_matrix").Logger() - cm := &ConvertedMessage{ - Parts: make([]*ConvertedMessagePart, 0), - } - - switch v := msg.(type) { - case *tg.Message: - if v.Message != "" { - converted := ConvertedMessagePart{ - Type: event.EventMessage, - Content: &event.MessageEventContent{ - MsgType: event.MsgText, - Body: v.Message, - }, - } - cm.Parts = append(cm.Parts, &converted) - } - - if m, ok := v.GetMedia(); ok { - switch media := m.(type) { - case *tg.MessageMediaPhoto: // messageMediaPhoto#695150d7 - fmt.Printf("photo %v\n", media) - if media.GetSpoiler() { - // TODO do something - fmt.Printf("SPOILER\n") - } - if p, ok := media.GetPhoto(); ok { - switch photo := p.(type) { - case *tg.Photo: // photo#fb197a65 - fmt.Printf("photo: %v\n", photo) - - largest := getLargestPhotoSize(photo.GetSizes()) - // file := tg.InputPhotoFileLocation{ - // ID: photo.GetID(), - // AccessHash: photo.GetAccessHash(), - // FileReference: photo.GetFileReference(), - // ThumbSize: largest.GetType(), - // } - - mxc := id.ContentURIString( - fmt.Sprintf("mxc://telegram.sumner.user.beeper.com/p.i%d.a%d.f%s.t%s", photo.GetID(), photo.GetAccessHash(), base64.RawURLEncoding.EncodeToString(photo.GetFileReference()), largest.GetType()), - ) - - fmt.Printf("%s\n", mxc) - - // data, err := mc.downloadFile(ctx, &file) - // if err != nil { - // panic(err) - // } - // err = os.WriteFile("/home/sumner/tmp/test.jpg", data, 0644) - // if err != nil { - // panic(err) - // } - default: - log.Error().Type("msg", msg).Msg("Unhandled photo type") - } - } - case *tg.MessageMediaGeo: // messageMediaGeo#56e0d474 - case *tg.MessageMediaContact: // messageMediaContact#70322949 - case *tg.MessageMediaUnsupported: // messageMediaUnsupported#9f84f49e - case *tg.MessageMediaDocument: // messageMediaDocument#4cf4d72d - case *tg.MessageMediaWebPage: // messageMediaWebPage#ddf10c3b - case *tg.MessageMediaVenue: // messageMediaVenue#2ec0533f - case *tg.MessageMediaGame: // messageMediaGame#fdb19008 - case *tg.MessageMediaInvoice: // messageMediaInvoice#f6a548d3 - case *tg.MessageMediaGeoLive: // messageMediaGeoLive#b940c666 - case *tg.MessageMediaPoll: // messageMediaPoll#4bd6e798 - case *tg.MessageMediaDice: // messageMediaDice#3f7ee58b - case *tg.MessageMediaStory: // messageMediaStory#68cb6283 - case *tg.MessageMediaGiveaway: // messageMediaGiveaway#daad85b0 - case *tg.MessageMediaGiveawayResults: // messageMediaGiveawayResults#c6991068 - default: - log.Error().Type("msg", msg).Msg("Unhandled media type") - } - } - - case *tg.MessageService: - fmt.Printf("%v\n", v) - default: - log.Error().Type("msg", msg).Msg("Unhandled message type") - } - - return cm -} diff --git a/pkg/connector/msgconv/msgconv.go b/pkg/connector/msgconv/msgconv.go deleted file mode 100644 index 4e8d8128..00000000 --- a/pkg/connector/msgconv/msgconv.go +++ /dev/null @@ -1,25 +0,0 @@ -package msgconv - -import ( - "context" - - "github.com/gotd/td/telegram" - "github.com/rs/zerolog" - "maunium.net/go/mautrix/event" - "maunium.net/go/mautrix/id" -) - -type PortalMethods interface { - DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error) - UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error) -} - -type MessageConverter struct { - PortalMethods - - Client *telegram.Client -} - -func (*MessageConverter) getLogger(ctx context.Context) zerolog.Logger { - return zerolog.Ctx(ctx).With().Str("component", "message_converter").Logger() -} diff --git a/pkg/connector/msgconv/tomatrix.go b/pkg/connector/msgconv/tomatrix.go new file mode 100644 index 00000000..23bd0fb0 --- /dev/null +++ b/pkg/connector/msgconv/tomatrix.go @@ -0,0 +1,146 @@ +package msgconv + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/gotd/td/telegram" + "github.com/gotd/td/telegram/downloader" + "github.com/gotd/td/tg" + "github.com/rs/zerolog" + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/networkid" + "maunium.net/go/mautrix/event" +) + +type MessageConverter struct { + client *telegram.Client +} + +func NewMessageConverter(client *telegram.Client) *MessageConverter { + return &MessageConverter{client: client} +} + +func (mc *MessageConverter) ToMatrix(ctx context.Context, portal *bridgev2.Portal, intent bridgev2.MatrixAPI, msg *tg.Message) (*bridgev2.ConvertedMessage, error) { + log := zerolog.Ctx(ctx).With().Str("conversion_direction", "to_matrix").Logger() + + cm := &bridgev2.ConvertedMessage{ + Timestamp: time.Unix(int64(msg.Date), 0), + } + if msg.Message != "" { + cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ + ID: networkid.PartID("caption"), + Type: event.EventMessage, + Content: &event.MessageEventContent{MsgType: event.MsgText, Body: msg.Message}, + }) + } + if m, ok := msg.GetMedia(); ok { + switch media := m.(type) { + case *tg.MessageMediaPhoto: + if media.GetSpoiler() { + // TODO do something + fmt.Printf("SPOILER\n") + } + if p, ok := media.GetPhoto(); ok { + switch photo := p.(type) { + case *tg.Photo: + largest := getLargestPhotoSize(photo.GetSizes()) + file := tg.InputPhotoFileLocation{ + ID: photo.GetID(), + AccessHash: photo.GetAccessHash(), + FileReference: photo.GetFileReference(), + ThumbSize: largest.GetType(), + } + + // TODO convert to streaming directly into UploadMedia + var buf bytes.Buffer + storageFileTypeClass, err := downloader.NewDownloader().Download(mc.client.API(), &file).Stream(ctx, &buf) + if err != nil { + return nil, err + } + contentType := "application/octet-stream" + switch storageFileTypeClass.(type) { + case *tg.StorageFileJpeg: + contentType = "image/jpeg" + case *tg.StorageFileGif: + contentType = "image/gif" + case *tg.StorageFilePng: + contentType = "image/png" + case *tg.StorageFilePdf: + contentType = "application/pdf" + case *tg.StorageFileMp3: + contentType = "audio/mp3" + case *tg.StorageFileMov: + contentType = "video/quicktime" + case *tg.StorageFileMp4: + contentType = "video/mp4" + case *tg.StorageFileWebp: + contentType = "image/webp" + } + + mxcURI, encryptedFileInfo, err := intent.UploadMedia(ctx, "", buf.Bytes(), "", contentType) + if err != nil { + return nil, err + } + cm.Parts = append(cm.Parts, &bridgev2.ConvertedMessagePart{ + ID: networkid.PartID("photo"), + Type: event.EventMessage, + Content: &event.MessageEventContent{ + MsgType: event.MsgImage, + // Body: filename, + URL: mxcURI, + File: encryptedFileInfo, + }, + }) + + default: + log.Error().Type("msg", msg).Msg("Unhandled photo type") + } + } + case *tg.MessageMediaGeo: // messageMediaGeo#56e0d474 + case *tg.MessageMediaContact: // messageMediaContact#70322949 + case *tg.MessageMediaUnsupported: // messageMediaUnsupported#9f84f49e + case *tg.MessageMediaDocument: // messageMediaDocument#4cf4d72d + case *tg.MessageMediaWebPage: // messageMediaWebPage#ddf10c3b + case *tg.MessageMediaVenue: // messageMediaVenue#2ec0533f + case *tg.MessageMediaGame: // messageMediaGame#fdb19008 + case *tg.MessageMediaInvoice: // messageMediaInvoice#f6a548d3 + case *tg.MessageMediaGeoLive: // messageMediaGeoLive#b940c666 + case *tg.MessageMediaPoll: // messageMediaPoll#4bd6e798 + case *tg.MessageMediaDice: // messageMediaDice#3f7ee58b + case *tg.MessageMediaStory: // messageMediaStory#68cb6283 + case *tg.MessageMediaGiveaway: // messageMediaGiveaway#daad85b0 + case *tg.MessageMediaGiveawayResults: // messageMediaGiveawayResults#c6991068 + default: + log.Error().Type("msg", msg).Msg("Unhandled media type") + } + } + return cm, nil +} + +func getLargestPhotoSize(sizes []tg.PhotoSizeClass) (largest tg.PhotoSizeClass) { + var maxSize int + for _, s := range sizes { + var currentSize int + switch size := s.(type) { + case *tg.PhotoSize: + currentSize = size.GetSize() + case *tg.PhotoCachedSize: + currentSize = max(size.GetW(), size.GetH()) + case *tg.PhotoSizeProgressive: + currentSize = max(size.GetW(), size.GetH()) + case *tg.PhotoPathSize: + currentSize = len(size.GetBytes()) + case *tg.PhotoStrippedSize: + currentSize = len(size.GetBytes()) + } + + if currentSize > maxSize { + maxSize = currentSize + largest = s + } + } + return +}