Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/bin/
**/obj/
**/.vs/
**/node_modules/
*.db
*.db-shm
*.db-wal
.git/
tests/
deploy/
11 changes: 11 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />

<!-- Logging -->
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.OpenTelemetry" Version="4.2.0" />

<!-- OpenTelemetry -->
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
<PackageVersion Include="Npgsql.OpenTelemetry" Version="10.0.2" />

<!-- Health Checks -->
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />

Expand Down
6 changes: 3 additions & 3 deletions deploy/k8s/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
30 changes: 28 additions & 2 deletions deploy/k8s/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: social-agent
namespace: social-agent
namespace: rockbot
labels:
app: social-agent
spec:
Expand All @@ -17,7 +17,7 @@ spec:
spec:
containers:
- name: social-agent
image: socialagent:latest
image: rockylhotka/socialagent:latest
ports:
- containerPort: 8080
env:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion deploy/k8s/namespace.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: social-agent
name: rockbot
3 changes: 2 additions & 1 deletion deploy/k8s/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion deploy/k8s/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v1
kind: Service
metadata:
name: social-agent
namespace: social-agent
namespace: rockbot
spec:
selector:
app: social-agent
Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.201",
"rollForward": "latestFeature"
"version": "10.0.100",
"rollForward": "latestMinor"
}
}
10 changes: 7 additions & 3 deletions src/SocialAgent.Data/Repositories/SocialDataRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,17 @@ public async Task UpsertNotificationsAsync(IEnumerable<SocialNotification> 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);
}
Expand Down
62 changes: 62 additions & 0 deletions src/SocialAgent.Host/Auth/ApiKeyAuthenticationHandler.cs
Original file line number Diff line number Diff line change
@@ -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<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler<ApiKeyAuthenticationOptions>(options, logger, encoder)
{
public const string SchemeName = "ApiKey";
private const string ApiKeyHeaderName = "X-Api-Key";

protected override Task<AuthenticateResult> 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 <key>
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;
}
22 changes: 22 additions & 0 deletions src/SocialAgent.Host/Auth/AuthServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -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<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName,
options =>
{
options.ApiKey = configuration["Authentication:ApiKey"] ?? string.Empty;
});

services.AddAuthorization();

return services;
}
}
32 changes: 32 additions & 0 deletions src/SocialAgent.Host/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@
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;

var builder = WebApplication.CreateBuilder(args);

builder.Configuration.AddUserSecrets<Program>(optional: true);

// Serilog
builder.Host.UseSerilog((context, configuration) => configuration
.ReadFrom.Configuration(context.Configuration));

// Agent infrastructure
builder.Services.AddSingleton<IStorage, MemoryStorage>();
builder.AddAgentApplicationOptions();
Expand All @@ -38,11 +46,35 @@
builder.Services.AddHostedService<DatabaseMigrationService>();
builder.Services.AddHostedService<SocialMediaPollingService>();

// 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<SkillRouterOptions>(options =>
{
options.Endpoint = llmSection["Endpoint"] ?? string.Empty;
options.ApiKey = llmSection["ApiKey"] ?? string.Empty;
options.ModelId = llmSection["ModelId"] ?? string.Empty;
});
builder.Services.AddHttpClient<SkillRouter>();
}

// 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());

Expand Down
97 changes: 97 additions & 0 deletions src/SocialAgent.Host/Routing/SkillRouter.cs
Original file line number Diff line number Diff line change
@@ -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<SkillRouterOptions> options, ILogger<SkillRouter> logger)
{
private readonly SkillRouterOptions _options = options.Value;

public record SkillDefinition(string Id, string Name, string Description);

public async Task<string?> RouteAsync(string userMessage, IReadOnlyList<SkillDefinition> 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<ChatCompletionResponse>(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<Choice>? Choices { get; set; }
}

private class Choice
{
[JsonPropertyName("message")]
public MessageContent? Message { get; set; }
}

private class MessageContent
{
[JsonPropertyName("content")]
public string? Content { get; set; }
}
}
Loading
Loading