summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/platforms/linkedin/linkedin.go135
-rw-r--r--internal/platforms/linkedin/preview.go55
2 files changed, 153 insertions, 37 deletions
diff --git a/internal/platforms/linkedin/linkedin.go b/internal/platforms/linkedin/linkedin.go
index 283ab14..b8790dd 100644
--- a/internal/platforms/linkedin/linkedin.go
+++ b/internal/platforms/linkedin/linkedin.go
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
+ "os"
"time"
"codeberg.org/snonux/gos/internal/colour"
@@ -19,10 +20,7 @@ import (
var errUnauthorized = errors.New("unauthorized access, refresh or create token?")
-const (
- linkedInPostsURL = "https://api.linkedin.com/rest/posts"
- linkedInTimeout = 10 * time.Second
-)
+const linkedInTimeout = 10 * time.Second
func Post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) error {
err := post(ctx, args, sizeLimit, en)
@@ -53,19 +51,12 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry)
newCtx, cancel = context.WithTimeout(ctx, linkedInTimeout)
defer cancel()
- prev, err := NewPreview(newCtx, urls)
+
+ prev, err := NewPreview(newCtx, args, urls)
if err != nil {
return err
}
- var filePath string
- if prev.imageURL != "" {
- if filePath, err = prev.DownloadImage(args.CacheDir); err != nil {
- return err
- }
- colour.Infoln("Downloaded preview image to ", filePath)
- }
-
question := fmt.Sprintf("Do you want to post this message to Linkedin (%v)?", prev)
if err := prompt.FileAction(question, content, en.Path); err != nil {
return err
@@ -73,13 +64,16 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry)
newCtx, cancel = context.WithTimeout(ctx, linkedInTimeout)
defer cancel()
- return callLinkedInAPI(newCtx, personID, accessToken, content, prev)
+ return postMessageToLinkedInAPI(newCtx, personID, accessToken, content, prev)
}
-// TODO: Also post preview images
-func callLinkedInAPI(ctx context.Context, personID, accessToken, content string, prev preview) error {
+// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
+func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, content string, prev preview) error {
+ const linkedInPostsURL = "https://api.linkedin.com/rest/posts"
+
+ personURN := fmt.Sprintf("urn:li:person:%s", personID)
post := map[string]interface{}{
- "author": fmt.Sprintf("urn:li:person:%s", personID),
+ "author": personURN,
"commentary": escapeLinkedInText(content),
"visibility": "PUBLIC",
"distribution": map[string]interface{}{
@@ -91,11 +85,20 @@ func callLinkedInAPI(ctx context.Context, personID, accessToken, content string,
"isReshareDisabledByAuthor": false,
}
- if !prev.Empty() {
+ var thumbnailURN string
+ if thumbnailPath, ok := prev.Thumbnail(); ok {
+ imageURN, err := postImageToLinkedInAPI(ctx, personURN, accessToken, thumbnailPath)
+ if err != nil {
+ return err
+ }
+ thumbnailURN = imageURN
+ }
+ if title, url, ok := prev.TitleAndURL(); ok {
post["content"] = map[string]interface{}{
"article": map[string]interface{}{
- "title": prev.title,
- "source": prev.url,
+ "title": title,
+ "source": url,
+ "thumbnail": thumbnailURN,
},
}
}
@@ -135,3 +138,95 @@ func callLinkedInAPI(ctx context.Context, personID, accessToken, content string,
}
return err
}
+
+// https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
+func postImageToLinkedInAPI(ctx context.Context, personURN, accessToken, imagePath string) (string, error) {
+ uploadURL, imageURN, err := initializeImageUpload(ctx, personURN, accessToken)
+ if err != nil {
+ return imageURN, err
+ }
+ return imageURN, uploadImage(ctx, imagePath, uploadURL, accessToken)
+}
+
+func initializeImageUpload(ctx context.Context, personURN, accessToken string) (string, string, error) {
+ const linkedInAPIURL = "https://api.linkedin.com/rest/images?action=initializeUpload"
+
+ type InitializeUploadRequest struct {
+ Owner string `json:"owner"`
+ }
+ requestBody, err := json.Marshal(map[string]interface{}{
+ "initializeUploadRequest": InitializeUploadRequest{Owner: personURN},
+ })
+
+ if err != nil {
+ return "", "", fmt.Errorf("error creating request body: %w", err)
+ }
+
+ // Initialize image upload
+ req, err := http.NewRequestWithContext(ctx, "POST", linkedInAPIURL, bytes.NewBuffer(requestBody))
+ if err != nil {
+ return "", "", fmt.Errorf("error creating request: %w", err)
+ }
+
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Add("LinkedIn-Version", "202409")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", "", fmt.Errorf("error sending request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ type InitializeUploadResponse struct {
+ Value struct {
+ UploadURL string `json:"uploadUrl"`
+ Image string `json:"image"`
+ } `json:"value"`
+ }
+ var response InitializeUploadResponse
+ if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+ return "", "", fmt.Errorf("error decoding response: %w", err)
+ }
+
+ return response.Value.UploadURL, response.Value.Image, nil
+}
+
+func uploadImage(ctx context.Context, imagePath, uploadURL, accessToken string) error {
+ file, err := os.Open(imagePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ imageData, err := io.ReadAll(file)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "PUT", uploadURL, bytes.NewBuffer(imageData))
+ if err != nil {
+ return fmt.Errorf("error creating upload request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("error sending upload request: %w", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+ colour.Infoln(string(body))
+
+ if resp.StatusCode != http.StatusCreated {
+ return fmt.Errorf("upload failed with status %s: %s", resp.Status, string(body))
+ }
+
+ return nil
+}
diff --git a/internal/platforms/linkedin/preview.go b/internal/platforms/linkedin/preview.go
index 6a16536..41b99a5 100644
--- a/internal/platforms/linkedin/preview.go
+++ b/internal/platforms/linkedin/preview.go
@@ -11,6 +11,7 @@ import (
"path/filepath"
"codeberg.org/snonux/gos/internal/colour"
+ "codeberg.org/snonux/gos/internal/config"
"codeberg.org/snonux/gos/internal/oi"
"golang.org/x/net/html"
)
@@ -21,41 +22,61 @@ var (
)
type preview struct {
- title, imageURL, url string
+ title, thumbnailURL, thumbnailDownloadPath, url string
}
-func NewPreview(ctx context.Context, urls []string) (preview, error) {
+func NewPreview(ctx context.Context, args config.Args, urls []string) (preview, error) {
+ var (
+ p preview
+ err error
+ )
if len(urls) == 0 {
- return preview{}, nil
+ return p, nil
}
- title, imageURL, err := extractFromURL(ctx, urls[0])
- if errors.Is(err, errNoTitleElementFound) || title == "" {
- colour.Infoln("Setting title to", urls[0])
- title = urls[0]
+ p.url = urls[0]
+
+ if p.title, p.thumbnailURL, err = extractFromURL(ctx, urls[0]); err != nil {
+ if errors.Is(err, errNoTitleElementFound) || p.title == "" {
+ colour.Infoln("Setting title to", urls[0])
+ p.title = urls[0]
+ }
+ if errors.Is(err, errNoImageElementFound) {
+ colour.Infoln("URL", urls[0], "without any image, that's fine, though.")
+ }
+ if !errors.Is(err, errNoTitleElementFound) && !errors.Is(err, errNoImageElementFound) {
+ return p, err
+ }
}
- if errors.Is(err, errNoImageElementFound) {
- colour.Infoln("URL", urls[0], "without any image, that's fine, though.")
- err = nil
+
+ if p.thumbnailURL != "" {
+ if p.thumbnailDownloadPath, err = p.DownloadImage(args.CacheDir); err != nil {
+ return p, err
+ }
+ colour.Infoln("Downloaded preview image to ", p.thumbnailDownloadPath)
}
- return preview{title: title, imageURL: imageURL, url: urls[0]}, err
+ return p, nil
}
func (p preview) String() string {
- if p.imageURL != "" {
- return fmt.Sprintf("Title: %s; URL: %s, Image: %s", p.title, p.url, p.imageURL)
+ if p.thumbnailURL != "" {
+ return fmt.Sprintf("Title: %s; URL: %s, Image: %s", p.title, p.url, p.thumbnailURL)
}
return fmt.Sprintf("Title: %s; URL: %s", p.title, p.url)
}
-func (p preview) Empty() bool {
- return p.url == ""
+func (p preview) TitleAndURL() (string, string, bool) {
+ return p.title, p.url, p.url != ""
+}
+
+func (p preview) Thumbnail() (string, bool) {
+ return p.thumbnailDownloadPath, p.thumbnailDownloadPath != ""
}
func (p preview) DownloadImage(destPath string) (string, error) {
if err := oi.EnsureDir(destPath); err != nil {
return "", err
}
- resp, err := http.Get(p.imageURL)
+ resp, err := http.Get(p.thumbnailURL)
if err != nil {
return "", err
}
@@ -65,7 +86,7 @@ func (p preview) DownloadImage(destPath string) (string, error) {
return "", fmt.Errorf("bad status while trying to download image: %s", resp.Status)
}
- destFile := fmt.Sprintf("%s/%s", destPath, filepath.Base(p.imageURL))
+ destFile := fmt.Sprintf("%s/%s", destPath, filepath.Base(p.thumbnailURL))
out, err := os.Create(destFile)
if err != nil {
return destFile, fmt.Errorf("%s: %w", destFile, err)