diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3b3c1ae
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,10 @@
+**/bin/
+**/obj/
+**/.vs/
+**/node_modules/
+*.db
+*.db-shm
+*.db-wal
+.git/
+tests/
+deploy/
diff --git a/Directory.Packages.props b/Directory.Packages.props
index d351846..7d2c383 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/deploy/k8s/configmap.yaml b/deploy/k8s/configmap.yaml
index 6841f84..d3bd0fd 100644
--- a/deploy/k8s/configmap.yaml
+++ b/deploy/k8s/configmap.yaml
@@ -2,9 +2,9 @@ apiVersion: v1
kind: ConfigMap
metadata:
name: social-agent-config
- namespace: social-agent
+ namespace: rockbot
data:
mastodon-enabled: "true"
- mastodon-instance-url: "https://mastodon.social"
+ mastodon-instance-url: "https://fosstodon.org"
bluesky-enabled: "true"
- bluesky-handle: ""
+ bluesky-handle: "rocky.lhotka.net"
diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml
index 4752298..1e73f2b 100644
--- a/deploy/k8s/deployment.yaml
+++ b/deploy/k8s/deployment.yaml
@@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: social-agent
- namespace: social-agent
+ namespace: rockbot
labels:
app: social-agent
spec:
@@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: social-agent
- image: socialagent:latest
+ image: rockylhotka/socialagent:latest
ports:
- containerPort: 8080
env:
@@ -62,6 +62,32 @@ spec:
secretKeyRef:
name: social-agent-secrets
key: bluesky-app-password
+ - name: Authentication__ApiKey
+ valueFrom:
+ secretKeyRef:
+ name: social-agent-secrets
+ key: api-key
+ # LLM skill routing (from rockbot-secrets)
+ - name: LLM__Low__Endpoint
+ valueFrom:
+ secretKeyRef:
+ name: rockbot-secrets
+ key: LLM__Low__Endpoint
+ - name: LLM__Low__ApiKey
+ valueFrom:
+ secretKeyRef:
+ name: rockbot-secrets
+ key: LLM__Low__ApiKey
+ - name: LLM__Low__ModelId
+ valueFrom:
+ secretKeyRef:
+ name: rockbot-secrets
+ key: LLM__Low__ModelId
+ # OpenTelemetry
+ - name: OTEL_EXPORTER_OTLP_ENDPOINT
+ value: "http://alloy.loki.svc.cluster.local:4317"
+ - name: OTEL_SERVICE_NAME
+ value: "social-agent"
livenessProbe:
httpGet:
path: /health/live
diff --git a/deploy/k8s/namespace.yaml b/deploy/k8s/namespace.yaml
index 4b03762..ce98094 100644
--- a/deploy/k8s/namespace.yaml
+++ b/deploy/k8s/namespace.yaml
@@ -1,4 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
- name: social-agent
+ name: rockbot
diff --git a/deploy/k8s/secret.yaml b/deploy/k8s/secret.yaml
index 2ff43cf..836d0c5 100644
--- a/deploy/k8s/secret.yaml
+++ b/deploy/k8s/secret.yaml
@@ -2,9 +2,10 @@ apiVersion: v1
kind: Secret
metadata:
name: social-agent-secrets
- namespace: social-agent
+ namespace: rockbot
type: Opaque
stringData:
connection-string: "Host=postgres;Database=socialagent;Username=socialagent;Password=CHANGE_ME"
mastodon-access-token: "CHANGE_ME"
bluesky-app-password: "CHANGE_ME"
+ api-key: "CHANGE_ME"
diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml
index fbf19ce..b8d4168 100644
--- a/deploy/k8s/service.yaml
+++ b/deploy/k8s/service.yaml
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: social-agent
- namespace: social-agent
+ namespace: rockbot
spec:
selector:
app: social-agent
diff --git a/global.json b/global.json
index 8287d38..1e7fdfa 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "10.0.201",
- "rollForward": "latestFeature"
+ "version": "10.0.100",
+ "rollForward": "latestMinor"
}
}
diff --git a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs
index 903a364..af8ec48 100644
--- a/src/SocialAgent.Data/Repositories/SocialDataRepository.cs
+++ b/src/SocialAgent.Data/Repositories/SocialDataRepository.cs
@@ -56,13 +56,17 @@ public async Task UpsertNotificationsAsync(IEnumerable notif
{
foreach (var notification in notifications)
{
- var exists = await db.Notifications
- .AnyAsync(n => n.ProviderId == notification.ProviderId && n.PlatformNotificationId == notification.PlatformNotificationId, ct);
+ var existing = await db.Notifications
+ .FirstOrDefaultAsync(n => n.ProviderId == notification.ProviderId && n.PlatformNotificationId == notification.PlatformNotificationId, ct);
- if (!exists)
+ if (existing is null)
{
db.Notifications.Add(notification);
}
+ else
+ {
+ existing.IsRead = notification.IsRead;
+ }
}
await db.SaveChangesAsync(ct);
}
diff --git a/src/SocialAgent.Host/Auth/ApiKeyAuthenticationHandler.cs b/src/SocialAgent.Host/Auth/ApiKeyAuthenticationHandler.cs
new file mode 100644
index 0000000..a69042e
--- /dev/null
+++ b/src/SocialAgent.Host/Auth/ApiKeyAuthenticationHandler.cs
@@ -0,0 +1,62 @@
+using System.Security.Claims;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.Options;
+
+namespace SocialAgent.Host.Auth;
+
+public class ApiKeyAuthenticationHandler(
+ IOptionsMonitor options,
+ ILoggerFactory logger,
+ UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder)
+{
+ public const string SchemeName = "ApiKey";
+ private const string ApiKeyHeaderName = "X-Api-Key";
+
+ protected override Task HandleAuthenticateAsync()
+ {
+ var configuredKey = Options.ApiKey;
+ if (string.IsNullOrEmpty(configuredKey))
+ {
+ return Task.FromResult(AuthenticateResult.Fail("API key is not configured on the server."));
+ }
+
+ // Check X-Api-Key header first, then Authorization: ApiKey
+ string? providedKey = null;
+
+ if (Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyHeader))
+ {
+ providedKey = apiKeyHeader.ToString();
+ }
+ else if (Request.Headers.TryGetValue("Authorization", out var authHeader))
+ {
+ var value = authHeader.ToString();
+ if (value.StartsWith("ApiKey ", StringComparison.OrdinalIgnoreCase))
+ {
+ providedKey = value["ApiKey ".Length..].Trim();
+ }
+ }
+
+ if (string.IsNullOrEmpty(providedKey))
+ {
+ return Task.FromResult(AuthenticateResult.NoResult());
+ }
+
+ if (!string.Equals(providedKey, configuredKey, StringComparison.Ordinal))
+ {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid API key."));
+ }
+
+ var claims = new[] { new Claim(ClaimTypes.Name, "ApiKeyClient") };
+ var identity = new ClaimsIdentity(claims, Scheme.Name);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, Scheme.Name);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ }
+}
+
+public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
+{
+ public string ApiKey { get; set; } = string.Empty;
+}
diff --git a/src/SocialAgent.Host/Auth/AuthServiceExtensions.cs b/src/SocialAgent.Host/Auth/AuthServiceExtensions.cs
new file mode 100644
index 0000000..2df346d
--- /dev/null
+++ b/src/SocialAgent.Host/Auth/AuthServiceExtensions.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Authentication;
+
+namespace SocialAgent.Host.Auth;
+
+public static class AuthServiceExtensions
+{
+ public static IServiceCollection AddApiKeyAuthentication(
+ this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddAuthentication(ApiKeyAuthenticationHandler.SchemeName)
+ .AddScheme(
+ ApiKeyAuthenticationHandler.SchemeName,
+ options =>
+ {
+ options.ApiKey = configuration["Authentication:ApiKey"] ?? string.Empty;
+ });
+
+ services.AddAuthorization();
+
+ return services;
+ }
+}
diff --git a/src/SocialAgent.Host/Program.cs b/src/SocialAgent.Host/Program.cs
index fbcf556..0235674 100644
--- a/src/SocialAgent.Host/Program.cs
+++ b/src/SocialAgent.Host/Program.cs
@@ -3,10 +3,14 @@
using Microsoft.Agents.Hosting.A2A;
using Microsoft.Agents.Hosting.AspNetCore;
using Microsoft.Agents.Storage;
+using Serilog;
using SocialAgent.Analytics;
using SocialAgent.Data;
using SocialAgent.Host;
+using SocialAgent.Host.Auth;
+using SocialAgent.Host.Routing;
using SocialAgent.Host.Services;
+using SocialAgent.Host.Telemetry;
using SocialAgent.Providers.Bluesky;
using SocialAgent.Providers.Mastodon;
@@ -14,6 +18,10 @@
builder.Configuration.AddUserSecrets(optional: true);
+// Serilog
+builder.Host.UseSerilog((context, configuration) => configuration
+ .ReadFrom.Configuration(context.Configuration));
+
// Agent infrastructure
builder.Services.AddSingleton();
builder.AddAgentApplicationOptions();
@@ -38,11 +46,35 @@
builder.Services.AddHostedService();
builder.Services.AddHostedService();
+// Authentication (API key required in non-Development environments)
+builder.Services.AddApiKeyAuthentication(builder.Configuration);
+
+// LLM skill routing (optional — falls back to keyword matching if not configured)
+var llmSection = builder.Configuration.GetSection("LLM:Low");
+if (llmSection.Exists() && !string.IsNullOrEmpty(llmSection["ApiKey"]))
+{
+ builder.Services.Configure(options =>
+ {
+ options.Endpoint = llmSection["Endpoint"] ?? string.Empty;
+ options.ApiKey = llmSection["ApiKey"] ?? string.Empty;
+ options.ModelId = llmSection["ModelId"] ?? string.Empty;
+ });
+ builder.Services.AddHttpClient();
+}
+
+// OpenTelemetry
+builder.Services.AddSocialAgentTelemetry();
+
// Health checks
builder.Services.AddHealthChecks();
var app = builder.Build();
+app.UseSerilogRequestLogging();
+
+app.UseAuthentication();
+app.UseAuthorization();
+
// Map A2A endpoints (no auth required for development)
app.MapA2AEndpoints(requireAuth: !app.Environment.IsDevelopment());
diff --git a/src/SocialAgent.Host/Routing/SkillRouter.cs b/src/SocialAgent.Host/Routing/SkillRouter.cs
new file mode 100644
index 0000000..a99b800
--- /dev/null
+++ b/src/SocialAgent.Host/Routing/SkillRouter.cs
@@ -0,0 +1,97 @@
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Options;
+
+namespace SocialAgent.Host.Routing;
+
+public class SkillRouter(HttpClient httpClient, IOptions options, ILogger logger)
+{
+ private readonly SkillRouterOptions _options = options.Value;
+
+ public record SkillDefinition(string Id, string Name, string Description);
+
+ public async Task RouteAsync(string userMessage, IReadOnlyList skills, CancellationToken ct)
+ {
+ var skillList = new StringBuilder();
+ foreach (var skill in skills)
+ {
+ skillList.AppendLine($"- {skill.Id}: {skill.Name} — {skill.Description}");
+ }
+
+ var systemPrompt = $"""
+ You are a skill router. Given a user message, determine which skill best matches their intent.
+
+ Available skills:
+ {skillList}
+
+ Respond with ONLY the skill ID (e.g. "recent-mentions") that best matches.
+ If no skill matches, respond with "unknown".
+ Do not explain your reasoning. Just the skill ID.
+ """;
+
+ var requestBody = new
+ {
+ model = _options.ModelId,
+ messages = new object[]
+ {
+ new { role = "system", content = systemPrompt },
+ new { role = "user", content = userMessage }
+ },
+ max_tokens = 50,
+ temperature = 0.0
+ };
+
+ var request = new HttpRequestMessage(HttpMethod.Post, new Uri(new Uri(_options.Endpoint), "chat/completions"))
+ {
+ Content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json")
+ };
+ request.Headers.Add("Authorization", $"Bearer {_options.ApiKey}");
+
+ try
+ {
+ var response = await httpClient.SendAsync(request, ct);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadFromJsonAsync(ct);
+ var result = json?.Choices?.FirstOrDefault()?.Message?.Content?.Trim().ToLowerInvariant();
+
+ if (string.IsNullOrEmpty(result))
+ return null;
+
+ // Validate the result is actually a known skill ID
+ var matched = skills.FirstOrDefault(s => result.Contains(s.Id));
+ if (matched != null)
+ {
+ logger.LogInformation("LLM routed \"{Message}\" to skill {SkillId}", userMessage, matched.Id);
+ return matched.Id;
+ }
+
+ logger.LogWarning("LLM returned unrecognized skill \"{Result}\" for \"{Message}\"", result, userMessage);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "LLM skill routing failed for \"{Message}\", falling back to keyword matching", userMessage);
+ return null;
+ }
+ }
+
+ private class ChatCompletionResponse
+ {
+ [JsonPropertyName("choices")]
+ public List? Choices { get; set; }
+ }
+
+ private class Choice
+ {
+ [JsonPropertyName("message")]
+ public MessageContent? Message { get; set; }
+ }
+
+ private class MessageContent
+ {
+ [JsonPropertyName("content")]
+ public string? Content { get; set; }
+ }
+}
diff --git a/src/SocialAgent.Host/Routing/SkillRouterOptions.cs b/src/SocialAgent.Host/Routing/SkillRouterOptions.cs
new file mode 100644
index 0000000..9822609
--- /dev/null
+++ b/src/SocialAgent.Host/Routing/SkillRouterOptions.cs
@@ -0,0 +1,8 @@
+namespace SocialAgent.Host.Routing;
+
+public class SkillRouterOptions
+{
+ public string Endpoint { get; set; } = string.Empty;
+ public string ApiKey { get; set; } = string.Empty;
+ public string ModelId { get; set; } = string.Empty;
+}
diff --git a/src/SocialAgent.Host/SocialAgent.Host.csproj b/src/SocialAgent.Host/SocialAgent.Host.csproj
index 5226f85..67c1675 100644
--- a/src/SocialAgent.Host/SocialAgent.Host.csproj
+++ b/src/SocialAgent.Host/SocialAgent.Host.csproj
@@ -9,6 +9,13 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
all
+
+
+
+
+
+
+
diff --git a/src/SocialAgent.Host/SocialAgentHandler.cs b/src/SocialAgent.Host/SocialAgentHandler.cs
index ec9ac5b..f7a64e2 100644
--- a/src/SocialAgent.Host/SocialAgentHandler.cs
+++ b/src/SocialAgent.Host/SocialAgentHandler.cs
@@ -9,6 +9,7 @@
using SocialAgent.Core.Analytics;
using SocialAgent.Core.Providers;
using SocialAgent.Data.Repositories;
+using SocialAgent.Host.Routing;
namespace SocialAgent.Host;
@@ -87,13 +88,29 @@ public Task GetAgentCard(AgentCard initialCard)
return Task.FromResult(initialCard);
}
+ private static readonly List SkillDefinitions =
+ [
+ new("engagement-summary", "Engagement Summary", "Get a summary of recent engagement across all connected social media platforms"),
+ new("top-posts", "Top Posts", "Get most-engaged posts ranked by total engagement"),
+ new("recent-mentions", "Recent Mentions", "Get recent mentions and replies across all connected platforms"),
+ new("follower-insights", "Follower Insights", "See who engages most with your content"),
+ new("platform-comparison", "Platform Comparison", "Compare engagement metrics across all connected platforms"),
+ new("check-notifications", "Check Notifications", "Get unread notifications across all connected platforms"),
+ new("provider-status", "Provider Status", "Check connectivity and health of all configured providers")
+ ];
+
private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken ct)
{
var text = turnContext.Activity.Text?.Trim() ?? string.Empty;
- var skillId = ExtractSkillId(text);
using var scope = _scopeFactory.CreateScope();
+ // Try LLM routing first, fall back to keyword matching
+ var router = scope.ServiceProvider.GetService();
+ var skillId = router != null
+ ? await router.RouteAsync(text, SkillDefinitions, ct) ?? ExtractSkillId(text)
+ : ExtractSkillId(text);
+
var result = skillId switch
{
"engagement-summary" => await HandleEngagementSummaryAsync(scope.ServiceProvider, ct),
@@ -120,12 +137,39 @@ private Task OnEndOfConversationAsync(ITurnContext turnContext, ITurnState turnS
return Task.CompletedTask;
}
+ private static readonly Dictionary SkillKeywords = new()
+ {
+ ["engagement-summary"] = ["engagement summary", "engagement-summary"],
+ ["top-posts"] = ["top posts", "top-posts", "most engaged", "best posts"],
+ ["recent-mentions"] = ["recent mentions", "recent-mentions", "mentions"],
+ ["follower-insights"] = ["follower insights", "follower-insights", "followers", "engagers"],
+ ["platform-comparison"] = ["platform comparison", "platform-comparison", "compare platforms", "compare engagement"],
+ ["check-notifications"] = ["check notifications", "check-notifications", "notifications", "unread"],
+ ["provider-status"] = ["provider status", "provider-status", "connectivity", "health check"]
+ };
+
private static string ExtractSkillId(string text)
{
var lower = text.ToLowerInvariant();
- string[] skillIds = ["engagement-summary", "top-posts", "recent-mentions",
- "follower-insights", "platform-comparison", "check-notifications", "provider-status"];
- return skillIds.FirstOrDefault(s => lower.Contains(s)) ?? lower;
+
+ // Exact skill ID match first
+ foreach (var skill in SkillKeywords)
+ {
+ if (lower == skill.Key)
+ return skill.Key;
+ }
+
+ // Keyword match (longer keywords first to avoid partial matches)
+ foreach (var skill in SkillKeywords)
+ {
+ foreach (var keyword in skill.Value)
+ {
+ if (lower.Contains(keyword))
+ return skill.Key;
+ }
+ }
+
+ return lower;
}
private static async Task HandleEngagementSummaryAsync(IServiceProvider sp, CancellationToken ct)
diff --git a/src/SocialAgent.Host/Telemetry/TelemetryServiceExtensions.cs b/src/SocialAgent.Host/Telemetry/TelemetryServiceExtensions.cs
new file mode 100644
index 0000000..9dc4eb5
--- /dev/null
+++ b/src/SocialAgent.Host/Telemetry/TelemetryServiceExtensions.cs
@@ -0,0 +1,29 @@
+using Npgsql;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace SocialAgent.Host.Telemetry;
+
+public static class TelemetryServiceExtensions
+{
+ public const string ServiceName = "SocialAgent";
+
+ public static IServiceCollection AddSocialAgentTelemetry(this IServiceCollection services)
+ {
+ services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource.AddService(ServiceName))
+ .WithTracing(tracing => tracing
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddSource("Npgsql")
+ .AddOtlpExporter())
+ .WithMetrics(metrics => metrics
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddNpgsqlInstrumentation()
+ .AddOtlpExporter());
+
+ return services;
+ }
+}
diff --git a/src/SocialAgent.Host/appsettings.json b/src/SocialAgent.Host/appsettings.json
index cfda721..2e181ab 100644
--- a/src/SocialAgent.Host/appsettings.json
+++ b/src/SocialAgent.Host/appsettings.json
@@ -1,10 +1,18 @@
{
- "Logging": {
- "LogLevel": {
+ "Serilog": {
+ "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.OpenTelemetry" ],
+ "MinimumLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning",
- "SocialAgent": "Debug"
- }
+ "Override": {
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "SocialAgent": "Debug"
+ }
+ },
+ "WriteTo": [
+ { "Name": "Console" }
+ ],
+ "Enrich": [ "FromLogContext", "WithMachineName" ]
},
"SocialAgent": {
"PollingIntervalMinutes": 5,
@@ -23,6 +31,9 @@
}
}
},
+ "Authentication": {
+ "ApiKey": ""
+ },
"ConnectionStrings": {
"SocialAgent": "Data Source=socialagent.db"
}
diff --git a/src/SocialAgent.Providers.Bluesky/BlueskyProvider.cs b/src/SocialAgent.Providers.Bluesky/BlueskyProvider.cs
index e95df27..c35a918 100644
--- a/src/SocialAgent.Providers.Bluesky/BlueskyProvider.cs
+++ b/src/SocialAgent.Providers.Bluesky/BlueskyProvider.cs
@@ -140,7 +140,8 @@ private SocialNotification MapToSocialNotification(BlueskyNotificationItem notif
Type = MapNotificationType(notification.Reason),
FromHandle = notification.Author?.Handle ?? "unknown",
CreatedAt = notification.IndexedAt,
- Content = notification.Record?.Text
+ Content = notification.Record?.Text,
+ IsRead = notification.IsRead
};
}
diff --git a/src/SocialAgent.Providers.Bluesky/ServiceCollectionExtensions.cs b/src/SocialAgent.Providers.Bluesky/ServiceCollectionExtensions.cs
index c849e09..1153fe5 100644
--- a/src/SocialAgent.Providers.Bluesky/ServiceCollectionExtensions.cs
+++ b/src/SocialAgent.Providers.Bluesky/ServiceCollectionExtensions.cs
@@ -16,10 +16,11 @@ public static IServiceCollection AddBlueskyProvider(this IServiceCollection serv
if (!options.Enabled) return services;
- services.AddHttpClient(client =>
+ services.AddHttpClient(client =>
{
client.BaseAddress = new Uri(options.ServiceUrl);
});
+ services.AddSingleton(sp => sp.GetRequiredService());
return services;
}
diff --git a/src/SocialAgent.Providers.Mastodon/MastodonProvider.cs b/src/SocialAgent.Providers.Mastodon/MastodonProvider.cs
index b37ce30..adee5cf 100644
--- a/src/SocialAgent.Providers.Mastodon/MastodonProvider.cs
+++ b/src/SocialAgent.Providers.Mastodon/MastodonProvider.cs
@@ -79,12 +79,26 @@ public async Task> GetRecentPostsAsync(DateTimeOffset?
public async Task> GetNotificationsAsync(DateTimeOffset? since = null, CancellationToken ct = default)
{
ConfigureClient();
+
+ // Fetch the last-read marker to determine read state
+ string? lastReadId = null;
+ try
+ {
+ var markers = await httpClient.GetFromJsonAsync(
+ "/api/v1/markers?timeline[]=notifications", JsonOptions, ct);
+ lastReadId = markers?.Notifications?.LastReadId;
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to fetch Mastodon notification markers, all will be marked unread");
+ }
+
var url = "/api/v1/notifications?limit=40";
var notifications = await httpClient.GetFromJsonAsync>(url, JsonOptions, ct) ?? [];
return notifications
.Where(n => since is null || n.CreatedAt >= since)
- .Select(MapToSocialNotification)
+ .Select(n => MapToSocialNotification(n, lastReadId))
.ToList();
}
@@ -114,8 +128,14 @@ private SocialPost MapToSocialPost(MastodonStatus status, string ownerAcct, bool
};
}
- private SocialNotification MapToSocialNotification(MastodonNotification notification)
+ private SocialNotification MapToSocialNotification(MastodonNotification notification, string? lastReadId)
{
+ // Mastodon IDs are numeric strings — notification is read if its ID <= lastReadId
+ var isRead = lastReadId is not null
+ && long.TryParse(notification.Id, out var notifId)
+ && long.TryParse(lastReadId, out var readId)
+ && notifId <= readId;
+
return new SocialNotification
{
Id = $"mastodon:{notification.Id}",
@@ -125,7 +145,8 @@ private SocialNotification MapToSocialNotification(MastodonNotification notifica
FromHandle = notification.Account?.Acct ?? "unknown",
CreatedAt = notification.CreatedAt,
RelatedPostId = notification.Status?.Id is not null ? $"mastodon:{notification.Status.Id}" : null,
- Content = notification.Status?.Content
+ Content = notification.Status?.Content,
+ IsRead = isRead
};
}
diff --git a/src/SocialAgent.Providers.Mastodon/Models/MastodonMarker.cs b/src/SocialAgent.Providers.Mastodon/Models/MastodonMarker.cs
new file mode 100644
index 0000000..385c5eb
--- /dev/null
+++ b/src/SocialAgent.Providers.Mastodon/Models/MastodonMarker.cs
@@ -0,0 +1,13 @@
+namespace SocialAgent.Providers.Mastodon;
+
+internal class MastodonMarkersResponse
+{
+ public MastodonMarker? Notifications { get; set; }
+}
+
+internal class MastodonMarker
+{
+ public string LastReadId { get; set; } = string.Empty;
+ public int Version { get; set; }
+ public DateTimeOffset UpdatedAt { get; set; }
+}
diff --git a/src/SocialAgent.Providers.Mastodon/ServiceCollectionExtensions.cs b/src/SocialAgent.Providers.Mastodon/ServiceCollectionExtensions.cs
index e90da3c..31b026a 100644
--- a/src/SocialAgent.Providers.Mastodon/ServiceCollectionExtensions.cs
+++ b/src/SocialAgent.Providers.Mastodon/ServiceCollectionExtensions.cs
@@ -16,10 +16,11 @@ public static IServiceCollection AddMastodonProvider(this IServiceCollection ser
if (!options.Enabled) return services;
- services.AddHttpClient(client =>
+ services.AddHttpClient(client =>
{
client.BaseAddress = new Uri(options.InstanceUrl);
});
+ services.AddSingleton(sp => sp.GetRequiredService());
return services;
}