Skip to content

Commit fcdbb39

Browse files
csharpfritzCopilot
andcommitted
fix: use valid LinkedIn API endpoint and update version to 202506
Replaced non-existent q=hashtag endpoint with q=author query that fetches authenticated user's posts and filters client-side for hashtag matches. Added ResolveMemberUrn() with caching. Updated LinkedIn-Version header from 202401 to 202506 to fix 426 error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6a702c8 commit fcdbb39

File tree

2 files changed

+80
-5
lines changed

2 files changed

+80
-5
lines changed

.ai-team/agents/sombra/history.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
- **LinkedIn OAuth flow:** Admin-initiated OAuth is implemented via minimal API endpoints (`/api/linkedin/authorize` and `/api/linkedin/callback`) in Service_LinkedInOAuth.cs, mapped in Program.cs via `app.MapLinkedInOAuthEndpoints()`. The flow uses LinkedIn's 3-legged OAuth 2.0 with scopes `openid`, `profile`, `w_member_social`, and `r_organization_social`. Access tokens expire in 60 days. Tokens are stored encrypted in LinkedInConfiguration via IConfigureTagzApp. State parameter is persisted in AuthenticationProperties for CSRF protection. The admin UI shows an "Authorize with LinkedIn" button that redirects to the authorize endpoint, which then redirects to LinkedIn, which redirects back to the callback endpoint where tokens are exchanged and stored.
2525
- **Blazor.Client WebAssembly constraints:** Microsoft.AspNetCore.WebUtilities.QueryHelpers is NOT available in Blazor WebAssembly projects. Use simple string parsing with `query.Contains()` and `Uri.UnescapeDataString()` for query string handling in client-side Blazor components.
2626
- **OAuth security patterns:** Callback URLs must use HTTPS and match exactly what's registered in the LinkedIn Developer App. The app uses X-Forwarded-Proto and X-Forwarded-Host headers to correctly construct callback URLs when behind proxies/containers. State parameter is generated as GUID and stored in AuthenticationProperties, then validated on callback to prevent CSRF attacks.
27+
- **LinkedIn API reality check:** `GET /rest/posts?q=hashtag&hashtag={tag}` does NOT exist. The `q=hashtag` finder is not a valid query parameter. With `w_member_social` scope, the realistic approach is `GET /rest/posts?q=author&author={memberUrn}` to fetch the authenticated member's own posts, then filter client-side for hashtag matches. The member URN is obtained from `GET /v2/userinfo` (`sub` field → `urn:li:person:{sub}`).
28+
- **LinkedIn member URN resolution:** Call `/v2/userinfo` once at StartAsync (and lazily on first use if that failed). Cache the URN in `_memberUrn` field. No version header needed on the `/v2/userinfo` endpoint — it's a standard OAuth2 endpoint.
2729

2830
## Team Updates
2931
- 📌 **2026-02-18**: LinkedIn provider plan decided by Mercy — architecture approved for implementation. 10 work items across 4 phases (scaffolding, core provider, integration, testing/docs). Estimated 2-3 days for implementation, 1 day for tests/docs.

src/TagzApp.Providers.LinkedIn/LinkedInProvider.cs

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace TagzApp.Providers.LinkedIn;
1010

