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; }