From 329d8b5a07c203d9097c55cbe568807dae890d8c Mon Sep 17 00:00:00 2001 From: Rockford Lhotka Date: Mon, 4 May 2026 14:46:00 -0500 Subject: [PATCH] feat: add Threads provider with auto token refresh (1.4.0) Adds SocialAgent.Providers.Threads implementing ISocialMediaProvider against Meta's Threads Graph API v1.0. Notifications are synthesized from /me/mentions and /me/replies; per-post likes and follower count are gated behind an IncludePostInsights flag. A new ThreadsTokenRefreshService persists the long-lived OAuth token to a ProviderTokens table and refreshes ahead of expiry, surviving pod restarts. DatabaseMigrationService now adds new tables idempotently on existing live databases. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 54 ++++ CLAUDE.md | 5 +- README.md | 45 ++-- SocialAgent.slnx | 2 + deploy/k8s/configmap.yaml | 2 + deploy/k8s/deployment.yaml | 17 +- deploy/k8s/secret.yaml | 1 + docs/providers/threads.md | 148 +++++++++++ src/SocialAgent.Core/Models/ProviderToken.cs | 9 + .../Repositories/ISocialDataRepository.cs | 4 + .../Repositories/SocialDataRepository.cs | 21 ++ src/SocialAgent.Data/SocialAgentDbContext.cs | 6 + src/SocialAgent.Host/Program.cs | 10 +- .../Services/DatabaseMigrationService.cs | 30 +++ .../Services/ThreadsTokenRefreshService.cs | 133 ++++++++++ src/SocialAgent.Host/SocialAgent.Host.csproj | 3 +- src/SocialAgent.Host/appsettings.json | 8 + .../Models/ThreadsConversationItem.cs | 16 ++ .../Models/ThreadsInsightsResponse.cs | 23 ++ .../Models/ThreadsListResponse.cs | 19 ++ .../Models/ThreadsRefreshResponse.cs | 8 + .../Models/ThreadsUser.cs | 10 + .../ServiceCollectionExtensions.cs | 29 +++ .../SocialAgent.Providers.Threads.csproj | 11 + .../ThreadsOptions.cs | 13 + .../ThreadsProvider.cs | 232 ++++++++++++++++++ .../ThreadsTokenStore.cs | 45 ++++ ...SocialAgent.Providers.Threads.Tests.csproj | 15 ++ .../ThreadsProviderTests.cs | 144 +++++++++++ 29 files changed, 1041 insertions(+), 22 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/providers/threads.md create mode 100644 src/SocialAgent.Core/Models/ProviderToken.cs create mode 100644 src/SocialAgent.Host/Services/ThreadsTokenRefreshService.cs create mode 100644 src/SocialAgent.Providers.Threads/Models/ThreadsConversationItem.cs create mode 100644 src/SocialAgent.Providers.Threads/Models/ThreadsInsightsResponse.cs create mode 100644 src/SocialAgent.Providers.Threads/Models/ThreadsListResponse.cs create mode 100644 src/SocialAgent.Providers.Threads/Models/ThreadsRefreshResponse.cs create mode 100644 src/SocialAgent.Providers.Threads/Models/ThreadsUser.cs create mode 100644 src/SocialAgent.Providers.Threads/ServiceCollectionExtensions.cs create mode 100644 src/SocialAgent.Providers.Threads/SocialAgent.Providers.Threads.csproj create mode 100644 src/SocialAgent.Providers.Threads/ThreadsOptions.cs create mode 100644 src/SocialAgent.Providers.Threads/ThreadsProvider.cs create mode 100644 src/SocialAgent.Providers.Threads/ThreadsTokenStore.cs create mode 100644 tests/SocialAgent.Providers.Threads.Tests/SocialAgent.Providers.Threads.Tests.csproj create mode 100644 tests/SocialAgent.Providers.Threads.Tests/ThreadsProviderTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61cfa05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +All notable changes to SocialAgent are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project +adheres to [Semantic Versioning](https://semver.org/). + +## [1.4.0] - 2026-05-04 + +### Added +- **Threads provider** (`SocialAgent.Providers.Threads`) implementing + `ISocialMediaProvider` against Meta's Threads API. Maps `/v1.0/me`, + `/v1.0/me/threads`, `/v1.0/me/mentions`, and `/v1.0/me/replies` to the + standard provider methods. Notifications are synthesized from mentions + plus replies. +- **Threads long-lived token refresh** via the new + `ThreadsTokenRefreshService`. Calls + `GET /refresh_access_token?grant_type=th_refresh_token` ahead of the + configured `RefreshThresholdDays` (default 7) and persists the new token + to the database so refreshes survive pod restarts. +- **`ProviderToken` entity** and repository methods + (`GetProviderTokenAsync`, `UpsertProviderTokenAsync`) for storing + rotating OAuth bearer tokens. +- New configuration section `SocialAgent:Providers:Threads` (`Enabled`, + `BaseUrl`, `AccessToken`, `IncludePostInsights`, `RefreshThresholdDays`, + `RefreshCheckIntervalHours`). +- Kubernetes ConfigMap / Secret / Deployment wiring for the Threads + provider. +- `docs/providers/threads.md` long-form provider documentation covering + OAuth scopes, token refresh, and known limitations. + +### Changed +- Agent version bumped from **1.3.4** to **1.4.0**; the `/.well-known/agent-card.json` + now reports `1.4.0`. +- Agent card description updated to mention Threads alongside Mastodon + and Bluesky. + +### Migration notes +- The new `ProviderTokens` table is created automatically by + `DatabaseMigrationService` on every startup, both for fresh databases + (via `EnsureCreatedAsync`) and existing PostgreSQL/SQLite databases + (via an idempotent `CREATE TABLE IF NOT EXISTS` patch). No manual DDL + is required when rolling out 1.4.0. See + [`docs/providers/threads.md`](docs/providers/threads.md#database-schema-change) + for the exact DDL the host runs. +- The `threads_manage_replies` and `threads_manage_insights` OAuth scopes + require Meta App Review for production tokens. The provider degrades + gracefully when scopes are missing: affected endpoints log a warning and + return empty. + +## Prior versions + +For commits prior to 1.4.0, see `git log`. Notable changes include the +A2A 1.0 migration (#4), data retention service (#3), API key +authentication (#2), and the initial scaffold. diff --git a/CLAUDE.md b/CLAUDE.md index cc131b5..05183cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,8 @@ SocialAgent is an **A2A-enabled social media monitoring agent** built on .NET 10 - **`src/SocialAgent.Analytics/`** — Analytics engine computing engagement summaries, top posts, follower insights, platform comparisons - **`src/SocialAgent.Providers.Mastodon/`** — Mastodon REST API client implementing `ISocialMediaProvider` - **`src/SocialAgent.Providers.Bluesky/`** — Bluesky AT Protocol client implementing `ISocialMediaProvider` -- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A request handler (`SocialAgentA2AHandler` implementing `A2A.IAgentHandler`), skill dispatcher (`SkillDispatcher`), skill metadata (`SkillCatalog`), stub `AIAgent` (`SocialAgentStubAgent`, framework-required name carrier), background polling service +- **`src/SocialAgent.Providers.Threads/`** — Threads Graph API v1.0 client implementing `ISocialMediaProvider`. Adds `ThreadsTokenStore` (in-memory current token + expiry) and a `RefreshTokenAsync` method on the provider; the host owns the refresh schedule via `ThreadsTokenRefreshService`. +- **`src/SocialAgent.Host/`** — ASP.NET Core host, A2A request handler (`SocialAgentA2AHandler` implementing `A2A.IAgentHandler`), skill dispatcher (`SkillDispatcher`), skill metadata (`SkillCatalog`), stub `AIAgent` (`SocialAgentStubAgent`, framework-required name carrier), background polling service, and `ThreadsTokenRefreshService` (registered only when Threads is enabled — seeds the token from DB on startup, refreshes ahead of `RefreshThresholdDays`, persists via `ISocialDataRepository`) - **`tests/`** — MSTest unit tests with NSubstitute for mocking - **`deploy/k8s/`** — Kubernetes manifests (Deployment, Service, ConfigMap, Secret) @@ -62,7 +63,7 @@ Each provider implements `ISocialMediaProvider` and is registered via DI extensi - **Async-first** — all I/O is Task-based - **NSubstitute** is the mocking framework for tests - **Configuration** — standard .NET config stack: `appsettings.json`, environment variables, user secrets, k8s Secrets -- **Database** — EF Core with SQLite for dev, PostgreSQL for prod. `DatabaseMigrationService` runs `EnsureCreatedAsync` on startup. +- **Database** — EF Core with SQLite for dev, PostgreSQL for prod. `DatabaseMigrationService` runs `EnsureCreatedAsync` plus dialect-aware `CREATE TABLE IF NOT EXISTS` patches for tables added after the initial deployment (e.g. `ProviderTokens` in 1.4.0). When adding a new table, extend `DatabaseMigrationService.EnsureProviderTokensTableAsync`-style patch logic so existing live databases pick up the change. This is an interim approach until proper EF Core migrations are adopted. ### Future Integration diff --git a/README.md b/README.md index 72706c3..87d9fea 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ SocialAgent monitors your social media accounts across multiple platforms, colle |---|---|---| | Mastodon | ✅ Implemented | REST API v1 | | Bluesky | ✅ Implemented | AT Protocol | +| Threads | ✅ Implemented | Threads Graph API v1.0 (with auto token refresh) | ### A2A Skills @@ -73,8 +74,16 @@ dotnet user-secrets set "SocialAgent:Providers:Mastodon:AccessToken" "your-token dotnet user-secrets set "SocialAgent:Providers:Bluesky:Enabled" "true" dotnet user-secrets set "SocialAgent:Providers:Bluesky:Handle" "you.bsky.social" dotnet user-secrets set "SocialAgent:Providers:Bluesky:AppPassword" "your-app-password" + +# Set Threads credentials (long-lived token; auto-refreshes ahead of expiry) +dotnet user-secrets set "SocialAgent:Providers:Threads:Enabled" "true" +dotnet user-secrets set "SocialAgent:Providers:Threads:AccessToken" "your-long-lived-token" ``` +For Threads-specific setup (OAuth scopes, Meta App Review, +`IncludePostInsights` tradeoffs, token-refresh behavior), see +[`docs/providers/threads.md`](docs/providers/threads.md). + ### Database - **Development:** SQLite (default, zero config) @@ -96,23 +105,25 @@ The agent runs as a continuous Deployment (not CronJob) for A2A responsiveness. ## Architecture ``` -┌─────────────────────────────────────────────────────────┐ -│ SocialAgent.Host (ASP.NET Core) │ -│ │ -│ ┌──────────────┐ ┌─────────────────────────────────┐ │ -│ │ A2A 1.0 │ │ Background Polling Services │ │ -│ │ (MS Agent │ │ ┌───────────┐ ┌──────────────┐ │ │ -│ │ Framework + │ │ │ Mastodon │ │ Bluesky │ │ │ -│ │ A2A SDK) │ │ │ Provider │ │ Provider │ │ │ -│ └──────┬───────┘ │ └─────┬─────┘ └──────┬───────┘ │ │ -│ │ └───────┼───────────────┼─────────┘ │ -│ ┌──────▼───────────────────▼───────────────▼─────────┐ │ -│ │ Core (Domain Models & Interfaces) │ │ -│ └──────────────────────┬─────────────────────────────┘ │ -│ ┌──────────────────────▼─────────────────────────────┐ │ -│ │ Data (EF Core — PostgreSQL / SQLite) │ │ -│ └─────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────┐ +│ SocialAgent.Host (ASP.NET Core) │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────────────────────────┐ │ +│ │ A2A 1.0 │ │ Background Services │ │ +│ │ (MS Agent │ │ ┌───────────┐ ┌──────────┐ ┌──────────────┐ │ │ +│ │ Framework + │ │ │ Mastodon │ │ Bluesky │ │ Threads │ │ │ +│ │ A2A SDK) │ │ │ Provider │ │ Provider │ │ Provider │ │ │ +│ └──────┬───────┘ │ └─────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │ +│ │ │ │ │ │ │ │ +│ │ │ + ThreadsTokenRefreshService (auto refresh)│ │ +│ │ └───────┼────────────┼──────────────┼─────────┘ │ +│ ┌──────▼───────────────── ▼ ▼ ▼─────────┐ │ +│ │ Core (Domain Models & Interfaces) │ │ +│ └─────────────────────────────┬──────────────────────────────────┘ │ +│ ┌─────────────────────────────▼──────────────────────────────────┐ │ +│ │ Data (EF Core — PostgreSQL / SQLite) │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ ``` ## Adding a New Provider diff --git a/SocialAgent.slnx b/SocialAgent.slnx index 644b2de..512c05f 100644 --- a/SocialAgent.slnx +++ b/SocialAgent.slnx @@ -5,6 +5,7 @@ + @@ -12,5 +13,6 @@ + diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml index aeeb95a..cef8e58 100644 --- a/deploy/k8s/configmap.yaml +++ b/deploy/k8s/configmap.yaml @@ -8,4 +8,6 @@ data: mastodon-instance-url: "https://fosstodon.org" bluesky-enabled: "true" bluesky-handle: "rocky.lhotka.net" + threads-enabled: "false" + threads-include-post-insights: "false" public-base-url: "http://social-agent.rockbot.svc.cluster.local" diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml index 6b5d9cb..86953cc 100644 --- a/deploy/k8s/deployment.yaml +++ b/deploy/k8s/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: social-agent - image: rockylhotka/socialagent:1.3.4 + image: rockylhotka/socialagent:1.4.0 ports: - containerPort: 8080 env: @@ -67,6 +67,21 @@ spec: secretKeyRef: name: social-agent-secrets key: bluesky-app-password + - name: SocialAgent__Providers__Threads__Enabled + valueFrom: + configMapKeyRef: + name: social-agent-config + key: threads-enabled + - name: SocialAgent__Providers__Threads__IncludePostInsights + valueFrom: + configMapKeyRef: + name: social-agent-config + key: threads-include-post-insights + - name: SocialAgent__Providers__Threads__AccessToken + valueFrom: + secretKeyRef: + name: social-agent-secrets + key: threads-access-token - name: Authentication__ApiKey valueFrom: secretKeyRef: diff --git a/deploy/k8s/secret.yaml b/deploy/k8s/secret.yaml index 836d0c5..c2598eb 100644 --- a/deploy/k8s/secret.yaml +++ b/deploy/k8s/secret.yaml @@ -8,4 +8,5 @@ stringData: connection-string: "Host=postgres;Database=socialagent;Username=socialagent;Password=CHANGE_ME" mastodon-access-token: "CHANGE_ME" bluesky-app-password: "CHANGE_ME" + threads-access-token: "CHANGE_ME" api-key: "CHANGE_ME" diff --git a/docs/providers/threads.md b/docs/providers/threads.md new file mode 100644 index 0000000..102e16f --- /dev/null +++ b/docs/providers/threads.md @@ -0,0 +1,148 @@ +# Threads Provider + +`SocialAgent.Providers.Threads` connects SocialAgent to Meta's Threads +platform via the public Threads API +([developers.facebook.com/docs/threads](https://developers.facebook.com/docs/threads)). + +## Endpoint mapping + +| `ISocialMediaProvider` method | Threads endpoint(s) | +|---|---| +| `ValidateConnectionAsync` | `GET /v1.0/me?fields=id` | +| `GetProfileAsync` | `GET /v1.0/me?fields=id,username,name,threads_profile_picture_url,threads_biography` plus optional `GET /v1.0/{user-id}/insights?metric=followers_count` | +| `GetRecentPostsAsync` | `GET /v1.0/me/threads?fields=id,text,timestamp,permalink,replies_count,reposts_count,quotes_count,media_type,media_url,is_quote_post,username&since={iso}` plus optional per-thread `GET /v1.0/{thread-id}/insights?metric=likes` | +| `GetNotificationsAsync` | merge of `GET /v1.0/me/mentions` and `GET /v1.0/me/replies`, deduped by id | +| `RefreshTokenAsync` | `GET /refresh_access_token?grant_type=th_refresh_token&access_token={current}` | + +## Configuration + +```jsonc +{ + "SocialAgent": { + "Providers": { + "Threads": { + "Enabled": true, + "BaseUrl": "https://graph.threads.net", + "AccessToken": "", + "IncludePostInsights": false, + "RefreshThresholdDays": 7, + "RefreshCheckIntervalHours": 24 + } + } + } +} +``` + +| Key | Default | Notes | +|---|---|---| +| `Enabled` | `false` | When `false`, neither the provider nor the refresh service is registered. | +| `BaseUrl` | `https://graph.threads.net` | Override only if Meta moves the endpoint. | +| `AccessToken` | `""` | Long-lived token (60-day lifetime). On first run it is persisted to the database; subsequent rotations are written back. | +| `IncludePostInsights` | `false` | When `true`, the provider issues an extra `insights` call per post (likes) and one per profile fetch (follower count). Costs N+1 requests per poll cycle and requires `threads_manage_insights`. | +| `RefreshThresholdDays` | `7` | Trigger a refresh when fewer than this many days remain. Must be ≥ 1. | +| `RefreshCheckIntervalHours` | `24` | How often `ThreadsTokenRefreshService` checks the expiry. Must be ≥ 1. | + +### Local development with user-secrets + +```bash +cd src/SocialAgent.Host +dotnet user-secrets set "SocialAgent:Providers:Threads:Enabled" "true" +dotnet user-secrets set "SocialAgent:Providers:Threads:AccessToken" "" +``` + +## OAuth scopes + +| Scope | Required for | +|---|---| +| `threads_basic` | `GET /me`, `GET /me/threads` (always required) | +| `threads_read_replies` | `GET /me/replies` | +| `threads_manage_replies` | `GET /me/mentions` | +| `threads_manage_insights` | `*/insights` (likes per post, follower count) — gated by `IncludePostInsights` | + +`threads_manage_replies` and `threads_manage_insights` require Meta App +Review for production tokens. Until review completes you can still run +the provider; the affected endpoints will fail and the provider will log +a warning and return an empty list. Posts and profile metadata still +work with `threads_basic` alone. + +## Token refresh + +Threads long-lived tokens last **60 days** and can be refreshed any time +after they are at least 24 hours old. The +`ThreadsTokenRefreshService` background service: + +1. On startup, loads the persisted token from the `ProviderTokens` table. + If none exists, it seeds the table from `ThreadsOptions.AccessToken` + with an assumed 60-day expiry. +2. Every `RefreshCheckIntervalHours` (default 24h), checks the expiry. If + the remaining lifetime is less than `RefreshThresholdDays` (default + 7d), it calls `ThreadsProvider.RefreshTokenAsync()`, which issues + `GET /refresh_access_token`, updates the in-memory `ThreadsTokenStore`, + and returns the new `(token, expiresAt)`. The service then persists + the new value via `ISocialDataRepository.UpsertProviderTokenAsync`. + +If a refresh fails (network, scope error, expired token), the existing +token continues to be used until the next check interval. The service +logs the failure but does not crash the process. + +## Known limitations + +- **No native read marker.** Threads does not expose a notification + marker like Mastodon or a per-notification read flag like Bluesky. + All notifications returned by the provider have `IsRead = false`. + Local read-state tracking is a candidate for a future release. +- **Follower count requires insights.** When `IncludePostInsights = false` + (the default), `SocialProfile.FollowerCount` is `0`. Set the flag to + `true` and grant `threads_manage_insights` to populate it. +- **`PostCount` and `FollowingCount` are always `0`.** The Threads API + does not expose these on the user object. +- **Repost vs. quote counts are merged.** Threads tracks `reposts_count` + and `quotes_count` separately; the provider sums them into + `SocialPost.RepostCount` to fit the normalized model. +- **`IncludePostInsights` is N+1.** Enabling insights adds one extra HTTP + call per post on each poll cycle. Watch your shared Graph API rate-limit + budget — Threads insights share the same per-user pool as the rest of + Meta's Graph API. + +## Database schema change + +Version 1.4.0 adds a `ProviderTokens` table that stores the rotating +Threads access token. The host creates the table automatically on +startup via `DatabaseMigrationService` — both fresh deployments +(through `EnsureCreatedAsync`) and existing PostgreSQL/SQLite deployments +(through an idempotent `CREATE TABLE IF NOT EXISTS` patch). No manual +DDL is required. + +For reference, the patch DDL emitted by the host is: + +```sql +-- PostgreSQL +CREATE TABLE IF NOT EXISTS "ProviderTokens" ( + "ProviderId" text NOT NULL PRIMARY KEY, + "AccessToken" text NOT NULL, + "ExpiresAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL +); + +-- SQLite (uses TEXT for DateTimeOffset, matching EF Core defaults) +CREATE TABLE IF NOT EXISTS "ProviderTokens" ( + "ProviderId" TEXT NOT NULL PRIMARY KEY, + "AccessToken" TEXT NOT NULL, + "ExpiresAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL +); +``` + +This ad-hoc patch path is an interim approach. Future schema changes +will warrant moving to proper EF Core migrations. + +## Files + +- `src/SocialAgent.Providers.Threads/ThreadsProvider.cs` — + `ISocialMediaProvider` implementation +- `src/SocialAgent.Providers.Threads/ThreadsTokenStore.cs` — singleton + holding the current `(token, expiresAt)` in memory +- `src/SocialAgent.Providers.Threads/ThreadsOptions.cs` — configuration +- `src/SocialAgent.Host/Services/ThreadsTokenRefreshService.cs` — + background service that owns refresh + persistence +- `src/SocialAgent.Core/Models/ProviderToken.cs` — persisted token row diff --git a/src/SocialAgent.Core/Models/ProviderToken.cs b/src/SocialAgent.Core/Models/ProviderToken.cs new file mode 100644 index 0000000..e0f5ef7 --- /dev/null +++ b/src/SocialAgent.Core/Models/ProviderToken.cs @@ -0,0 +1,9 @@ +namespace SocialAgent.Core.Models; + +public class ProviderToken +{ + public required string ProviderId { get; set; } + public required string AccessToken { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs b/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs index 2e75b21..2cb105f 100644 --- a/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs +++ b/src/SocialAgent.Data/Repositories/ISocialDataRepository.cs @@ -22,6 +22,10 @@ public interface ISocialDataRepository Task GetPollStateAsync(string providerId, CancellationToken ct = default); Task UpsertPollStateAsync(PollState state, CancellationToken ct = default); + // Provider Tokens (for refreshable OAuth bearer tokens) + Task GetProviderTokenAsync(string providerId, CancellationToken ct = default); + Task UpsertProviderTokenAsync(ProviderToken token, CancellationToken ct = default); + // Retention Task PurgeOldPostsAsync(DateTimeOffset olderThan, CancellationToken ct = default); Task PurgeOldNotificationsAsync(DateTimeOffset olderThan, CancellationToken ct = default); diff --git a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs index 5a86a69..17c5d16 100644 --- a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs +++ b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs @@ -139,6 +139,27 @@ public async Task UpsertPollStateAsync(PollState state, CancellationToken ct = d await db.SaveChangesAsync(ct); } + public async Task GetProviderTokenAsync(string providerId, CancellationToken ct = default) + { + return await db.ProviderTokens.FindAsync([providerId], ct); + } + + public async Task UpsertProviderTokenAsync(ProviderToken token, CancellationToken ct = default) + { + var existing = await db.ProviderTokens.FindAsync([token.ProviderId], ct); + if (existing is null) + { + db.ProviderTokens.Add(token); + } + else + { + existing.AccessToken = token.AccessToken; + existing.ExpiresAt = token.ExpiresAt; + existing.UpdatedAt = DateTimeOffset.UtcNow; + } + await db.SaveChangesAsync(ct); + } + public async Task PurgeOldPostsAsync(DateTimeOffset olderThan, CancellationToken ct = default) { return await db.Posts diff --git a/src/SocialAgent.Data/SocialAgentDbContext.cs b/src/SocialAgent.Data/SocialAgentDbContext.cs index 16d6a9f..4c117da 100644 --- a/src/SocialAgent.Data/SocialAgentDbContext.cs +++ b/src/SocialAgent.Data/SocialAgentDbContext.cs @@ -9,6 +9,7 @@ public class SocialAgentDbContext(DbContextOptions options public DbSet Notifications => Set(); public DbSet Profiles => Set(); public DbSet PollStates => Set(); + public DbSet ProviderTokens => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -37,5 +38,10 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasKey(e => e.ProviderId); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.ProviderId); + }); } } diff --git a/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs index c2c1e66..5696f7a 100644 --- a/src/SocialAgent.Host/Program.cs +++ b/src/SocialAgent.Host/Program.cs @@ -12,6 +12,7 @@ using SocialAgent.Host.Telemetry; using SocialAgent.Providers.Bluesky; using SocialAgent.Providers.Mastodon; +using SocialAgent.Providers.Threads; var builder = WebApplication.CreateBuilder(args); @@ -39,9 +40,16 @@ // Social media providers builder.Services.AddMastodonProvider(builder.Configuration); builder.Services.AddBlueskyProvider(builder.Configuration); +builder.Services.AddThreadsProvider(builder.Configuration); // Background services builder.Services.AddHostedService(); +if (builder.Configuration.GetValue("SocialAgent:Providers:Threads:Enabled")) +{ + // Registered after the migration service so the database table exists when the + // refresh service first reads or writes the persisted token. + builder.Services.AddHostedService(); +} builder.Services.AddHostedService(); builder.Services.AddHostedService(); @@ -84,7 +92,7 @@ { Name = "SocialAgent", Description = "Social media monitoring and analytics agent. " + - "Monitors Mastodon, Bluesky, and other platforms for posts, mentions, and engagement. " + + "Monitors Mastodon, Bluesky, Threads, and other platforms for posts, mentions, and engagement. " + "Provides analytics on engagement trends, top posts, and follower insights.", Version = agentVersion, Skills = [.. SkillCatalog.AgentCardSkills], diff --git a/src/SocialAgent.Host/Services/DatabaseMigrationService.cs b/src/SocialAgent.Host/Services/DatabaseMigrationService.cs index b07bea1..c91b001 100644 --- a/src/SocialAgent.Host/Services/DatabaseMigrationService.cs +++ b/src/SocialAgent.Host/Services/DatabaseMigrationService.cs @@ -13,8 +13,38 @@ public async Task StartAsync(CancellationToken cancellationToken) using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(cancellationToken); + + // EnsureCreatedAsync is a no-op on databases that already exist, so any table + // added after the initial deployment must be patched in idempotently here. + // This is an interim approach until we adopt EF Core migrations. + await EnsureProviderTokensTableAsync(db, cancellationToken); + logger.LogInformation("Database ready"); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task EnsureProviderTokensTableAsync(SocialAgentDbContext db, CancellationToken ct) + { + var ddl = db.Database.IsNpgsql() + ? """ + CREATE TABLE IF NOT EXISTS "ProviderTokens" ( + "ProviderId" text NOT NULL PRIMARY KEY, + "AccessToken" text NOT NULL, + "ExpiresAt" timestamptz NOT NULL, + "UpdatedAt" timestamptz NOT NULL + ); + """ + : """ + CREATE TABLE IF NOT EXISTS "ProviderTokens" ( + "ProviderId" TEXT NOT NULL PRIMARY KEY, + "AccessToken" TEXT NOT NULL, + "ExpiresAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """; + + await db.Database.ExecuteSqlRawAsync(ddl, ct); + logger.LogDebug("ProviderTokens table is present"); + } } diff --git a/src/SocialAgent.Host/Services/ThreadsTokenRefreshService.cs b/src/SocialAgent.Host/Services/ThreadsTokenRefreshService.cs new file mode 100644 index 0000000..6a89d84 --- /dev/null +++ b/src/SocialAgent.Host/Services/ThreadsTokenRefreshService.cs @@ -0,0 +1,133 @@ +using Microsoft.Extensions.Options; +using SocialAgent.Core.Models; +using SocialAgent.Data.Repositories; +using SocialAgent.Providers.Threads; + +namespace SocialAgent.Host.Services; + +public class ThreadsTokenRefreshService( + IServiceScopeFactory scopeFactory, + ThreadsTokenStore tokenStore, + IOptions options, + ILogger logger) : BackgroundService +{ + private const string ProviderId = "threads"; + private readonly ThreadsOptions _options = options.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await SeedTokenFromDatabaseAsync(stoppingToken); + + var interval = TimeSpan.FromHours(Math.Max(1, _options.RefreshCheckIntervalHours)); + var threshold = TimeSpan.FromDays(Math.Max(1, _options.RefreshThresholdDays)); + + logger.LogInformation( + "Threads token refresh service running; check every {Interval}h, refresh when within {Threshold}d of expiry", + interval.TotalHours, threshold.TotalDays); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CheckAndRefreshAsync(threshold, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "Error during Threads token refresh check"); + } + + try + { + await Task.Delay(interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task CheckAndRefreshAsync(TimeSpan threshold, CancellationToken ct) + { + DateTimeOffset expiresAt; + try + { + (_, expiresAt) = tokenStore.Current; + } + catch (InvalidOperationException ex) + { + logger.LogWarning(ex, "Threads token unavailable; skipping refresh check"); + return; + } + + var remaining = expiresAt - DateTimeOffset.UtcNow; + if (remaining > threshold) + { + logger.LogDebug("Threads token has {Remaining} remaining; no refresh needed", remaining); + return; + } + + logger.LogInformation( + "Threads token expires at {Expiry:o} ({Remaining} remaining); refreshing", + expiresAt, remaining); + + using var scope = scopeFactory.CreateScope(); + var provider = scope.ServiceProvider.GetRequiredService(); + var refreshed = await provider.RefreshTokenAsync(ct); + if (refreshed is null) return; + + var repo = scope.ServiceProvider.GetRequiredService(); + await repo.UpsertProviderTokenAsync(new ProviderToken + { + ProviderId = ProviderId, + AccessToken = refreshed.Value.Token, + ExpiresAt = refreshed.Value.ExpiresAt, + UpdatedAt = DateTimeOffset.UtcNow + }, ct); + } + + private async Task SeedTokenFromDatabaseAsync(CancellationToken ct) + { + try + { + using var scope = scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var stored = await repo.GetProviderTokenAsync(ProviderId, ct); + + if (stored is not null && !string.IsNullOrEmpty(stored.AccessToken)) + { + tokenStore.Set(stored.AccessToken, stored.ExpiresAt); + logger.LogInformation( + "Loaded persisted Threads token (expires {Expiry:o}, last updated {Updated:o})", + stored.ExpiresAt, stored.UpdatedAt); + return; + } + + if (string.IsNullOrEmpty(_options.AccessToken)) + { + logger.LogWarning( + "Threads provider is enabled but no AccessToken is configured; provider calls will fail"); + return; + } + + // First-run: persist the configured token with a default 60-day lifetime so the + // refresh loop has an expiry to reason about. + var seededExpiry = DateTimeOffset.UtcNow.AddDays(60); + tokenStore.Set(_options.AccessToken, seededExpiry); + await repo.UpsertProviderTokenAsync(new ProviderToken + { + ProviderId = ProviderId, + AccessToken = _options.AccessToken, + ExpiresAt = seededExpiry, + UpdatedAt = DateTimeOffset.UtcNow + }, ct); + logger.LogInformation( + "Seeded Threads token from configuration; assumed expiry {Expiry:o}", + seededExpiry); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to seed Threads token from database"); + } + } +} diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj index fd48f64..6ee1974 100644 --- a/src/SocialAgent.Host/SocialAgent.Host.csproj +++ b/src/SocialAgent.Host/SocialAgent.Host.csproj @@ -2,7 +2,7 @@ SocialAgent.Host socialagent-host-001 - 1.3.4 + 1.4.0 @@ -26,5 +26,6 @@ + diff --git a/src/SocialAgent.Host/appsettings.json b/src/SocialAgent.Host/appsettings.json index 4b69de6..7fe6ce9 100644 --- a/src/SocialAgent.Host/appsettings.json +++ b/src/SocialAgent.Host/appsettings.json @@ -30,6 +30,14 @@ "ServiceUrl": "https://bsky.social", "Handle": "", "AppPassword": "" + }, + "Threads": { + "Enabled": false, + "BaseUrl": "https://graph.threads.net", + "AccessToken": "", + "IncludePostInsights": false, + "RefreshThresholdDays": 7, + "RefreshCheckIntervalHours": 24 } } }, diff --git a/src/SocialAgent.Providers.Threads/Models/ThreadsConversationItem.cs b/src/SocialAgent.Providers.Threads/Models/ThreadsConversationItem.cs new file mode 100644 index 0000000..93451be --- /dev/null +++ b/src/SocialAgent.Providers.Threads/Models/ThreadsConversationItem.cs @@ -0,0 +1,16 @@ +namespace SocialAgent.Providers.Threads; + +internal class ThreadsConversationItem +{ + public string Id { get; set; } = string.Empty; + public string? Text { get; set; } + public DateTimeOffset Timestamp { get; set; } + public string? Permalink { get; set; } + public string? Username { get; set; } + public int RepliesCount { get; set; } + public int RepostsCount { get; set; } + public int QuotesCount { get; set; } + public string? MediaType { get; set; } + public string? MediaUrl { get; set; } + public bool IsQuotePost { get; set; } +} diff --git a/src/SocialAgent.Providers.Threads/Models/ThreadsInsightsResponse.cs b/src/SocialAgent.Providers.Threads/Models/ThreadsInsightsResponse.cs new file mode 100644 index 0000000..22bd238 --- /dev/null +++ b/src/SocialAgent.Providers.Threads/Models/ThreadsInsightsResponse.cs @@ -0,0 +1,23 @@ +namespace SocialAgent.Providers.Threads; + +internal class ThreadsInsightsResponse +{ + public List? Data { get; set; } +} + +internal class ThreadsInsightMetric +{ + public string Name { get; set; } = string.Empty; + public List? Values { get; set; } + public ThreadsInsightTotalValue? TotalValue { get; set; } +} + +internal class ThreadsInsightValue +{ + public int Value { get; set; } +} + +internal class ThreadsInsightTotalValue +{ + public int Value { get; set; } +} diff --git a/src/SocialAgent.Providers.Threads/Models/ThreadsListResponse.cs b/src/SocialAgent.Providers.Threads/Models/ThreadsListResponse.cs new file mode 100644 index 0000000..06455a7 --- /dev/null +++ b/src/SocialAgent.Providers.Threads/Models/ThreadsListResponse.cs @@ -0,0 +1,19 @@ +namespace SocialAgent.Providers.Threads; + +internal class ThreadsListResponse +{ + public List? Data { get; set; } + public ThreadsPaging? Paging { get; set; } +} + +internal class ThreadsPaging +{ + public ThreadsCursors? Cursors { get; set; } + public string? Next { get; set; } +} + +internal class ThreadsCursors +{ + public string? Before { get; set; } + public string? After { get; set; } +} diff --git a/src/SocialAgent.Providers.Threads/Models/ThreadsRefreshResponse.cs b/src/SocialAgent.Providers.Threads/Models/ThreadsRefreshResponse.cs new file mode 100644 index 0000000..2e4c4ae --- /dev/null +++ b/src/SocialAgent.Providers.Threads/Models/ThreadsRefreshResponse.cs @@ -0,0 +1,8 @@ +namespace SocialAgent.Providers.Threads; + +internal class ThreadsRefreshResponse +{ + public string AccessToken { get; set; } = string.Empty; + public string TokenType { get; set; } = string.Empty; + public long ExpiresIn { get; set; } +} diff --git a/src/SocialAgent.Providers.Threads/Models/ThreadsUser.cs b/src/SocialAgent.Providers.Threads/Models/ThreadsUser.cs new file mode 100644 index 0000000..d0a186e --- /dev/null +++ b/src/SocialAgent.Providers.Threads/Models/ThreadsUser.cs @@ -0,0 +1,10 @@ +namespace SocialAgent.Providers.Threads; + +internal class ThreadsUser +{ + public string Id { get; set; } = string.Empty; + public string? Username { get; set; } + public string? Name { get; set; } + public string? ThreadsBiography { get; set; } + public string? ThreadsProfilePictureUrl { get; set; } +} diff --git a/src/SocialAgent.Providers.Threads/ServiceCollectionExtensions.cs b/src/SocialAgent.Providers.Threads/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..1f5b18b --- /dev/null +++ b/src/SocialAgent.Providers.Threads/ServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using SocialAgent.Core.Providers; + +namespace SocialAgent.Providers.Threads; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddThreadsProvider(this IServiceCollection services, IConfiguration configuration) + { + var section = configuration.GetSection("SocialAgent:Providers:Threads"); + services.Configure(section); + + var options = new ThreadsOptions(); + section.Bind(options); + + if (!options.Enabled) return services; + + services.AddSingleton(); + + services.AddHttpClient(client => + { + client.BaseAddress = new Uri(options.BaseUrl); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + + return services; + } +} diff --git a/src/SocialAgent.Providers.Threads/SocialAgent.Providers.Threads.csproj b/src/SocialAgent.Providers.Threads/SocialAgent.Providers.Threads.csproj new file mode 100644 index 0000000..fb9cfcf --- /dev/null +++ b/src/SocialAgent.Providers.Threads/SocialAgent.Providers.Threads.csproj @@ -0,0 +1,11 @@ + + + SocialAgent.Providers.Threads + + + + + + + + diff --git a/src/SocialAgent.Providers.Threads/ThreadsOptions.cs b/src/SocialAgent.Providers.Threads/ThreadsOptions.cs new file mode 100644 index 0000000..a4e554c --- /dev/null +++ b/src/SocialAgent.Providers.Threads/ThreadsOptions.cs @@ -0,0 +1,13 @@ +namespace SocialAgent.Providers.Threads; + +public class ThreadsOptions +{ + public bool Enabled { get; set; } + public string BaseUrl { get; set; } = "https://graph.threads.net"; + public string AccessToken { get; set; } = string.Empty; + + public bool IncludePostInsights { get; set; } + + public int RefreshThresholdDays { get; set; } = 7; + public int RefreshCheckIntervalHours { get; set; } = 24; +} diff --git a/src/SocialAgent.Providers.Threads/ThreadsProvider.cs b/src/SocialAgent.Providers.Threads/ThreadsProvider.cs new file mode 100644 index 0000000..e49c537 --- /dev/null +++ b/src/SocialAgent.Providers.Threads/ThreadsProvider.cs @@ -0,0 +1,232 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SocialAgent.Core.Models; +using SocialAgent.Core.Providers; + +namespace SocialAgent.Providers.Threads; + +public class ThreadsProvider( + HttpClient httpClient, + IOptions options, + ThreadsTokenStore tokenStore, + ILogger logger) : ISocialMediaProvider +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly ThreadsOptions _options = options.Value; + + public string ProviderId => "threads"; + public string ProviderName => "Threads"; + + public async Task ValidateConnectionAsync(CancellationToken ct = default) + { + try + { + var user = await GetAsync("/v1.0/me?fields=id", ct); + return user is not null && !string.IsNullOrEmpty(user.Id); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Threads connection validation failed"); + return false; + } + } + + public async Task GetProfileAsync(CancellationToken ct = default) + { + var user = await GetAsync( + "/v1.0/me?fields=id,username,name,threads_profile_picture_url,threads_biography", ct) + ?? throw new InvalidOperationException("Failed to get Threads user"); + + var followerCount = 0; + if (_options.IncludePostInsights && !string.IsNullOrEmpty(user.Id)) + { + try + { + var insights = await GetAsync( + $"/v1.0/{user.Id}/insights?metric=followers_count", ct); + followerCount = insights?.Data?.FirstOrDefault()?.TotalValue?.Value + ?? insights?.Data?.FirstOrDefault()?.Values?.FirstOrDefault()?.Value + ?? 0; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch Threads follower count (insights scope may be missing)"); + } + } + + return new SocialProfile + { + ProviderId = ProviderId, + Handle = user.Username ?? string.Empty, + DisplayName = user.Name, + Bio = user.ThreadsBiography, + AvatarUrl = user.ThreadsProfilePictureUrl, + FollowerCount = followerCount, + FollowingCount = 0, + PostCount = 0 + }; + } + + public async Task> GetRecentPostsAsync(DateTimeOffset? since = null, CancellationToken ct = default) + { + var url = "/v1.0/me/threads?fields=id,text,timestamp,permalink,replies_count,reposts_count,quotes_count,media_type,media_url,is_quote_post,username&limit=25"; + if (since is not null) + { + url += $"&since={Uri.EscapeDataString(since.Value.ToUniversalTime().ToString("o"))}"; + } + + var response = await GetAsync>(url, ct); + var items = response?.Data ?? []; + + var posts = new List(items.Count); + foreach (var item in items) + { + var likeCount = 0; + if (_options.IncludePostInsights) + { + try + { + var insights = await GetAsync( + $"/v1.0/{item.Id}/insights?metric=likes", ct); + likeCount = insights?.Data?.FirstOrDefault()?.Values?.FirstOrDefault()?.Value + ?? insights?.Data?.FirstOrDefault()?.TotalValue?.Value + ?? 0; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to fetch Threads insights for thread {Id}", item.Id); + } + } + posts.Add(MapToSocialPost(item, isOwn: true, likeCount)); + } + return posts; + } + + public async Task> GetNotificationsAsync(DateTimeOffset? since = null, CancellationToken ct = default) + { + var mentions = await SafeGetListAsync(BuildNotificationUrl("/v1.0/me/mentions", since), ct); + var replies = await SafeGetListAsync(BuildNotificationUrl("/v1.0/me/replies", since), ct); + + var seen = new HashSet(); + var notifications = new List(mentions.Count + replies.Count); + + foreach (var item in mentions) + { + if (!seen.Add($"mention:{item.Id}")) continue; + notifications.Add(MapToSocialNotification(item, "mention")); + } + foreach (var item in replies) + { + if (!seen.Add($"reply:{item.Id}")) continue; + notifications.Add(MapToSocialNotification(item, "reply")); + } + + return notifications; + } + + public async Task<(string Token, DateTimeOffset ExpiresAt)?> RefreshTokenAsync(CancellationToken ct = default) + { + var (currentToken, _) = tokenStore.Current; + var url = $"/refresh_access_token?grant_type=th_refresh_token&access_token={Uri.EscapeDataString(currentToken)}"; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + using var response = await httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + var refreshed = await response.Content.ReadFromJsonAsync(JsonOptions, ct); + if (refreshed is null || string.IsNullOrEmpty(refreshed.AccessToken)) + { + logger.LogWarning("Threads token refresh returned an empty response"); + return null; + } + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(refreshed.ExpiresIn); + tokenStore.Set(refreshed.AccessToken, expiresAt); + logger.LogInformation("Threads access token refreshed; new expiry {Expiry:o}", expiresAt); + return (refreshed.AccessToken, expiresAt); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to refresh Threads access token"); + return null; + } + } + + private static string BuildNotificationUrl(string path, DateTimeOffset? since) + { + var url = $"{path}?fields=id,text,timestamp,permalink,username,replies_count,reposts_count,quotes_count&limit=25"; + if (since is not null) + { + url += $"&since={Uri.EscapeDataString(since.Value.ToUniversalTime().ToString("o"))}"; + } + return url; + } + + private async Task> SafeGetListAsync(string url, CancellationToken ct) + { + try + { + var response = await GetAsync>(url, ct); + return response?.Data ?? []; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch Threads list at {Url} (scope may be missing)", url); + return []; + } + } + + private async Task GetAsync(string url, CancellationToken ct) + { + var (token, _) = tokenStore.Current; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(JsonOptions, ct); + } + + private SocialPost MapToSocialPost(ThreadsConversationItem item, bool isOwn, int likeCount) + { + return new SocialPost + { + Id = $"threads:{item.Id}", + ProviderId = ProviderId, + PlatformPostId = item.Id, + AuthorHandle = item.Username ?? "unknown", + Content = item.Text ?? string.Empty, + CreatedAt = item.Timestamp, + Url = item.Permalink, + LikeCount = likeCount, + RepostCount = item.RepostsCount + item.QuotesCount, + ReplyCount = item.RepliesCount, + IsOwnPost = isOwn + }; + } + + private SocialNotification MapToSocialNotification(ThreadsConversationItem item, string type) + { + return new SocialNotification + { + Id = $"threads:{type}:{item.Id}", + ProviderId = ProviderId, + PlatformNotificationId = $"{type}:{item.Id}", + Type = type, + FromHandle = item.Username ?? "unknown", + CreatedAt = item.Timestamp, + Content = item.Text, + // Threads has no native read marker. Notifications stay unread until a higher-layer + // mark-as-read mechanism is added. + IsRead = false + }; + } +} diff --git a/src/SocialAgent.Providers.Threads/ThreadsTokenStore.cs b/src/SocialAgent.Providers.Threads/ThreadsTokenStore.cs new file mode 100644 index 0000000..f738f62 --- /dev/null +++ b/src/SocialAgent.Providers.Threads/ThreadsTokenStore.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Options; + +namespace SocialAgent.Providers.Threads; + +public class ThreadsTokenStore(IOptions options) +{ + private readonly ThreadsOptions _options = options.Value; + private readonly object _lock = new(); + private string? _token; + private DateTimeOffset _expiresAt; + + public (string Token, DateTimeOffset ExpiresAt) Current + { + get + { + lock (_lock) + { + if (_token is null) + { + // Lazy fallback so the provider can operate before the refresh service has + // had a chance to seed from the database. The 60-day default mirrors a fresh + // Threads long-lived token; the refresh service will tighten the expiry once + // it has the persisted value. + _token = _options.AccessToken; + _expiresAt = DateTimeOffset.UtcNow.AddDays(60); + } + if (string.IsNullOrEmpty(_token)) + { + throw new InvalidOperationException( + "Threads access token is not configured. Set SocialAgent:Providers:Threads:AccessToken."); + } + return (_token, _expiresAt); + } + } + } + + public void Set(string token, DateTimeOffset expiresAt) + { + lock (_lock) + { + _token = token; + _expiresAt = expiresAt; + } + } +} diff --git a/tests/SocialAgent.Providers.Threads.Tests/SocialAgent.Providers.Threads.Tests.csproj b/tests/SocialAgent.Providers.Threads.Tests/SocialAgent.Providers.Threads.Tests.csproj new file mode 100644 index 0000000..d96a153 --- /dev/null +++ b/tests/SocialAgent.Providers.Threads.Tests/SocialAgent.Providers.Threads.Tests.csproj @@ -0,0 +1,15 @@ + + + SocialAgent.Providers.Threads.Tests + false + true + + + + + + + + + + diff --git a/tests/SocialAgent.Providers.Threads.Tests/ThreadsProviderTests.cs b/tests/SocialAgent.Providers.Threads.Tests/ThreadsProviderTests.cs new file mode 100644 index 0000000..129bb55 --- /dev/null +++ b/tests/SocialAgent.Providers.Threads.Tests/ThreadsProviderTests.cs @@ -0,0 +1,144 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace SocialAgent.Providers.Threads.Tests; + +[TestClass] +public class ThreadsOptionsTests +{ + [TestMethod] + public void ThreadsOptions_Defaults_AreSet() + { + var options = new ThreadsOptions(); + + Assert.IsFalse(options.Enabled); + Assert.AreEqual("https://graph.threads.net", options.BaseUrl); + Assert.AreEqual(string.Empty, options.AccessToken); + Assert.IsFalse(options.IncludePostInsights); + Assert.AreEqual(7, options.RefreshThresholdDays); + Assert.AreEqual(24, options.RefreshCheckIntervalHours); + } +} + +[TestClass] +public class ThreadsTokenStoreTests +{ + [TestMethod] + public void Current_FallsBackToConfiguredAccessToken_WhenNotSeeded() + { + var store = new ThreadsTokenStore(Options.Create(new ThreadsOptions + { + AccessToken = "configured-token" + })); + + var (token, expiresAt) = store.Current; + + Assert.AreEqual("configured-token", token); + Assert.IsTrue(expiresAt > DateTimeOffset.UtcNow.AddDays(59)); + Assert.IsTrue(expiresAt < DateTimeOffset.UtcNow.AddDays(61)); + } + + [TestMethod] + public void Current_ReturnsSetValue_WhenSeeded() + { + var store = new ThreadsTokenStore(Options.Create(new ThreadsOptions + { + AccessToken = "configured-token" + })); + var future = DateTimeOffset.UtcNow.AddDays(45); + + store.Set("refreshed-token", future); + var (token, expiresAt) = store.Current; + + Assert.AreEqual("refreshed-token", token); + Assert.AreEqual(future, expiresAt); + } + + [TestMethod] + public void Current_Throws_WhenNoTokenConfiguredAndNotSeeded() + { + var store = new ThreadsTokenStore(Options.Create(new ThreadsOptions())); + + Assert.Throws(() => _ = store.Current); + } +} + +[TestClass] +[TestCategory("Integration")] +public class ThreadsProviderIntegrationTests +{ + private static ThreadsProvider CreateProvider() + { + var accessToken = Environment.GetEnvironmentVariable("THREADS_ACCESS_TOKEN") + ?? throw new InvalidOperationException( + "Set THREADS_ACCESS_TOKEN environment variable to run integration tests"); + + var options = Options.Create(new ThreadsOptions + { + Enabled = true, + BaseUrl = "https://graph.threads.net", + AccessToken = accessToken + }); + + var tokenStore = new ThreadsTokenStore(options); + + var httpClient = new HttpClient { BaseAddress = new Uri(options.Value.BaseUrl) }; + + return new ThreadsProvider( + httpClient, + options, + tokenStore, + NullLogger.Instance); + } + + [TestMethod] + public async Task ValidateConnection_WithRealToken_ReturnsTrue() + { + var provider = CreateProvider(); + + var result = await provider.ValidateConnectionAsync(); + + Assert.IsTrue(result, "Connection to Threads should succeed with a valid token"); + } + + [TestMethod] + public async Task GetProfile_WithRealToken_ReturnsProfile() + { + var provider = CreateProvider(); + + var profile = await provider.GetProfileAsync(); + + Assert.IsNotNull(profile); + Assert.AreEqual("threads", profile.ProviderId); + Assert.IsFalse(string.IsNullOrEmpty(profile.Handle), "Handle should not be empty"); + Console.WriteLine($"Handle: {profile.Handle}"); + Console.WriteLine($"Display Name: {profile.DisplayName}"); + Console.WriteLine($"Followers: {profile.FollowerCount}"); + } + + [TestMethod] + public async Task GetRecentPosts_WithRealToken_ReturnsPosts() + { + var provider = CreateProvider(); + + var posts = await provider.GetRecentPostsAsync(); + + Assert.IsNotNull(posts); + Console.WriteLine($"Retrieved {posts.Count} posts"); + foreach (var post in posts.Take(3)) + { + Console.WriteLine($" [{post.PlatformPostId}] {post.Content?[..Math.Min(80, post.Content.Length)]}"); + } + } + + [TestMethod] + public async Task GetNotifications_WithRealToken_ReturnsList() + { + var provider = CreateProvider(); + + var notifications = await provider.GetNotificationsAsync(); + + Assert.IsNotNull(notifications); + Console.WriteLine($"Retrieved {notifications.Count} notifications"); + } +}