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