1111
public class LinkedInProvider : ISocialMediaProvider, IDisposable
1212
{
13-
private const string LinkedInApiVersion = "202401";
13+
private const string LinkedInApiVersion = "202506";
1414
private const string LinkedInApiBase = "https://api.linkedin.com";
1515

1616
private readonly HttpClient _httpClient;
@@ -23,6 +23,7 @@ public class LinkedInProvider : ISocialMediaProvider, IDisposable
2323
private string _statusMessage = "Not started";
2424
private int _dailyCallCount;
2525
private DateTimeOffset _dailyResetTime = DateTimeOffset.UtcNow.Date.AddDays(1);
26+
private string? _memberUrn;
2627

2728
public LinkedInProvider(
2829
IHttpClientFactory httpClientFactory,
@@ -79,9 +80,20 @@ public async Task<IEnumerable<Content>> GetContentForHashtag(Hashtag tag, DateTi
7980
return [];
8081
}
8182

83+
if (string.IsNullOrEmpty(_memberUrn))
84+
{
85+
_memberUrn = await ResolveMemberUrn();
86+
if (string.IsNullOrEmpty(_memberUrn))
87+
{
88+
_status = SocialMediaStatus.Unhealthy;
89+
_statusMessage = "Unable to resolve authenticated member URN";
90+
return [];
91+
}
92+
}
93+
8294
var hashtag = Hashtag.ClearFormatting(tag.Text);
83-
var encodedHashtag = HttpUtility.UrlEncode(hashtag);
84-
var requestUri = $"{LinkedInApiBase}/rest/posts?q=hashtag&hashtag={encodedHashtag}";
95+
var encodedAuthor = HttpUtility.UrlEncode(_memberUrn);
96+
var requestUri = $"{LinkedInApiBase}/rest/posts?q=author&author={encodedAuthor}&count=50";
8597

8698
try
8799
{
@@ -117,6 +129,9 @@ public async Task<IEnumerable<Content>> GetContentForHashtag(Hashtag tag, DateTi
117129
var postTimestamp = DateTimeOffset.FromUnixTimeMilliseconds(post.CreatedAt);
118130
if (postTimestamp <= since) continue;
119131

132+
// Client-side hashtag filter: only include posts mentioning the hashtag
133+
if (!ContainsHashtag(post.Commentary, hashtag)) continue;
134+
120135
var author = await ResolveAuthor(post.Author);
121136

122137
var postUrn = post.Id ?? string.Empty;
@@ -203,18 +218,76 @@ public async Task<IEnumerable<Content>> GetContentForHashtag(Hashtag tag, DateTi
203218
return Task.FromResult((SocialMediaStatus.Healthy, _status == SocialMediaStatus.Unhealthy && _statusMessage == "Not started" ? "OK" : _statusMessage));
204219
}
205220

206-
public Task StartAsync()
221+
public async Task StartAsync()
207222
{
223+
_memberUrn = await ResolveMemberUrn();
208224
_status = SocialMediaStatus.Healthy;
209225
_statusMessage = "OK";
210-
return Task.CompletedTask;
211226
}
212227

213228
public Task StopAsync()
214229
{
215230
return Task.CompletedTask;
216231
}
217232

233+
private async Task<string?> ResolveMemberUrn()
234+
{
235+
if (string.IsNullOrWhiteSpace(_configuration.AccessToken))
236+
{
237+
return null;
238+
}
239+
240+
try
241+
{
242+
ResetDailyBudgetIfNeeded();
243+
if (_dailyCallCount >= _configuration.DailyCallBudget)
244+
{
245+
_logger.LogWarning("LinkedIn daily budget exhausted; cannot resolve member URN");
246+
return null;
247+
}
248+
249+
using var request = new HttpRequestMessage(HttpMethod.Get, $"{LinkedInApiBase}/v2/userinfo");
250+
request.Headers.Add("Authorization", $"Bearer {_configuration.AccessToken}");
251+
252+
var response = await _httpClient.SendAsync(request);
253+
Interlocked.Increment(ref _dailyCallCount);
254+
255+
if (!response.IsSuccessStatusCode)
256+
{
257+
_logger.LogError("LinkedIn userinfo call failed: {StatusCode} {Reason}", (int)response.StatusCode, response.ReasonPhrase);
258+
return null;
259+
}
260+
261+
var userInfo = await response.Content.ReadFromJsonAsync<JsonElement>();
262+
if (userInfo.TryGetProperty("sub", out var sub))
263+
{
264+
var memberId = sub.GetString();
265+
if (!string.IsNullOrEmpty(memberId))
266+
{
267+
var urn = $"urn:li:person:{memberId}";
268+
_logger.LogInformation("Resolved LinkedIn member URN: {Urn}", urn);
269+
return urn;
270+
}
271+
}
272+
273+
_logger.LogError("LinkedIn userinfo response missing 'sub' field");
274+
return null;
275+
}
276+
catch (Exception ex)
277+
{
278+
_logger.LogError(ex, "Error resolving LinkedIn member URN");
279+
return null;
280+
}
281+
}
282+
283+
private static bool ContainsHashtag(string? text, string hashtag)
284+
{
285+
if (string.IsNullOrEmpty(text)) return false;
286+
// Match #hashtag or plain hashtag text (case-insensitive)
287+
return text.Contains($"#{hashtag}", StringComparison.OrdinalIgnoreCase)
288+
|| text.Contains(hashtag, StringComparison.OrdinalIgnoreCase);
289+
}
290+
218291
private async Task<LinkedInAuthor> ResolveAuthor(string? authorUrn)
219292
{
220293
if (string.IsNullOrEmpty(authorUrn))

0 commit comments

Comments
 (0)