From d91dae2fc12026a447dac7c773bb4795d6616d6b Mon Sep 17 00:00:00 2001
From: Sumner Evans <sumner.evans@automattic.com>
Date: Wed, 12 Feb 2025 11:33:10 -0700
Subject: [PATCH] connector: implement sending edits

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
---
 ROADMAP.md                    |  6 +++---
 pkg/connector/capabilities.go |  4 ++--
 pkg/connector/client.go       |  8 ++++++--
 pkg/linkedingo/constants.go   |  1 +
 pkg/linkedingo/messages.go    | 33 +++++++++++++++++++++++++++++++++
 pkg/linkedingo/types/urn.go   |  5 +++++
 6 files changed, 50 insertions(+), 7 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md
index b881abf..580ea7b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
 
 * Matrix → LinkedIn
   * [ ] Message content
-    * [ ] Text
+    * [x] Text
     * [ ] Media
       * [ ] Files
       * [ ] Images
@@ -12,9 +12,9 @@
       * [ ] ~~Stickers~~ (unsupported)
     * [ ] ~~Formatting~~ (LinkedIn does not support rich formatting)
     * [ ] Replies
-    * [ ] Mentions
+    * [x] Mentions
     * [ ] Emotes
-  * [ ] Message edits
+  * [x] Message edits
   * [ ] Message redactions
   * [ ] Message reactions
   * [ ] Presence
diff --git a/pkg/connector/capabilities.go b/pkg/connector/capabilities.go
index e6bc2e1..73e215e 100644
--- a/pkg/connector/capabilities.go
+++ b/pkg/connector/capabilities.go
@@ -48,7 +48,7 @@ var formattingCaps = event.FormattingFeatureMap{
 	event.FmtSyntaxHighlighting: event.CapLevelDropped,
 	event.FmtBlockquote:         event.CapLevelDropped,
 	event.FmtInlineLink:         event.CapLevelDropped,
-	event.FmtUserLink:           event.CapLevelDropped,
+	event.FmtUserLink:           event.CapLevelFullySupported,
 	event.FmtUnorderedList:      event.CapLevelDropped,
 	event.FmtOrderedList:        event.CapLevelDropped,
 	event.FmtListStart:          event.CapLevelDropped,
@@ -145,7 +145,7 @@ func (*LinkedInClient) GetCapabilities(ctx context.Context, portal *bridgev2.Por
 		MaxTextLength:       MaxTextLength,
 		LocationMessage:     event.CapLevelDropped,
 		Reply:               event.CapLevelDropped,
-		Edit:                event.CapLevelDropped,
+		Edit:                event.CapLevelFullySupported, // TODO note that edits are restricted to specific msgtypes
 		Delete:              event.CapLevelDropped,
 		Reaction:            event.CapLevelDropped,
 		ReactionCount:       1,
diff --git a/pkg/connector/client.go b/pkg/connector/client.go
index e911ca6..acb699a 100644
--- a/pkg/connector/client.go
+++ b/pkg/connector/client.go
@@ -48,8 +48,8 @@ type LinkedInClient struct {
 }
 
 var (
-	_ bridgev2.NetworkAPI = (*LinkedInClient)(nil)
-	// _ bridgev2.EditHandlingNetworkAPI          = (*LinkedInClient)(nil)
+	_ bridgev2.NetworkAPI             = (*LinkedInClient)(nil)
+	_ bridgev2.EditHandlingNetworkAPI = (*LinkedInClient)(nil)
 	// _ bridgev2.ReactionHandlingNetworkAPI      = (*LinkedInClient)(nil)
 	// _ bridgev2.RedactionHandlingNetworkAPI     = (*LinkedInClient)(nil)
 	// _ bridgev2.ReadReceiptHandlingNetworkAPI   = (*LinkedInClient)(nil)
@@ -432,6 +432,10 @@ func (l *LinkedInClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.
 	}, nil
 }
 
+func (l *LinkedInClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
+	return l.client.EditMessage(ctx, types.NewURN(string(msg.EditTarget.ID)), matrixfmt.Parse(ctx, l.matrixParser, msg.Content))
+}
+
 func (l *LinkedInClient) IsLoggedIn() bool {
 	return l.userLogin.Metadata.(*UserLoginMetadata).Cookies.GetCookie(linkedingo.LinkedInCookieJSESSIONID) != ""
 }
diff --git a/pkg/linkedingo/constants.go b/pkg/linkedingo/constants.go
index 2666157..19bde7d 100644
--- a/pkg/linkedingo/constants.go
+++ b/pkg/linkedingo/constants.go
@@ -28,6 +28,7 @@ const (
 const LinkedInCookieJSESSIONID = "JSESSIONID"
 
 const (
+	contentTypeJSON                   = "application/json"
 	contentTypeJSONLinkedInNormalized = "application/vnd.linkedin.normalized+json+2.1"
 	contentTypeGraphQL                = "application/graphql"
 	contentTypeTextEventStream        = "text/event-stream"
diff --git a/pkg/linkedingo/messages.go b/pkg/linkedingo/messages.go
index 683da2e..2d364ea 100644
--- a/pkg/linkedingo/messages.go
+++ b/pkg/linkedingo/messages.go
@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/url"
 
 	"github.com/google/uuid"
 	"go.mau.fi/util/random"
@@ -70,3 +71,35 @@ func (c *Client) SendMessage(ctx context.Context, payload SendMessagePayload) (*
 	var messageSentResponse MessageSentResponse
 	return &messageSentResponse, json.NewDecoder(resp.Body).Decode(&messageSentResponse)
 }
+
+type GraphQLPatchBody struct {
+	Patch Patch `json:"patch,omitempty"`
+}
+
+// TODO: genericise?
+type Patch struct {
+	Set any `json:"$set,omitempty"`
+}
+
+type EditMessagePayload struct {
+	Body SendMessageBody `json:"body,omitempty"`
+}
+
+func (c *Client) EditMessage(ctx context.Context, messageURN types.URN, p SendMessageBody) error {
+	url, err := url.JoinPath(linkedInVoyagerMessagingDashMessengerMessagesURL, messageURN.URLEscaped())
+	if err != nil {
+		return err
+	}
+	resp, err := c.newAuthedRequest(http.MethodPost, url).
+		WithCSRF().
+		WithJSONPayload(GraphQLPatchBody{Patch: Patch{Set: EditMessagePayload{Body: p}}}).
+		WithHeader("accept", contentTypeJSON).
+		WithRealtimeHeaders().
+		Do(ctx)
+	if err != nil {
+		return err
+	} else if resp.StatusCode != http.StatusNoContent {
+		return fmt.Errorf("failed to edit message with urn %s (statusCode=%d)", messageURN, resp.StatusCode)
+	}
+	return nil
+}
diff --git a/pkg/linkedingo/types/urn.go b/pkg/linkedingo/types/urn.go
index b58fda4..4a42a6f 100644
--- a/pkg/linkedingo/types/urn.go
+++ b/pkg/linkedingo/types/urn.go
@@ -3,6 +3,7 @@ package types
 import (
 	"encoding/json"
 	"fmt"
+	"net/url"
 	"strings"
 )
 
@@ -32,6 +33,10 @@ func (u URN) String() string {
 	return strings.Join(u.parts, ":")
 }
 
+func (u URN) URLEscaped() string {
+	return url.PathEscape(u.String())
+}
+
 func (u URN) IsEmpty() bool {
 	return len(u.parts) == 0
 }
-- 
GitLab