diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-16 23:50:00 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-16 23:50:00 +0300 |
| commit | 650bfe958f84cbb3045c33044b46562c51720e0a (patch) | |
| tree | 469721d25f6d362c15f3aa153e0da7e966417272 | |
| parent | 2c076a24fd94a64b4da8b3e7682a27c99d24cc0d (diff) | |
make linkedin version API configurable
| -rw-r--r-- | README.md | 12 | ||||
| -rw-r--r-- | examples/gos.json | 1 | ||||
| -rw-r--r-- | internal/config/config.go | 4 | ||||
| -rw-r--r-- | internal/platforms/linkedin/linkedin.go | 41 |
4 files changed, 43 insertions, 15 deletions
@@ -62,7 +62,8 @@ Example Configuration File (`~/.config/gos/gos.json`): "MastodonAccessToken": "your-mastodon-access-token", "LinkedInClientID": "your-linkedin-client-id", "LinkedInSecret": "your-linkedin-client-secret", - "LinkedInRedirectURL": "http://localhost:8080/callback" + "LinkedInRedirectURL": "http://localhost:8080/callback", + "LinkedInVersion": "" } ``` @@ -86,11 +87,20 @@ Example with pause period configured: * `LinkedInClientID`: The client ID for your LinkedIn app, which is needed for OAuth2 authentication. * `LinkedInSecret`: The client secret for your LinkedIn app. * `LinkedInRedirectURL`: The redirect URL configured for handling OAuth2 responses. +* `LinkedInVersion`: (Optional) LinkedIn API version header value (e.g., `202502`). Set this if you receive 426 Upgrade Required with `NONEXISTENT_VERSION`. Leave empty to omit the header and use LinkedIn's default. * `LinkedInAccessToken`: Gos will automatically update this after successful OAuth2 authentication with LinkedIn. * `LinkedInPersonID`: Gos will automatically update this after successful OAuth2 authentication with LinkedIn. * `PauseStart`: (Optional) Start date for pausing all posts in YYYY-MM-DD format. * `PauseEnd`: (Optional) End date for pausing all posts in YYYY-MM-DD format. +### LinkedIn API versioning + +LinkedIn requires an active API version via the `LinkedIn-Version` header for some endpoints. If you see an error like: + +`Status: 426 Upgrade Required {"code":"NONEXISTENT_VERSION","message":"Requested version <date> is not active"}` + +set an active version string (e.g., `202502`) in the `LinkedInVersion` field of your config. You can also clear the field to omit the header and fall back to LinkedIn's default version. + ### Automatically managed fields Once you finish the OAuth2 setup (after the initial run of `gos`), some fields—like `LinkedInAccessToken` and `LinkedInPersonID` will get filled in automatically. To check if everything's working without actually posting anything, you can run the app in dry run mode with the `--dry` option. After OAuth2 is successful, the file will be updated with `LinkedInClientID` and `LinkedInAccessToken`. If the access token expires, it will go through the OAuth2 process again. diff --git a/examples/gos.json b/examples/gos.json index 2b033c6..d8e9082 100644 --- a/examples/gos.json +++ b/examples/gos.json @@ -4,6 +4,7 @@ "LinkedInClientID": "CENSORED", "LinkedInSecret": "CENSORED", "LinkedInRedirectURL": "http://localhost:8080/callback", + "LinkedInVersion": "", "LinkedInAccessToken": "CENSORED", "LinkedInPersonID": "CENSORED" } diff --git a/internal/config/config.go b/internal/config/config.go index 1504b95..8b101be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,10 @@ type Config struct { LinkedInClientID string LinkedInSecret string LinkedInRedirectURL string + // Optional LinkedIn API version header value (e.g., "202502"). + // When set, requests include `LinkedIn-Version: <value>`. + // Leave empty to omit the header and use LinkedIn's default. + LinkedInVersion string `json:"LinkedInVersion,omitempty"` // Will be updated by gos automatically, after successful oauth2 LinkedInAccessToken string `json:"LinkedInAccessToken,omitempty"` // Will be updated by gos automatically, after successful oauth2 diff --git a/internal/platforms/linkedin/linkedin.go b/internal/platforms/linkedin/linkedin.go index 4c63f9a..11291fb 100644 --- a/internal/platforms/linkedin/linkedin.go +++ b/internal/platforms/linkedin/linkedin.go @@ -22,6 +22,16 @@ var errUnauthorized = errors.New("unauthorized access, refresh or create token?" const linkedInTimeout = 10 * time.Second +// addCommonHeaders applies required headers and optional LinkedIn versioning. +func addCommonHeaders(req *http.Request, accessToken, liVersion string) { + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-RestLi-Protocol-Version", "2.0.0") + if liVersion != "" { + req.Header.Set("LinkedIn-Version", liVersion) + } +} + func Post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) error { err := post(ctx, args, sizeLimit, en) if errors.Is(err, errUnauthorized) { @@ -68,11 +78,11 @@ func post(ctx context.Context, args config.Args, sizeLimit int, en entry.Entry) newCtx, cancel = context.WithTimeout(ctx, linkedInTimeout) defer cancel() - return postMessageToLinkedInAPI(newCtx, personID, accessToken, content, prev) + return postMessageToLinkedInAPI(newCtx, personID, accessToken, content, prev, args.Config.LinkedInVersion) } // 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 { +func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, content string, prev preview, liVersion string) error { const linkedInPostsURL = "https://api.linkedin.com/rest/posts" personURN := fmt.Sprintf("urn:li:person:%s", personID) @@ -91,7 +101,7 @@ func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, conten article := map[string]interface{}{} if thumbnailPath, ok := prev.Thumbnail(); ok { - thumbnailURN, err := postImageToLinkedInAPI(ctx, personURN, accessToken, thumbnailPath) + thumbnailURN, err := postImageToLinkedInAPI(ctx, personURN, accessToken, thumbnailPath, liVersion) if err != nil { return err } @@ -112,10 +122,11 @@ func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, conten return fmt.Errorf("Error creating request: %w", err) } - req.Header.Add("Authorization", "Bearer "+accessToken) - req.Header.Set("Content-Type", "application/json") - req.Header.Add("X-RestLi-Protocol-Version", "2.0.0") - req.Header.Add("LinkedIn-Version", "202409") + // Use configured LinkedIn version if available + addCommonHeaders(req, accessToken, liVersion) + if liVersion != "" { + colour.Infoln("Using LinkedIn-Version", liVersion) + } client := &http.Client{} resp, err := client.Do(req) @@ -133,21 +144,25 @@ func postMessageToLinkedInAPI(ctx context.Context, personID, accessToken, conten resp.Status, string(body)) if resp.StatusCode == http.StatusUnauthorized { err = errors.Join(err, errUnauthorized) + } else if resp.StatusCode == http.StatusUpgradeRequired { + // 426 often indicates a non-active LinkedIn-Version header. + // Provide a clear hint to configure a valid version. + err = fmt.Errorf("%w; LinkedIn API version likely inactive. Set an active 'LinkedInVersion' in config (e.g. 202502) or remove to use default. Response: %s", err, string(body)) } } 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) +func postImageToLinkedInAPI(ctx context.Context, personURN, accessToken, imagePath string, liVersion string) (string, error) { + uploadURL, imageURN, err := initializeImageUpload(ctx, personURN, accessToken, liVersion) if err != nil { return imageURN, err } return imageURN, performImageUpload(ctx, imagePath, uploadURL, accessToken) } -func initializeImageUpload(ctx context.Context, personURN, accessToken string) (string, string, error) { +func initializeImageUpload(ctx context.Context, personURN, accessToken string, liVersion string) (string, string, error) { const linkedInAPIURL = "https://api.linkedin.com/rest/images?action=initializeUpload" type InitializeUploadRequest struct { @@ -156,7 +171,6 @@ func initializeImageUpload(ctx context.Context, personURN, accessToken string) ( requestBody, err := json.Marshal(map[string]interface{}{ "initializeUploadRequest": InitializeUploadRequest{Owner: personURN}, }) - if err != nil { return "", "", fmt.Errorf("error creating request body: %w", err) } @@ -167,9 +181,8 @@ func initializeImageUpload(ctx context.Context, personURN, accessToken string) ( 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") + // Version header is optional and configurable + addCommonHeaders(req, accessToken, liVersion) client := &http.Client{} resp, err := client.Do(req) |
