From 8e1da6147491373df270e1c4fea9cec0888d9b74 Mon Sep 17 00:00:00 2001 From: Sumner Evans <sumner.evans@automattic.com> Date: Thu, 13 Feb 2025 12:43:26 -0700 Subject: [PATCH] connector: support sending images Signed-off-by: Sumner Evans <sumner.evans@automattic.com> --- pkg/connector/client.go | 97 ------------------------- pkg/connector/matrix.go | 136 ++++++++++++++++++++++++++++++++++++ pkg/linkedingo/constants.go | 2 + pkg/linkedingo/media.go | 71 +++++++++++++++++++ pkg/linkedingo/messages.go | 36 +++++++--- pkg/linkedingo/realtime.go | 2 - pkg/linkedingo/request.go | 34 +++++---- 7 files changed, 255 insertions(+), 123 deletions(-) create mode 100644 pkg/connector/matrix.go diff --git a/pkg/connector/client.go b/pkg/connector/client.go index ba1ac34..5ddb0cd 100644 --- a/pkg/connector/client.go +++ b/pkg/connector/client.go @@ -356,103 +356,6 @@ func (l *LinkedInClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) return nil, nil } -func (l *LinkedInClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { - conversationURN := types.NewURN(msg.Portal.ID) - - if msg.Content.MsgType == event.MsgEmote { - if msg.Content.FormattedBody == "" { - msg.Content.FormattedBody = msg.Content.Body - } - msg.Content.Format = event.FormatHTML - msg.Content.Body = fmt.Sprintf("* %s %s", l.userLogin.RemoteName, msg.Content.Body) - msg.Content.FormattedBody = fmt.Sprintf(`* <a href="https://matrix.to/#/%s">%s</a> %s`, l.userLogin.UserMXID, l.userLogin.RemoteName, msg.Content.FormattedBody) - msg.Content.Mentions = &event.Mentions{UserIDs: []id.UserID{l.userLogin.UserMXID}} - } - - sendMessagePayload := linkedingo.SendMessagePayload{ - Message: linkedingo.SendMessage{ - Body: matrixfmt.Parse(ctx, l.matrixParser, msg.Content), - ConversationURN: conversationURN, - }, - } - - // if msg.ReplyTo != nil { - // sendMessagePayload.Message.RenderContentUnions = append( - // sendMessagePayload.Message.RenderContentUnions, - // payloadold.RenderContent{ - // RepliedMessageContent: &payloadold.RepliedMessageContent{ - // OriginalSenderUrn: string(msg.ReplyTo.SenderID), - // OriginalMessageUrn: string(msg.ReplyTo.ID), - // OriginalSendAt: msg.ReplyTo.Timestamp.UnixMilli(), - // //MessageBody: "", // todo add at some point - // }, - // }, - // ) - // } - - // content := msg.Content - // - // switch content.MsgType { - // case event.MsgText: - // break - // case event.MsgVideo, event.MsgImage: - // if content.Body == content.FileName { - // sendMessagePayload.Message.Body.Text = "" - // } - // - // file := content.GetFile() - // data, err := lc.connector.br.Bot.DownloadMedia(ctx, file.URL, file) - // if err != nil { - // return nil, err - // } - // - // attachmentType := payloadold.MediaUploadFileAttachment - // if content.MsgType == event.MsgImage { - // attachmentType = payloadold.MediaUploadTypePhotoAttachment - // } - // - // mediaMetadata, err := lc.client.UploadMedia(attachmentType, content.FileName, data, typesold.ContentTypeJSONPlaintextUTF8) - // if err != nil { - // return nil, err - // } - // - // lc.client.Logger.Debug().Any("media_metadata", mediaMetadata).Msg("Successfully uploaded media to LinkedIn's servers") - // sendMessagePayload.Message.RenderContentUnions = append(sendMessagePayload.Message.RenderContentUnions, payloadold.RenderContent{ - // File: &payloadold.File{ - // AssetUrn: mediaMetadata.Urn, - // Name: content.FileName, - // MediaType: typesold.ContentType(content.Info.MimeType), - // ByteSize: len(data), - // }, - // }) - // default: - // return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType) - // } - // - resp, err := l.client.SendMessage(ctx, sendMessagePayload) - if err != nil { - return nil, err - } - - return &bridgev2.MatrixMessageResponse{ - DB: &database.Message{ - ID: resp.Data.MessageID(), - MXID: msg.Event.ID, - Room: msg.Portal.PortalKey, - SenderID: l.userID, - Timestamp: resp.Data.DeliveredAt.Time, - }, - }, nil -} - -func (l *LinkedInClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { - return l.client.EditMessage(ctx, types.NewURN(msg.EditTarget.ID), matrixfmt.Parse(ctx, l.matrixParser, msg.Content)) -} - -func (l *LinkedInClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { - return l.client.RecallMessage(ctx, types.NewURN(msg.TargetMessage.ID)) -} - func (l *LinkedInClient) IsLoggedIn() bool { return l.userLogin.Metadata.(*UserLoginMetadata).Cookies.GetCookie(linkedingo.LinkedInCookieJSESSIONID) != "" } diff --git a/pkg/connector/matrix.go b/pkg/connector/matrix.go new file mode 100644 index 0000000..16c3ca2 --- /dev/null +++ b/pkg/connector/matrix.go @@ -0,0 +1,136 @@ +package connector + +import ( + "context" + "fmt" + "os" + "strings" + + "maunium.net/go/mautrix/bridgev2" + "maunium.net/go/mautrix/bridgev2/database" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" + + "go.mau.fi/mautrix-linkedin/pkg/connector/matrixfmt" + "go.mau.fi/mautrix-linkedin/pkg/linkedingo" + "go.mau.fi/mautrix-linkedin/pkg/linkedingo/types" +) + +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 (l *LinkedInClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error) { + conversationURN := types.NewURN(msg.Portal.ID) + + // Handle emotes by adding a "*" and the user's name to the message + if msg.Content.MsgType == event.MsgEmote { + if msg.Content.FormattedBody == "" { + msg.Content.FormattedBody = msg.Content.Body + } + msg.Content.Format = event.FormatHTML + msg.Content.Body = fmt.Sprintf("* %s %s", l.userLogin.RemoteName, msg.Content.Body) + msg.Content.FormattedBody = fmt.Sprintf(`* <a href="https://matrix.to/#/%s">%s</a> %s`, l.userLogin.UserMXID, l.userLogin.RemoteName, msg.Content.FormattedBody) + msg.Content.Mentions = &event.Mentions{UserIDs: []id.UserID{l.userLogin.UserMXID}} + } + + var renderContent []linkedingo.SendRenderContent + switch msg.Content.MsgType { + case event.MsgImage: + err := l.main.Bridge.Bot.DownloadMediaToFile(ctx, msg.Content.URL, msg.Content.File, false, func(f *os.File) error { + attachmentType := linkedingo.MediaUploadTypePhotoAttachment + filename := getMediaFilename(msg.Content) + urn, err := l.client.UploadMedia(ctx, attachmentType, filename, msg.Content.Info.MimeType, msg.Content.Info.Size, f) + if err != nil { + return err + } + renderContent = append(renderContent, linkedingo.SendRenderContent{ + File: &linkedingo.SendFile{ + AssetURN: urn, + Name: filename, + MediaType: msg.Content.Info.MimeType, + ByteSize: msg.Content.Info.Size, + }, + }) + return nil + }) + if err != nil { + return nil, err + } + } + + // content := msg.Content + // + // switch content.MsgType { + // case event.MsgText: + // break + // case event.MsgVideo, event.MsgImage: + // if content.Body == content.FileName { + // sendMessagePayload.Message.Body.Text = "" + // } + // + // file := content.GetFile() + // data, err := lc.connector.br.Bot.DownloadMedia(ctx, file.URL, file) + // if err != nil { + // return nil, err + // } + // + // attachmentType := payloadold.MediaUploadFileAttachment + // if content.MsgType == event.MsgImage { + // attachmentType = payloadold.MediaUploadTypePhotoAttachment + // } + // + // mediaMetadata, err := lc.client.UploadMedia(attachmentType, content.FileName, data, typesold.ContentTypeJSONPlaintextUTF8) + // if err != nil { + // return nil, err + // } + // + // lc.client.Logger.Debug().Any("media_metadata", mediaMetadata).Msg("Successfully uploaded media to LinkedIn's servers") + // sendMessagePayload.Message.RenderContentUnions = append(sendMessagePayload.Message.RenderContentUnions, payloadold.RenderContent{ + // File: &payloadold.File{ + // AssetUrn: mediaMetadata.Urn, + // Name: content.FileName, + // MediaType: typesold.ContentType(content.Info.MimeType), + // ByteSize: len(data), + // }, + // }) + // default: + // return nil, fmt.Errorf("%w %s", bridgev2.ErrUnsupportedMessageType, content.MsgType) + // } + + resp, err := l.client.SendMessage(ctx, conversationURN, matrixfmt.Parse(ctx, l.matrixParser, msg.Content), renderContent) + if err != nil { + return nil, err + } + return &bridgev2.MatrixMessageResponse{ + DB: &database.Message{ + ID: resp.Data.MessageID(), + MXID: msg.Event.ID, + Room: msg.Portal.PortalKey, + SenderID: l.userID, + Timestamp: resp.Data.DeliveredAt.Time, + }, + }, nil +} + +func (l *LinkedInClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error { + return l.client.EditMessage(ctx, types.NewURN(msg.EditTarget.ID), matrixfmt.Parse(ctx, l.matrixParser, msg.Content)) +} + +func (l *LinkedInClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error { + return l.client.RecallMessage(ctx, types.NewURN(msg.TargetMessage.ID)) +} diff --git a/pkg/linkedingo/constants.go b/pkg/linkedingo/constants.go index 19bde7d..ebd4131 100644 --- a/pkg/linkedingo/constants.go +++ b/pkg/linkedingo/constants.go @@ -23,12 +23,14 @@ const ( linkedInRealtimeHeartbeatURL = "https://www.linkedin.com/realtime/realtimeFrontendClientConnectivityTracking?action=sendHeartbeat" linkedInLogoutURL = "https://www.linkedin.com/uas/logout" linkedInVoyagerMessagingDashMessengerMessagesURL = "https://www.linkedin.com/voyager/api/voyagerMessagingDashMessengerMessages" + linkedInVoyagerMediaUploadMetadataURL = "https://www.linkedin.com/voyager/api/voyagerVideoDashMediaUploadMetadata" ) const LinkedInCookieJSESSIONID = "JSESSIONID" const ( contentTypeJSON = "application/json" + contentTypeJSONPlaintextUTF8 = "application/json; charset=UTF-8" contentTypeJSONLinkedInNormalized = "application/vnd.linkedin.normalized+json+2.1" contentTypeGraphQL = "application/graphql" contentTypeTextEventStream = "text/event-stream" diff --git a/pkg/linkedingo/media.go b/pkg/linkedingo/media.go index 26cd674..002f4ef 100644 --- a/pkg/linkedingo/media.go +++ b/pkg/linkedingo/media.go @@ -3,9 +3,12 @@ package linkedingo import ( "bytes" "context" + "encoding/json" + "fmt" "io" "mime" "net/http" + "strconv" "maunium.net/go/mautrix/event" @@ -54,3 +57,71 @@ func (c *Client) GetAudioFileInfo(ctx context.Context, audio *types.AudioMetadat } return } + +type MediaUploadType string + +const ( + MediaUploadTypePhotoAttachment MediaUploadType = "MESSAGING_PHOTO_ATTACHMENT" + MediaUploadTypeFileAttachment MediaUploadType = "MESSAGING_FILE_ATTACHMENT" +) + +type UploadMediaMetadataPayload struct { + MediaUploadType MediaUploadType `json:"mediaUploadType,omitempty"` + FileSize int `json:"fileSize,omitempty"` + Filename string `json:"filename,omitempty"` +} + +type UploadMediaMetadataResponse struct { + Data ActionResponse `json:"data,omitempty"` +} + +// ActionResponse represents a com.linkedin.restli.common.ActionResponse +// object. +type ActionResponse struct { + Value MediaUploadMetadata `json:"value,omitempty"` +} + +// MediaUploadMetadata represents a +// com.linkedin.mediauploader.MediaUploadMetadata object. +type MediaUploadMetadata struct { + URN types.URN `json:"urn,omitempty"` + SingleUploadURL string `json:"singleUploadUrl,omitempty"` +} + +func (c *Client) UploadMedia(ctx context.Context, mediaUploadType MediaUploadType, filename, contentType string, size int, r io.Reader) (types.URN, error) { + resp, err := c.newAuthedRequest(http.MethodPost, linkedInVoyagerMediaUploadMetadataURL). + WithParam("action", "upload"). + WithCSRF(). + WithRealtimeHeaders(). + WithHeader("accept", contentTypeJSONLinkedInNormalized). + WithContentType(contentTypeJSONPlaintextUTF8). + WithJSONPayload(UploadMediaMetadataPayload{ + MediaUploadType: mediaUploadType, + FileSize: size, + Filename: filename, + }). + Do(ctx) + if err != nil { + return types.URN{}, err + } else if resp.StatusCode != http.StatusOK { + return types.URN{}, fmt.Errorf("failed to get upload media metadata (statusCode=%d)", resp.StatusCode) + } + + var uploadMetadata UploadMediaMetadataResponse + if err = json.NewDecoder(resp.Body).Decode(&uploadMetadata); err != nil { + return types.URN{}, err + } + + resp, err = c.newAuthedRequest(http.MethodPut, uploadMetadata.Data.Value.SingleUploadURL). + WithCSRF(). + WithHeader("content-length", strconv.Itoa(size)). + WithContentType(contentType). + WithBody(r). + Do(ctx) + if err != nil { + return types.URN{}, err + } else if resp.StatusCode != http.StatusCreated { + return types.URN{}, fmt.Errorf("failed to upload media: status=%d", resp.StatusCode) + } + return uploadMetadata.Data.Value.URN, nil +} diff --git a/pkg/linkedingo/messages.go b/pkg/linkedingo/messages.go index 465fe4b..beca1de 100644 --- a/pkg/linkedingo/messages.go +++ b/pkg/linkedingo/messages.go @@ -13,7 +13,7 @@ import ( "go.mau.fi/mautrix-linkedin/pkg/linkedingo/types" ) -type SendMessagePayload struct { +type sendMessagePayload struct { Message SendMessage `json:"message,omitempty"` MailboxURN types.URN `json:"mailboxUrn,omitempty"` TrackingID string `json:"trackingId,omitempty"` @@ -23,10 +23,10 @@ type SendMessagePayload struct { } type SendMessage struct { - Body SendMessageBody `json:"body,omitempty"` - RenderContentUnions []any `json:"renderContentUnions,omitempty"` - ConversationURN types.URN `json:"conversationUrn,omitempty"` - OriginToken uuid.UUID `json:"originToken,omitempty"` + Body SendMessageBody `json:"body,omitempty"` + RenderContentUnions []SendRenderContent `json:"renderContentUnions,omitempty"` + ConversationURN types.URN `json:"conversationUrn,omitempty"` + OriginToken uuid.UUID `json:"originToken,omitempty"` } type SendMessageBody struct { @@ -44,14 +44,32 @@ type AttributeType struct { Entity *types.Entity `json:"com.linkedin.pemberly.text.Entity,omitempty"` } +type SendRenderContent struct { + File *SendFile `json:"file,omitempty"` +} + +type SendFile struct { + AssetURN types.URN `json:"assetUrn,omitempty"` + ByteSize int `json:"byteSize,omitempty"` + MediaType string `json:"mediaType,omitempty"` + Name string `json:"name,omitempty"` +} + type MessageSentResponse struct { Data types.Message `json:"value,omitempty"` } -func (c *Client) SendMessage(ctx context.Context, payload SendMessagePayload) (*MessageSentResponse, error) { - payload.MailboxURN = c.userEntityURN.WithPrefix("urn", "li", "fsd_profile") - payload.TrackingID = random.String(16) - payload.Message.OriginToken = uuid.New() +func (c *Client) SendMessage(ctx context.Context, conversationURN types.URN, body SendMessageBody, renderContent []SendRenderContent) (*MessageSentResponse, error) { + payload := sendMessagePayload{ + Message: SendMessage{ + Body: body, + RenderContentUnions: renderContent, + ConversationURN: conversationURN, + OriginToken: uuid.New(), + }, + MailboxURN: c.userEntityURN.WithPrefix("urn", "li", "fsd_profile"), + TrackingID: random.String(16), + } resp, err := c.newAuthedRequest(http.MethodPost, linkedInVoyagerMessagingDashMessengerMessagesURL). WithJSONPayload(payload). diff --git a/pkg/linkedingo/realtime.go b/pkg/linkedingo/realtime.go index 4f2b445..a6266df 100644 --- a/pkg/linkedingo/realtime.go +++ b/pkg/linkedingo/realtime.go @@ -263,8 +263,6 @@ func (c *Client) realtimeConnectLoop(ctx context.Context) { case realtimeEvent.Heartbeat != nil: c.handlers.onHeartbeat(ctx) case realtimeEvent.ClientConnection != nil: - c.realtimeSessionID = realtimeEvent.ClientConnection.ID - log.Debug().Stringer("realtime_session_id", c.realtimeSessionID).Msg("Got new realtime session ID") c.handlers.onClientConnection(ctx, realtimeEvent.ClientConnection) case realtimeEvent.DecoratedEvent != nil: log.Debug(). diff --git a/pkg/linkedingo/request.go b/pkg/linkedingo/request.go index 5ad2d4f..dbf319a 100644 --- a/pkg/linkedingo/request.go +++ b/pkg/linkedingo/request.go @@ -17,23 +17,22 @@ func (c *Client) getCSRFToken() string { } type authedRequest struct { + parseErr error + method string - url string - body io.Reader + url *url.URL header http.Header params url.Values + body io.Reader client *Client } -func (c *Client) newAuthedRequest(method, url string) *authedRequest { - return &authedRequest{ - method: method, - url: url, - header: http.Header{}, - params: map[string][]string{}, - client: c, - } +func (c *Client) newAuthedRequest(method, urlStr string) *authedRequest { + ar := authedRequest{header: http.Header{}, method: method, client: c} + ar.url, ar.parseErr = url.Parse(urlStr) + ar.params = ar.url.Query() + return &ar } func (a *authedRequest) WithHeader(key, value string) *authedRequest { @@ -55,6 +54,11 @@ func (a *authedRequest) WithJSONPayload(payload any) *authedRequest { return a } +func (a *authedRequest) WithBody(r io.Reader) *authedRequest { + a.body = r + return a +} + func (a *authedRequest) WithContentType(contentType string) *authedRequest { return a.WithHeader("content-type", contentType) } @@ -83,12 +87,12 @@ func (a *authedRequest) WithWebpageHeaders() *authedRequest { } func (a *authedRequest) Do(ctx context.Context) (*http.Response, error) { - u, err := url.Parse(a.url) - if err != nil { - return nil, err + if a.parseErr != nil { + return nil, a.parseErr } - u.RawQuery = a.params.Encode() - req, err := http.NewRequestWithContext(ctx, a.method, u.String(), a.body) + a.url.RawQuery = a.params.Encode() + + req, err := http.NewRequestWithContext(ctx, a.method, a.url.String(), a.body) if err != nil { return nil, fmt.Errorf("failed to perform authed request %s %s: %w", a.method, a.url, err) } -- GitLab