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");
+ }
+}