Files
Swarm/DysonNetwork.Sphere/ActivityPub/ActivityPubDeliveryService.cs
2025-12-28 18:08:35 +08:00

349 lines
12 KiB
C#

using DysonNetwork.Shared.Models;
using DysonNetwork.Sphere.ActivityPub;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace DysonNetwork.Sphere.ActivityPub;
public class ActivityPubDeliveryService(
AppDatabase db,
ActivityPubSignatureService signatureService,
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<ActivityPubDeliveryService> logger
)
{
private string Domain => configuration["ActivityPub:Domain"] ?? "localhost";
private HttpClient HttpClient => httpClientFactory.CreateClient();
public async Task<bool> SendAcceptActivityAsync(
Guid publisherId,
string followerActorUri,
string followActivityId
)
{
var publisher = await db.Publishers.FindAsync(publisherId);
if (publisher == null)
return false;
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var followerActor = await db.FediverseActors
.FirstOrDefaultAsync(a => a.Uri == followerActorUri);
if (followerActor?.InboxUri == null)
{
logger.LogWarning("Follower actor or inbox not found: {Uri}", followerActorUri);
return false;
}
var activity = new Dictionary<string, object>
{
["@context"] = "https://www.w3.org/ns/activitystreams",
["id"] = $"{actorUrl}/accepts/{Guid.NewGuid()}",
["type"] = "Accept",
["actor"] = actorUrl,
["object"] = followActivityId
};
return await SendActivityToInboxAsync(activity, followerActor.InboxUri, actorUrl);
}
public async Task<bool> SendFollowActivityAsync(
Guid publisherId,
string targetActorUri
)
{
var publisher = await db.Publishers.FindAsync(publisherId);
if (publisher == null)
return false;
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var targetActor = await GetOrFetchActorAsync(targetActorUri);
if (targetActor?.InboxUri == null)
{
logger.LogWarning("Target actor or inbox not found: {Uri}", targetActorUri);
return false;
}
var activity = new Dictionary<string, object>
{
["@context"] = "https://www.w3.org/ns/activitystreams",
["id"] = $"{actorUrl}/follows/{Guid.NewGuid()}",
["type"] = "Follow",
["actor"] = actorUrl,
["object"] = targetActorUri
};
await db.FediverseRelationships.AddAsync(new SnFediverseRelationship
{
IsLocalActor = true,
LocalPublisherId = publisher.Id,
ActorId = Guid.NewGuid(),
TargetActorId = targetActor.Id,
State = RelationshipState.Pending,
IsFollowing = true,
IsFollowedBy = false
});
await db.SaveChangesAsync();
return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl);
}
public async Task<bool> SendCreateActivityAsync(SnPost post)
{
var publisher = await db.Publishers.FindAsync(post.PublisherId);
if (publisher == null)
return false;
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var postUrl = $"https://{Domain}/posts/{post.Id}";
var activity = new Dictionary<string, object>
{
["@context"] = "https://www.w3.org/ns/activitystreams",
["id"] = $"{postUrl}/activity",
["type"] = "Create",
["actor"] = actorUrl,
["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(),
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
["cc"] = new[] { $"{actorUrl}/followers" },
["object"] = new Dictionary<string, object>
{
["id"] = postUrl,
["type"] = post.Type == PostType.Article ? "Article" : "Note",
["published"] = (post.PublishedAt ?? post.CreatedAt).ToDateTimeOffset(),
["attributedTo"] = actorUrl,
["content"] = post.Content ?? "",
["to"] = new[] { "https://www.w3.org/ns/activitystreams#Public" },
["cc"] = new[] { $"{actorUrl}/followers" },
["attachment"] = post.Attachments.Select(a => new Dictionary<string, object>
{
["type"] = "Document",
["mediaType"] = "image/jpeg",
["url"] = $"https://{Domain}/api/files/{a.Id}"
}).ToList<object>()
}
};
var followers = await GetRemoteFollowersAsync(publisher.Id);
var successCount = 0;
foreach (var follower in followers)
{
if (follower.InboxUri != null)
{
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
if (success)
successCount++;
}
}
logger.LogInformation("Sent Create activity to {Count}/{Total} followers",
successCount, followers.Count);
return successCount > 0;
}
public async Task<bool> SendLikeActivityAsync(
Guid postId,
Guid accountId,
string targetActorUri
)
{
var publisher = await db.Publishers
.Include(p => p.Members)
.Where(p => p.Members.Any(m => m.AccountId == accountId))
.FirstOrDefaultAsync();
if (publisher == null)
return false;
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var postUrl = $"https://{Domain}/posts/{postId}";
var targetActor = await GetOrFetchActorAsync(targetActorUri);
if (targetActor?.InboxUri == null)
return false;
var activity = new Dictionary<string, object>
{
["@context"] = "https://www.w3.org/ns/activitystreams",
["id"] = $"{actorUrl}/likes/{Guid.NewGuid()}",
["type"] = "Like",
["actor"] = actorUrl,
["object"] = postUrl
};
return await SendActivityToInboxAsync(activity, targetActor.InboxUri, actorUrl);
}
public async Task<bool> SendUndoActivityAsync(
string activityType,
string objectUri,
Guid publisherId
)
{
var publisher = await db.Publishers.FindAsync(publisherId);
if (publisher == null)
return false;
var actorUrl = $"https://{Domain}/activitypub/actors/{publisher.Name}";
var followers = await GetRemoteFollowersAsync(publisher.Id);
var activity = new Dictionary<string, object>
{
["@context"] = "https://www.w3.org/ns/activitystreams",
["id"] = $"{actorUrl}/undo/{Guid.NewGuid()}",
["type"] = "Undo",
["actor"] = actorUrl,
["object"] = new Dictionary<string, object>
{
["type"] = activityType,
["object"] = objectUri
}
};
var successCount = 0;
foreach (var follower in followers)
{
if (follower.InboxUri != null)
{
var success = await SendActivityToInboxAsync(activity, follower.InboxUri, actorUrl);
if (success)
successCount++;
}
}
return successCount > 0;
}
private async Task<bool> SendActivityToInboxAsync(
Dictionary<string, object> activity,
string inboxUrl,
string actorUri
)
{
try
{
var json = JsonSerializer.Serialize(activity);
var request = new HttpRequestMessage(HttpMethod.Post, inboxUrl);
request.Content = new StringContent(json, Encoding.UTF8, "application/activity+json");
request.Headers.Date = DateTimeOffset.UtcNow;
var signatureHeaders = await signatureService.SignOutgoingRequest(request, actorUri);
var signature = signatureHeaders;
var signatureString = $"keyId=\"{signature["keyId"]}\"," +
$"algorithm=\"{signature["algorithm"]}\"," +
$"headers=\"{signature["headers"]}\"," +
$"signature=\"{signature["signature"]}\"";
request.Headers.Add("Signature", signatureString);
request.Headers.Add("Host", new Uri(inboxUrl).Host);
request.Headers.Add("Content-Type", "application/activity+json");
var response = await HttpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Failed to send activity to {Inbox}. Status: {Status}, Response: {Response}",
inboxUrl, response.StatusCode, responseContent);
return false;
}
logger.LogInformation("Successfully sent activity to {Inbox}", inboxUrl);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Error sending activity to {Inbox}", inboxUrl);
return false;
}
}
private async Task<List<SnFediverseActor>> GetRemoteFollowersAsync(Guid publisherId)
{
return await db.FediverseRelationships
.Include(r => r.TargetActor)
.Where(r =>
r.LocalPublisherId == publisherId &&
r.IsFollowedBy &&
r.IsLocalActor)
.Select(r => r.TargetActor)
.ToListAsync();
}
private async Task<SnFediverseActor?> GetOrFetchActorAsync(string actorUri)
{
var actor = await db.FediverseActors
.FirstOrDefaultAsync(a => a.Uri == actorUri);
if (actor != null)
return actor;
try
{
var response = await HttpClient.GetAsync(actorUri);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
var actorData = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
if (actorData == null)
return null;
var domain = new Uri(actorUri).Host;
var instance = await db.FediverseInstances
.FirstOrDefaultAsync(i => i.Domain == domain);
if (instance == null)
{
instance = new SnFediverseInstance
{
Domain = domain,
Name = domain
};
db.FediverseInstances.Add(instance);
await db.SaveChangesAsync();
}
actor = new SnFediverseActor
{
Uri = actorUri,
Username = ExtractUsername(actorUri),
DisplayName = actorData.GetValueOrDefault("name")?.ToString(),
Bio = actorData.GetValueOrDefault("summary")?.ToString(),
InboxUri = actorData.GetValueOrDefault("inbox")?.ToString(),
OutboxUri = actorData.GetValueOrDefault("outbox")?.ToString(),
FollowersUri = actorData.GetValueOrDefault("followers")?.ToString(),
FollowingUri = actorData.GetValueOrDefault("following")?.ToString(),
AvatarUrl = actorData.GetValueOrDefault("icon")?.ToString(),
InstanceId = instance.Id
};
db.FediverseActors.Add(actor);
await db.SaveChangesAsync();
return actor;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to fetch actor: {Uri}", actorUri);
return null;
}
}
private string ExtractUsername(string actorUri)
{
return actorUri.Split('/').Last();
}
}