diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml index 9e68110..b1fc70d 100644 --- a/.github/workflows/dotnet-core.yml +++ b/.github/workflows/dotnet-core.yml @@ -1,10 +1,6 @@ -name: .NET Core +name: ASP.NET Core Build & Tests -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] +on: [push, pull_request] jobs: build: diff --git a/src/BirdsiteLive/Dockerfile b/Dockerfile similarity index 53% rename from src/BirdsiteLive/Dockerfile rename to Dockerfile index 7945589..0dcccda 100644 --- a/src/BirdsiteLive/Dockerfile +++ b/Dockerfile @@ -6,15 +6,12 @@ EXPOSE 80 EXPOSE 443 FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build -WORKDIR /src -COPY ["BirdsiteLive/BirdsiteLive.csproj", "BirdsiteLive/"] -RUN dotnet restore "BirdsiteLive/BirdsiteLive.csproj" -COPY . . -WORKDIR "/src/BirdsiteLive" -RUN dotnet build "BirdsiteLive.csproj" -c Release -o /app/build +COPY ./src/ ./src/ +RUN dotnet restore "/src/BirdsiteLive/BirdsiteLive.csproj" +RUN dotnet build "/src/BirdsiteLive/BirdsiteLive.csproj" -c Release -o /app/build FROM build AS publish -RUN dotnet publish "BirdsiteLive.csproj" -c Release -o /app/publish +RUN dotnet publish "/src/BirdsiteLive/BirdsiteLive.csproj" -c Release -o /app/publish FROM base AS final WORKDIR /app diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..77dee53 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3" + +networks: + birdsitelivenetwork: + external: false + +services: + server: + image: nicolasconstant/birdsitelive:latest + restart: always + container_name: birdsitelive + environment: + - Instance:Domain=domain.name + - Instance:AdminEmail=name@domain.ext + - Db:Type=postgres + - Db:Host=db + - Db:Name=birdsitelive + - Db:User=birdsitelive + - Db:Password=birdsitelive + - Twitter:ConsumerKey=twitter.api.key + - Twitter:ConsumerSecret=twitter.api.key + networks: + - birdsitelivenetwork + ports: + - "5000:80" + depends_on: + - db + + db: + image: postgres:9.6 + restart: always + environment: + - POSTGRES_USER=birdsitelive + - POSTGRES_PASSWORD=birdsitelive + - POSTGRES_DB=birdsitelive + networks: + - birdsitelivenetwork + volumes: + - ./postgres:/var/lib/postgresql/data \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/ApDeserializer.cs b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs index 1eaffa8..e577fbb 100644 --- a/src/BirdsiteLive.ActivityPub/ApDeserializer.cs +++ b/src/BirdsiteLive.ActivityPub/ApDeserializer.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using System; +using Newtonsoft.Json; namespace BirdsiteLive.ActivityPub { @@ -6,41 +7,48 @@ namespace BirdsiteLive.ActivityPub { public static Activity ProcessActivity(string json) { - var activity = JsonConvert.DeserializeObject(json); - switch (activity.type) + try { - case "Follow": - return JsonConvert.DeserializeObject(json); - case "Undo": - var a = JsonConvert.DeserializeObject(json); - if(a.apObject.type == "Follow") - return JsonConvert.DeserializeObject(json); - break; - case "Accept": - var accept = JsonConvert.DeserializeObject(json); - //var acceptType = JsonConvert.DeserializeObject(accept.apObject); - switch ((accept.apObject as dynamic).type.ToString()) - { - case "Follow": - var acceptFollow = new ActivityAcceptFollow() - { - type = accept.type, - id = accept.id, - actor = accept.actor, - context = accept.context, - apObject = new ActivityFollow() + var activity = JsonConvert.DeserializeObject(json); + switch (activity.type) + { + case "Follow": + return JsonConvert.DeserializeObject(json); + case "Undo": + var a = JsonConvert.DeserializeObject(json); + if(a.apObject.type == "Follow") + return JsonConvert.DeserializeObject(json); + break; + case "Accept": + var accept = JsonConvert.DeserializeObject(json); + //var acceptType = JsonConvert.DeserializeObject(accept.apObject); + switch ((accept.apObject as dynamic).type.ToString()) + { + case "Follow": + var acceptFollow = new ActivityAcceptFollow() { - id = (accept.apObject as dynamic).id?.ToString(), - type = (accept.apObject as dynamic).type?.ToString(), - actor = (accept.apObject as dynamic).actor?.ToString(), - context = (accept.apObject as dynamic).context?.ToString(), - apObject = (accept.apObject as dynamic).@object?.ToString() - } - }; - return acceptFollow; - break; - } - break; + type = accept.type, + id = accept.id, + actor = accept.actor, + context = accept.context, + apObject = new ActivityFollow() + { + id = (accept.apObject as dynamic).id?.ToString(), + type = (accept.apObject as dynamic).type?.ToString(), + actor = (accept.apObject as dynamic).actor?.ToString(), + context = (accept.apObject as dynamic).context?.ToString(), + apObject = (accept.apObject as dynamic).@object?.ToString() + } + }; + return acceptFollow; + break; + } + break; + } + } + catch (Exception e) + { + Console.WriteLine(e); } return null; diff --git a/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj b/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj index 8dfebd7..a690b63 100644 --- a/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj +++ b/src/BirdsiteLive.ActivityPub/BirdsiteLive.ActivityPub.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/BirdsiteLive.ActivityPub/Models/ActivityAcceptUndoFollow.cs b/src/BirdsiteLive.ActivityPub/Models/ActivityAcceptUndoFollow.cs new file mode 100644 index 0000000..9453f25 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/ActivityAcceptUndoFollow.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub +{ + public class ActivityAcceptUndoFollow : Activity + { + [JsonProperty("object")] + public ActivityUndoFollow apObject { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/ActivityCreateNote.cs b/src/BirdsiteLive.ActivityPub/Models/ActivityCreateNote.cs index d100b3a..fe14ac7 100644 --- a/src/BirdsiteLive.ActivityPub/Models/ActivityCreateNote.cs +++ b/src/BirdsiteLive.ActivityPub/Models/ActivityCreateNote.cs @@ -1,4 +1,5 @@ using System; +using BirdsiteLive.ActivityPub.Models; using Newtonsoft.Json; namespace BirdsiteLive.ActivityPub diff --git a/src/BirdsiteLive.ActivityPub/Models/Actor.cs b/src/BirdsiteLive.ActivityPub/Models/Actor.cs index 59ea71f..0552f25 100644 --- a/src/BirdsiteLive.ActivityPub/Models/Actor.cs +++ b/src/BirdsiteLive.ActivityPub/Models/Actor.cs @@ -1,4 +1,5 @@ -using BirdsiteLive.ActivityPub.Converters; +using System.Net; +using BirdsiteLive.ActivityPub.Converters; using Newtonsoft.Json; namespace BirdsiteLive.ActivityPub @@ -11,13 +12,16 @@ namespace BirdsiteLive.ActivityPub public string[] context { get; set; } = new[] { "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" }; public string id { get; set; } public string type { get; set; } + public string followers { get; set; } public string preferredUsername { get; set; } public string name { get; set; } public string summary { get; set; } public string url { get; set; } public string inbox { get; set; } + public bool? discoverable { get; set; } = true; public PublicKey publicKey { get; set; } public Image icon { get; set; } public Image image { get; set; } + public EndPoints endpoints { get; set; } } } diff --git a/src/BirdsiteLive.ActivityPub/Models/Attachment.cs b/src/BirdsiteLive.ActivityPub/Models/Attachment.cs new file mode 100644 index 0000000..d7b86dd --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/Attachment.cs @@ -0,0 +1,9 @@ +namespace BirdsiteLive.ActivityPub +{ + public class Attachment + { + public string type { get; set; } + public string mediaType { get; set; } + public string url { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/EndPoints.cs b/src/BirdsiteLive.ActivityPub/Models/EndPoints.cs new file mode 100644 index 0000000..8d671d5 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/EndPoints.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.ActivityPub +{ + public class EndPoints + { + public string sharedInbox { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/Followers.cs b/src/BirdsiteLive.ActivityPub/Models/Followers.cs new file mode 100644 index 0000000..85c44d2 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/Followers.cs @@ -0,0 +1,15 @@ +using BirdsiteLive.ActivityPub.Converters; +using Newtonsoft.Json; + +namespace BirdsiteLive.ActivityPub.Models +{ + public class Followers + { + [JsonProperty("@context")] + [JsonConverter(typeof(ContextArrayConverter))] + public string context { get; set; } = "https://www.w3.org/ns/activitystreams"; + + public string id { get; set; } + public string type { get; set; } = "OrderedCollection"; + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/Note.cs b/src/BirdsiteLive.ActivityPub/Models/Note.cs index cc3d561..fc6dc5b 100644 --- a/src/BirdsiteLive.ActivityPub/Models/Note.cs +++ b/src/BirdsiteLive.ActivityPub/Models/Note.cs @@ -1,9 +1,7 @@ -using System; -using System.Collections.Generic; -using BirdsiteLive.ActivityPub.Converters; +using BirdsiteLive.ActivityPub.Converters; using Newtonsoft.Json; -namespace BirdsiteLive.ActivityPub +namespace BirdsiteLive.ActivityPub.Models { public class Note { @@ -24,8 +22,8 @@ namespace BirdsiteLive.ActivityPub //public string conversation { get; set; } public string content { get; set; } //public Dictionary contentMap { get; set; } - public string[] attachment { get; set; } - public string[] tag { get; set; } + public Attachment[] attachment { get; set; } + public Tag[] tag { get; set; } //public Dictionary replies; } } \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/Tag.cs b/src/BirdsiteLive.ActivityPub/Models/Tag.cs new file mode 100644 index 0000000..0699c97 --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/Tag.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.ActivityPub.Models +{ + public class Tag { + public string type { get; set; } //Hashtag + public string href { get; set; } //https://mastodon.social/tags/app + public string name { get; set; } //#app + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Common/Settings/DbSettings.cs b/src/BirdsiteLive.Common/Settings/DbSettings.cs new file mode 100644 index 0000000..b70fba1 --- /dev/null +++ b/src/BirdsiteLive.Common/Settings/DbSettings.cs @@ -0,0 +1,11 @@ +namespace BirdsiteLive.Common.Settings +{ + public class DbSettings + { + public string Type { get; set; } + public string Host { get; set; } + public string Name { get; set; } + public string User { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Common/Settings/InstanceSettings.cs b/src/BirdsiteLive.Common/Settings/InstanceSettings.cs index aabe822..ba2a517 100644 --- a/src/BirdsiteLive.Common/Settings/InstanceSettings.cs +++ b/src/BirdsiteLive.Common/Settings/InstanceSettings.cs @@ -3,5 +3,6 @@ public class InstanceSettings { public string Domain { get; set; } + public string AdminEmail { get; set; } } } \ No newline at end of file diff --git a/src/BirdsiteLive.Common/Settings/TwitterSettings.cs b/src/BirdsiteLive.Common/Settings/TwitterSettings.cs index d0970f2..3e9095a 100644 --- a/src/BirdsiteLive.Common/Settings/TwitterSettings.cs +++ b/src/BirdsiteLive.Common/Settings/TwitterSettings.cs @@ -4,7 +4,5 @@ { public string ConsumerKey { get; set; } public string ConsumerSecret { get; set; } - public string AccessToken { get; set; } - public string AccessTokenSecret { get; set; } } } \ No newline at end of file diff --git a/src/BirdsiteLive.Common/Structs/DbTypes.cs b/src/BirdsiteLive.Common/Structs/DbTypes.cs new file mode 100644 index 0000000..767f0f3 --- /dev/null +++ b/src/BirdsiteLive.Common/Structs/DbTypes.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.Common.Structs +{ + public struct DbTypes + { + public static string Postgres = "postgres"; + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/ActivityPubService.cs b/src/BirdsiteLive.Domain/ActivityPubService.cs index d30daf8..bbabf07 100644 --- a/src/BirdsiteLive.Domain/ActivityPubService.cs +++ b/src/BirdsiteLive.Domain/ActivityPubService.cs @@ -1,9 +1,13 @@ using System; +using System.Linq; using System.Net; using System.Net.Http; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.Common.Settings; using Newtonsoft.Json; using Org.BouncyCastle.Bcpg; @@ -13,16 +17,20 @@ namespace BirdsiteLive.Domain { Task GetUser(string objectId); Task PostDataAsync(T data, string targetHost, string actorUrl, string inbox = null); + Task PostNewNoteActivity(Note note, string username, string noteId, string targetHost, + string targetInbox); } public class ActivityPubService : IActivityPubService { + private readonly InstanceSettings _instanceSettings; private readonly ICryptoService _cryptoService; #region Ctor - public ActivityPubService(ICryptoService cryptoService) + public ActivityPubService(ICryptoService cryptoService, InstanceSettings instanceSettings) { _cryptoService = cryptoService; + _instanceSettings = instanceSettings; } #endregion @@ -37,6 +45,40 @@ namespace BirdsiteLive.Domain } } + public async Task PostNewNoteActivity(Note note, string username, string noteId, string targetHost, string targetInbox) + { + //var username = "gra"; + var actor = $"https://{_instanceSettings.Domain}/users/{username}"; + //var targetHost = "mastodon.technology"; + //var target = $"{targetHost}/users/testtest"; + //var inbox = $"/users/testtest/inbox"; + + //var noteGuid = Guid.NewGuid(); + var noteUri = $"https://{_instanceSettings.Domain}/users/{username}/statuses/{noteId}"; + + //var noteUrl = $"https://{_instanceSettings.Domain}/@{username}/{noteId}"; + //var to = $"{actor}/followers"; + //var apPublic = "https://www.w3.org/ns/activitystreams#Public"; + + var now = DateTime.UtcNow; + var nowString = now.ToString("s") + "Z"; + + var noteActivity = new ActivityCreateNote() + { + context = "https://www.w3.org/ns/activitystreams", + id = $"{noteUri}/activity", + type = "Create", + actor = actor, + published = nowString, + + to = note.to, + cc = note.cc, + apObject = note + }; + + return await PostDataAsync(noteActivity, targetHost, actor, targetInbox); + } + public async Task PostDataAsync(T data, string targetHost, string actorUrl, string inbox = null) { var usedInbox = $"/inbox"; @@ -47,20 +89,22 @@ namespace BirdsiteLive.Domain var date = DateTime.UtcNow.ToUniversalTime(); var httpDate = date.ToString("r"); - var signature = _cryptoService.SignAndGetSignatureHeader(date, actorUrl, targetHost, usedInbox); - + var digest = _cryptoService.ComputeSha256Hash(json); + + var signature = _cryptoService.SignAndGetSignatureHeader(date, actorUrl, targetHost, digest, usedInbox); var client = new HttpClient(); var httpRequestMessage = new HttpRequestMessage { Method = HttpMethod.Post, - RequestUri = new Uri($"https://{targetHost}/{usedInbox}"), + RequestUri = new Uri($"https://{targetHost}{usedInbox}"), Headers = { {"Host", targetHost}, {"Date", httpDate}, - {"Signature", signature} + {"Signature", signature}, + {"Digest", $"SHA-256={digest}"} }, Content = new StringContent(json, Encoding.UTF8, "application/ld+json") }; @@ -68,5 +112,7 @@ namespace BirdsiteLive.Domain var response = await client.SendAsync(httpRequestMessage); return response.StatusCode; } + + } } \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj b/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj index 50eb4d2..cb89578 100644 --- a/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj +++ b/src/BirdsiteLive.Domain/BirdsiteLive.Domain.csproj @@ -8,6 +8,7 @@ + diff --git a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs new file mode 100644 index 0000000..ac657e4 --- /dev/null +++ b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessFollowUser.cs @@ -0,0 +1,53 @@ +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; + +namespace BirdsiteLive.Domain.BusinessUseCases +{ + public interface IProcessFollowUser + { + Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox); + } + + public class ProcessFollowUser : IProcessFollowUser + { + private readonly IFollowersDal _followerDal; + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public ProcessFollowUser(IFollowersDal followerDal, ITwitterUserDal twitterUserDal) + { + _followerDal = followerDal; + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername, string followerInbox, string sharedInbox) + { + // Get Follower and Twitter Users + var follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); + if (follower == null) + { + await _followerDal.CreateFollowerAsync(followerUsername, followerDomain, followerInbox, sharedInbox); + follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); + } + + var twitterUser = await _twitterUserDal.GetTwitterUserAsync(twitterUsername); + if (twitterUser == null) + { + await _twitterUserDal.CreateTwitterUserAsync(twitterUsername, -1); + twitterUser = await _twitterUserDal.GetTwitterUserAsync(twitterUsername); + } + + // Update Follower + var twitterUserId = twitterUser.Id; + if(!follower.Followings.Contains(twitterUserId)) + follower.Followings.Add(twitterUserId); + + if(!follower.FollowingsSyncStatus.ContainsKey(twitterUserId)) + follower.FollowingsSyncStatus.Add(twitterUserId, -1); + + // Save Follower + await _followerDal.UpdateFollowerAsync(follower); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs new file mode 100644 index 0000000..4d5483a --- /dev/null +++ b/src/BirdsiteLive.Domain/BusinessUseCases/ProcessUnfollowUser.cs @@ -0,0 +1,45 @@ +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; + +namespace BirdsiteLive.Domain.BusinessUseCases +{ + public interface IProcessUndoFollowUser + { + Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername); + } + + public class ProcessUndoFollowUser : IProcessUndoFollowUser + { + private readonly IFollowersDal _followerDal; + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public ProcessUndoFollowUser(IFollowersDal followerDal, ITwitterUserDal twitterUserDal) + { + _followerDal = followerDal; + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ExecuteAsync(string followerUsername, string followerDomain, string twitterUsername) + { + // Get Follower and Twitter Users + var follower = await _followerDal.GetFollowerAsync(followerUsername, followerDomain); + if (follower == null) return; + + var twitterUser = await _twitterUserDal.GetTwitterUserAsync(twitterUsername); + if (twitterUser == null) return; + + // Update Follower + var twitterUserId = twitterUser.Id; + if (follower.Followings.Contains(twitterUserId)) + follower.Followings.Remove(twitterUserId); + + if (follower.FollowingsSyncStatus.ContainsKey(twitterUserId)) + follower.FollowingsSyncStatus.Remove(twitterUserId); + + // Save Follower + await _followerDal.UpdateFollowerAsync(follower); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/CryptoService.cs b/src/BirdsiteLive.Domain/CryptoService.cs index ed62a59..01e7d63 100644 --- a/src/BirdsiteLive.Domain/CryptoService.cs +++ b/src/BirdsiteLive.Domain/CryptoService.cs @@ -1,4 +1,5 @@ using System; +using System.Security.Cryptography; using System.Text; using BirdsiteLive.Domain.Factories; @@ -7,7 +8,8 @@ namespace BirdsiteLive.Domain public interface ICryptoService { string GetUserPem(string id); - string SignAndGetSignatureHeader(DateTime date, string actor, string host, string inbox = null); + string SignAndGetSignatureHeader(DateTime date, string actor, string host, string digest, string inbox); + string ComputeSha256Hash(string data); } public class CryptoService : ICryptoService @@ -33,7 +35,7 @@ namespace BirdsiteLive.Domain /// in the form of https://domain.io/actor /// in the form of domain.io /// - public string SignAndGetSignatureHeader(DateTime date, string actor, string targethost, string inbox = null) + public string SignAndGetSignatureHeader(DateTime date, string actor, string targethost, string digest, string inbox) { var usedInbox = "/inbox"; if (!string.IsNullOrWhiteSpace(inbox)) @@ -41,13 +43,24 @@ namespace BirdsiteLive.Domain var httpDate = date.ToString("r"); - var signedString = $"(request-target): post {usedInbox}\nhost: {targethost}\ndate: {httpDate}"; + var signedString = $"(request-target): post {usedInbox}\nhost: {targethost}\ndate: {httpDate}\ndigest: SHA-256={digest}"; var signedStringBytes = Encoding.UTF8.GetBytes(signedString); var signature = _magicKeyFactory.GetMagicKey().Sign(signedStringBytes); var sig64 = Convert.ToBase64String(signature); - var header = "keyId=\"" + actor + "\",headers=\"(request-target) host date\",signature=\"" + sig64 + "\""; + var header = "keyId=\"" + actor + "\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"" + sig64 + "\""; return header; } + + public string ComputeSha256Hash(string data) + { + // Create a SHA256 + using (SHA256 sha256Hash = SHA256.Create()) + { + // ComputeHash - returns byte array + byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(data)); + return Convert.ToBase64String(bytes); + } + } } } \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/StatusService.cs b/src/BirdsiteLive.Domain/StatusService.cs new file mode 100644 index 0000000..54a95ca --- /dev/null +++ b/src/BirdsiteLive.Domain/StatusService.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.Domain.Tools; +using BirdsiteLive.Twitter.Models; +using Tweetinvi.Models; +using Tweetinvi.Models.Entities; + +namespace BirdsiteLive.Domain +{ + public interface IStatusService + { + Note GetStatus(string username, ExtractedTweet tweet); + } + + public class StatusService : IStatusService + { + private readonly InstanceSettings _instanceSettings; + private readonly IStatusExtractor _statusExtractor; + + #region Ctor + public StatusService(InstanceSettings instanceSettings, IStatusExtractor statusExtractor) + { + _instanceSettings = instanceSettings; + _statusExtractor = statusExtractor; + } + #endregion + + public Note GetStatus(string username, ExtractedTweet tweet) + { + var actorUrl = $"https://{_instanceSettings.Domain}/users/{username}"; + var noteId = $"https://{_instanceSettings.Domain}/users/{username}/statuses/{tweet.Id}"; + var noteUrl = $"https://{_instanceSettings.Domain}/@{username}/{tweet.Id}"; + + var to = $"{actorUrl}/followers"; + var apPublic = "https://www.w3.org/ns/activitystreams#Public"; + + var extractedTags = _statusExtractor.ExtractTags(tweet.MessageContent); + + string inReplyTo = null; + if (tweet.InReplyToStatusId != default) + inReplyTo = $"https://{_instanceSettings.Domain}/users/{tweet.InReplyToAccount}/statuses/{tweet.InReplyToStatusId}"; + + var note = new Note + { + //id = $"{noteId}/activity", + id = $"{noteId}", + + published = tweet.CreatedAt.ToString("s") + "Z", + url = noteUrl, + attributedTo = actorUrl, + + inReplyTo = inReplyTo, + //to = new [] {to}, + //cc = new [] { apPublic }, + + to = new[] { to }, + //cc = new[] { apPublic }, + cc = new string[0], + + sensitive = false, + content = $"

{extractedTags.content}

", + attachment = Convert(tweet.Media), + tag = extractedTags.tags + }; + + + return note; + } + + private Attachment[] Convert(ExtractedMedia[] media) + { + if(media == null) return new Attachment[0]; + return media.Select(x => + { + return new Attachment + { + type = "Document", + url = x.Url, + mediaType = x.MediaType + }; + }).ToArray(); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/Tools/StatusExtractor.cs b/src/BirdsiteLive.Domain/Tools/StatusExtractor.cs new file mode 100644 index 0000000..d78bf6e --- /dev/null +++ b/src/BirdsiteLive.Domain/Tools/StatusExtractor.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.Common.Settings; + +namespace BirdsiteLive.Domain.Tools +{ + public interface IStatusExtractor + { + (string content, Tag[] tags) ExtractTags(string messageContent); + } + + public class StatusExtractor : IStatusExtractor + { + private readonly Regex _hastagRegex = new Regex(@"\W(\#[a-zA-Z0-9_ー]+\b)(?!;)"); + //private readonly Regex _hastagRegex = new Regex(@"#\w+"); + //private readonly Regex _hastagRegex = new Regex(@"(?<=[\s>]|^)#(\w*[a-zA-Z0-9_ー]+\w*)\b(?!;)"); + //private readonly Regex _hastagRegex = new Regex(@"(?<=[\s>]|^)#(\w*[a-zA-Z0-9_ー]+)\b(?!;)"); + + private readonly Regex _mentionRegex = new Regex(@"\W(\@[a-zA-Z0-9_ー]+\b)(?!;)"); + //private readonly Regex _mentionRegex = new Regex(@"@\w+"); + //private readonly Regex _mentionRegex = new Regex(@"(?<=[\s>]|^)@(\w*[a-zA-Z0-9_ー]+\w*)\b(?!;)"); + //private readonly Regex _mentionRegex = new Regex(@"(?<=[\s>]|^)@(\w*[a-zA-Z0-9_ー]+)\b(?!;)"); + + private readonly Regex _urlRegex = new Regex(@"((http|ftp|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)"); + private readonly InstanceSettings _instanceSettings; + + #region Ctor + public StatusExtractor(InstanceSettings instanceSettings) + { + _instanceSettings = instanceSettings; + } + #endregion + + public (string content, Tag[] tags) ExtractTags(string messageContent) + { + var tags = new List(); + messageContent = $" {messageContent} "; + + // Replace return lines + messageContent = Regex.Replace(messageContent, @"\r\n\r\n?|\n\n", "

"); + messageContent = Regex.Replace(messageContent, @"\r\n?|\n", "
"); + + // Extract Urls + var urlMatch = _urlRegex.Matches(messageContent); + foreach (Match m in urlMatch) + { + var url = m.ToString().Replace("\n", string.Empty).Trim(); + + var protocol = "https://"; + if (url.StartsWith("http://")) protocol = "http://"; + else if (url.StartsWith("ftp://")) protocol = "ftp://"; + + var truncatedUrl = url.Replace(protocol, string.Empty); + + if (truncatedUrl.StartsWith("www.")) + { + protocol += "www."; + truncatedUrl = truncatedUrl.Replace("www.", string.Empty); + } + + var firstPart = truncatedUrl; + var secondPart = string.Empty; + + if (truncatedUrl.Length > 30) + { + firstPart = truncatedUrl.Substring(0, 30); + secondPart = truncatedUrl.Substring(30); + } + + messageContent = Regex.Replace(messageContent, m.ToString(), + $@" {protocol}{firstPart}{secondPart}"); + } + + // Extract Hashtags + var hashtagMatch = OrderByLength(_hastagRegex.Matches(messageContent)); + foreach (Match m in hashtagMatch) + { + var tag = m.ToString().Replace("#", string.Empty).Replace("\n", string.Empty).Trim(); + var url = $"https://{_instanceSettings.Domain}/tags/{tag}"; + + tags.Add(new Tag + { + name = $"#{tag}", + href = url, + type = "Hashtag" + }); + + messageContent = Regex.Replace(messageContent, m.ToString(), + $@" #{tag}"); + } + + // Extract Mentions + var mentionMatch = OrderByLength(_mentionRegex.Matches(messageContent)); + foreach (Match m in mentionMatch) + { + var mention = m.ToString().Replace("@", string.Empty).Replace("\n", string.Empty).Trim(); + var url = $"https://{_instanceSettings.Domain}/users/{mention}"; + var name = $"@{mention}@{_instanceSettings.Domain}"; + + tags.Add(new Tag + { + name = name, + href = url, + type = "Mention" + }); + + messageContent = Regex.Replace(messageContent, m.ToString(), + $@" @{mention}"); + } + + // Clean up return lines + messageContent = Regex.Replace(messageContent, @"

", "

"); + messageContent = Regex.Replace(messageContent, @"
", "
"); + + return (messageContent.Trim(), tags.ToArray()); + } + + private IEnumerable OrderByLength(MatchCollection matches) + { + var result = new List(); + + foreach (Match m in matches) result.Add(m); + result = result.OrderByDescending(x => x.Length).ToList(); + + return result; + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/UserService.cs b/src/BirdsiteLive.Domain/UserService.cs index d084e97..221ff4f 100644 --- a/src/BirdsiteLive.Domain/UserService.cs +++ b/src/BirdsiteLive.Domain/UserService.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using BirdsiteLive.ActivityPub; using BirdsiteLive.Common.Settings; using BirdsiteLive.Cryptography; +using BirdsiteLive.Domain.BusinessUseCases; using BirdsiteLive.Twitter.Models; using Tweetinvi.Core.Exceptions; using Tweetinvi.Models; @@ -17,22 +18,28 @@ namespace BirdsiteLive.Domain public interface IUserService { Actor GetUser(TwitterUser twitterUser); - Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity); - Note GetStatus(TwitterUser user, ITweet tweet); + Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity, string body); + Task UndoFollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityUndoFollow activity, string body); } public class UserService : IUserService { + private readonly IProcessFollowUser _processFollowUser; + private readonly IProcessUndoFollowUser _processUndoFollowUser; + + private readonly InstanceSettings _instanceSettings; private readonly ICryptoService _cryptoService; private readonly IActivityPubService _activityPubService; - private readonly string _host; #region Ctor - public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService) + public UserService(InstanceSettings instanceSettings, ICryptoService cryptoService, IActivityPubService activityPubService, IProcessFollowUser processFollowUser, IProcessUndoFollowUser processUndoFollowUser) { + _instanceSettings = instanceSettings; _cryptoService = cryptoService; _activityPubService = activityPubService; - _host = $"https://{instanceSettings.Domain.Replace("https://",string.Empty).Replace("http://", string.Empty).TrimEnd('/')}"; + _processFollowUser = processFollowUser; + _processUndoFollowUser = processUndoFollowUser; + //_host = $"https://{instanceSettings.Domain.Replace("https://",string.Empty).Replace("http://", string.Empty).TrimEnd('/')}"; } #endregion @@ -40,17 +47,18 @@ namespace BirdsiteLive.Domain { var user = new Actor { - id = $"{_host}/users/{twitterUser.Acct}", - type = "Person", + id = $"https://{_instanceSettings.Domain}/users/{twitterUser.Acct}", + type = "Service", //Person Service + followers = $"https://{_instanceSettings.Domain}/users/{twitterUser.Acct}/followers", preferredUsername = twitterUser.Acct, name = twitterUser.Name, - inbox = $"{_host}/users/{twitterUser.Acct}/inbox", + inbox = $"https://{_instanceSettings.Domain}/users/{twitterUser.Acct}/inbox", summary = twitterUser.Description, - url = $"{_host}/@{twitterUser.Acct}", + url = $"https://{_instanceSettings.Domain}/@{twitterUser.Acct}", publicKey = new PublicKey() { - id = $"{_host}/users/{twitterUser.Acct}#main-key", - owner = $"{_host}/users/{twitterUser.Acct}", + id = $"https://{_instanceSettings.Domain}/users/{twitterUser.Acct}#main-key", + owner = $"https://{_instanceSettings.Domain}/users/{twitterUser.Acct}", publicKeyPem = _cryptoService.GetUserPem(twitterUser.Acct) }, icon = new Image @@ -62,53 +70,36 @@ namespace BirdsiteLive.Domain { mediaType = "image/jpeg", url = twitterUser.ProfileBannerURL + }, + endpoints = new EndPoints + { + sharedInbox = $"https://{_instanceSettings.Domain}/inbox" } }; return user; } - public Note GetStatus(TwitterUser user, ITweet tweet) - { - var actor = GetUser(user); - - var actorUrl = $"{_host}/users/{user.Acct}"; - var noteId = $"{_host}/users/{user.Acct}/statuses/{tweet.Id}"; - var noteUrl = $"{_host}/@{user.Acct}/{tweet.Id}"; - - var to = $"{actor}/followers"; - var apPublic = "https://www.w3.org/ns/activitystreams#Public"; - - var note = new Note - { - id = $"{noteId}/activity", - - published = tweet.CreatedAt.ToString("s") + "Z", - url = noteUrl, - attributedTo = actorUrl, - - //to = new [] {to}, - //cc = new [] { apPublic }, - - to = new[] { apPublic }, - cc = new[] { to }, - - sensitive = false, - content = $"

{tweet.Text}

", - attachment = new string[0], - tag = new string[0] - }; - return note; - } - - public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity) + public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity, string body) { // Validate - if (!await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders)) return false; + var sigValidation = await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders, body); + if (!sigValidation.SignatureIsValidated) return false; // Save Follow in DB + var followerUserName = sigValidation.User.preferredUsername.ToLowerInvariant(); + var followerHost = sigValidation.User.url.Replace("https://", string.Empty).Split('/').First(); + var followerInbox = sigValidation.User.inbox; + var followerSharedInbox = sigValidation.User?.endpoints?.sharedInbox; + var twitterUser = activity.apObject.Split('/').Last().Replace("@", string.Empty); + + // Make sure to only keep routes + followerInbox = OnlyKeepRoute(followerInbox, followerHost); + followerSharedInbox = OnlyKeepRoute(followerSharedInbox, followerHost); - // Send Accept Activity - var targetHost = activity.actor.Replace("https://", string.Empty).Split('/').First(); + // Execute + await _processFollowUser.ExecuteAsync(followerUserName, followerHost, twitterUser, followerInbox, followerSharedInbox); + + // Send Accept Activity var acceptFollow = new ActivityAcceptFollow() { context = "https://www.w3.org/ns/activitystreams", @@ -123,12 +114,70 @@ namespace BirdsiteLive.Domain apObject = activity.apObject } }; - var result = await _activityPubService.PostDataAsync(acceptFollow, targetHost, activity.apObject); + var result = await _activityPubService.PostDataAsync(acceptFollow, followerHost, activity.apObject); return result == HttpStatusCode.Accepted; } - - private async Task ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary requestHeaders) + + private string OnlyKeepRoute(string inbox, string host) { + if (string.IsNullOrWhiteSpace(inbox)) + return null; + + if (inbox.Contains(host)) + inbox = inbox.Split(new[] { host }, StringSplitOptions.RemoveEmptyEntries).Last(); + + return inbox; + } + + public async Task UndoFollowRequestedAsync(string signature, string method, string path, string queryString, + Dictionary requestHeaders, ActivityUndoFollow activity, string body) + { + // Validate + var sigValidation = await ValidateSignature(activity.actor, signature, method, path, queryString, requestHeaders, body); + if (!sigValidation.SignatureIsValidated) return false; + + // Save Follow in DB + var followerUserName = sigValidation.User.name.ToLowerInvariant(); + var followerHost = sigValidation.User.url.Replace("https://", string.Empty).Split('/').First(); + //var followerInbox = sigValidation.User.inbox; + var twitterUser = activity.apObject.apObject.Split('/').Last().Replace("@", string.Empty); + await _processUndoFollowUser.ExecuteAsync(followerUserName, followerHost, twitterUser); + + // Send Accept Activity + var acceptFollow = new ActivityAcceptUndoFollow() + { + context = "https://www.w3.org/ns/activitystreams", + id = $"{activity.apObject.apObject}#accepts/undofollows/{Guid.NewGuid()}", + type = "Accept", + actor = activity.apObject.apObject, + apObject = new ActivityUndoFollow() + { + id = activity.id, + type = activity.type, + actor = activity.actor, + apObject = activity.apObject + } + }; + var result = await _activityPubService.PostDataAsync(acceptFollow, followerHost, activity.apObject.apObject); + return result == HttpStatusCode.Accepted; + } + + private async Task ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary requestHeaders, string body) + { + //Check Date Validity + var date = requestHeaders["date"]; + var d = DateTime.Parse(date).ToUniversalTime(); + var now = DateTime.UtcNow; + var delta = Math.Abs((d - now).TotalSeconds); + if (delta > 30) return new SignatureValidationResult { SignatureIsValidated = false }; + + //Check Digest + var digest = requestHeaders["digest"]; + var digestHash = digest.Split(new [] {"SHA-256="},StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + var calculatedDigestHash = _cryptoService.ComputeSha256Hash(body); + if (digestHash != calculatedDigestHash) return new SignatureValidationResult { SignatureIsValidated = false }; + + //Check Signature var signatures = rawSig.Split(','); var signature_header = new Dictionary(); foreach (var signature in signatures) @@ -184,7 +233,17 @@ namespace BirdsiteLive.Domain var result = signKey.VerifyData(Encoding.UTF8.GetBytes(toSign.ToString()), sig, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return result; + return new SignatureValidationResult() + { + SignatureIsValidated = result, + User = remoteUser + }; } } + + public class SignatureValidationResult + { + public bool SignatureIsValidated { get; set; } + public Actor User { get; set; } + } } diff --git a/src/BirdsiteLive.Pipeline/BirdsiteLive.Pipeline.csproj b/src/BirdsiteLive.Pipeline/BirdsiteLive.Pipeline.csproj new file mode 100644 index 0000000..6b8b510 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/BirdsiteLive.Pipeline.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + latest + + + + + + + + + + + + + + diff --git a/src/BirdsiteLive.Pipeline/Contracts/IRetrieveFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveFollowersProcessor.cs new file mode 100644 index 0000000..e0d45dc --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveFollowersProcessor.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface IRetrieveFollowersProcessor + { + Task> ProcessAsync(UserWithTweetsToSync[] userWithTweetsToSyncs, CancellationToken ct); + //IAsyncEnumerable ProcessAsync(UserWithTweetsToSync[] userWithTweetsToSyncs, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTweetsProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTweetsProcessor.cs new file mode 100644 index 0000000..451f1d1 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTweetsProcessor.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface IRetrieveTweetsProcessor + { + Task ProcessAsync(SyncTwitterUser[] syncTwitterUsers, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTwitterUsersProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTwitterUsersProcessor.cs new file mode 100644 index 0000000..b71ae93 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/IRetrieveTwitterUsersProcessor.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using BirdsiteLive.DAL.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface IRetrieveTwitterUsersProcessor + { + Task GetTwitterUsersAsync(BufferBlock twitterUsersBufferBlock, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs new file mode 100644 index 0000000..02efaef --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface ISaveProgressionProcessor + { + Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs new file mode 100644 index 0000000..6d55957 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface ISendTweetsToFollowersProcessor + { + Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Models/UserWithTweetsToSync.cs b/src/BirdsiteLive.Pipeline/Models/UserWithTweetsToSync.cs new file mode 100644 index 0000000..57810c7 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Models/UserWithTweetsToSync.cs @@ -0,0 +1,13 @@ +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Twitter.Models; +using Tweetinvi.Models; + +namespace BirdsiteLive.Pipeline.Models +{ + public class UserWithTweetsToSync + { + public SyncTwitterUser User { get; set; } + public ExtractedTweet[] Tweets { get; set; } + public Follower[] Followers { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/RetrieveFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/RetrieveFollowersProcessor.cs new file mode 100644 index 0000000..4b2f150 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/RetrieveFollowersProcessor.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class RetrieveFollowersProcessor : IRetrieveFollowersProcessor + { + private readonly IFollowersDal _followersDal; + + #region Ctor + public RetrieveFollowersProcessor(IFollowersDal followersDal) + { + _followersDal = followersDal; + } + #endregion + + public async Task> ProcessAsync(UserWithTweetsToSync[] userWithTweetsToSyncs, CancellationToken ct) + { + //TODO multithread this + foreach (var user in userWithTweetsToSyncs) + { + var followers = await _followersDal.GetFollowersAsync(user.User.Id); + user.Followers = followers; + } + + return userWithTweetsToSyncs; + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/RetrieveTweetsProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/RetrieveTweetsProcessor.cs new file mode 100644 index 0000000..68ca0b0 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/RetrieveTweetsProcessor.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; +using BirdsiteLive.Twitter; +using BirdsiteLive.Twitter.Models; +using Tweetinvi.Models; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class RetrieveTweetsProcessor : IRetrieveTweetsProcessor + { + private readonly ITwitterService _twitterService; + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public RetrieveTweetsProcessor(ITwitterService twitterService, ITwitterUserDal twitterUserDal) + { + _twitterService = twitterService; + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ProcessAsync(SyncTwitterUser[] syncTwitterUsers, CancellationToken ct) + { + var usersWtTweets = new List(); + + //TODO multithread this + foreach (var user in syncTwitterUsers) + { + var tweets = RetrieveNewTweets(user); + if (tweets.Length > 0 && user.LastTweetPostedId != -1) + { + var userWtTweets = new UserWithTweetsToSync + { + User = user, + Tweets = tweets + }; + usersWtTweets.Add(userWtTweets); + } + else if (tweets.Length > 0 && user.LastTweetPostedId == -1) + { + var tweetId = tweets.Last().Id; + await _twitterUserDal.UpdateTwitterUserAsync(user.Id, tweetId, tweetId); + } + } + + return usersWtTweets.ToArray(); + } + + private ExtractedTweet[] RetrieveNewTweets(SyncTwitterUser user) + { + ExtractedTweet[] tweets; + if (user.LastTweetPostedId == -1) + tweets = _twitterService.GetTimeline(user.Acct, 1); + else + tweets = _twitterService.GetTimeline(user.Acct, 200, user.LastTweetSynchronizedForAllFollowersId); + + return tweets; + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/RetrieveTwitterUsersProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/RetrieveTwitterUsersProcessor.cs new file mode 100644 index 0000000..f8ea2a2 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/RetrieveTwitterUsersProcessor.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Contracts; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class RetrieveTwitterUsersProcessor : IRetrieveTwitterUsersProcessor + { + private readonly ITwitterUserDal _twitterUserDal; + private const int SyncPeriod = 15; //in minutes + + #region Ctor + public RetrieveTwitterUsersProcessor(ITwitterUserDal twitterUserDal) + { + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task GetTwitterUsersAsync(BufferBlock twitterUsersBufferBlock, CancellationToken ct) + { + for (;;) + { + ct.ThrowIfCancellationRequested(); + + try + { + var users = await _twitterUserDal.GetAllTwitterUsersAsync(); + + if(users.Length > 0) + await twitterUsersBufferBlock.SendAsync(users, ct); + } + catch (Exception e) + { + Console.WriteLine(e); + } + + await Task.Delay(SyncPeriod * 1000 * 60, ct); + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs new file mode 100644 index 0000000..5b305e7 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class SaveProgressionProcessor : ISaveProgressionProcessor + { + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public SaveProgressionProcessor(ITwitterUserDal twitterUserDal) + { + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct) + { + var userId = userWithTweetsToSync.User.Id; + var lastPostedTweet = userWithTweetsToSync.Tweets.Select(x => x.Id).Max(); + var minimumSync = userWithTweetsToSync.Followers.Select(x => x.FollowingsSyncStatus[userId]).Min(); + await _twitterUserDal.UpdateTwitterUserAsync(userId, lastPostedTweet, minimumSync); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs new file mode 100644 index 0000000..95fd0c8 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; +using BirdsiteLive.Pipeline.Processors.SubTasks; +using BirdsiteLive.Twitter; +using BirdsiteLive.Twitter.Models; +using Tweetinvi.Models; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class SendTweetsToFollowersProcessor : ISendTweetsToFollowersProcessor + { + private readonly ISendTweetsToInboxTask _sendTweetsToInboxTask; + private readonly ISendTweetsToSharedInboxTask _sendTweetsToSharedInbox; + + #region Ctor + public SendTweetsToFollowersProcessor(ISendTweetsToInboxTask sendTweetsToInboxTask, ISendTweetsToSharedInboxTask sendTweetsToSharedInbox) + { + _sendTweetsToInboxTask = sendTweetsToInboxTask; + _sendTweetsToSharedInbox = sendTweetsToSharedInbox; + } + #endregion + + public async Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct) + { + var user = userWithTweetsToSync.User; + + // Process Shared Inbox + var followersWtSharedInbox = userWithTweetsToSync.Followers + .Where(x => !string.IsNullOrWhiteSpace(x.SharedInboxRoute)) + .ToList(); + await ProcessFollowersWithSharedInbox(userWithTweetsToSync.Tweets, followersWtSharedInbox, user); + + // Process Inbox + var followerWtInbox = userWithTweetsToSync.Followers + .Where(x => string.IsNullOrWhiteSpace(x.SharedInboxRoute)) + .ToList(); + await ProcessFollowersWithInbox(userWithTweetsToSync.Tweets, followerWtInbox, user); + + return userWithTweetsToSync; + } + + private async Task ProcessFollowersWithSharedInbox(ExtractedTweet[] tweets, List followers, SyncTwitterUser user) + { + var followersPerInstances = followers.GroupBy(x => x.Host); + + foreach (var followersPerInstance in followersPerInstances) + { + try + { + await _sendTweetsToSharedInbox.ExecuteAsync(tweets, user, followersPerInstance.Key, followersPerInstance.ToArray()); + } + catch (Exception e) + { + Console.WriteLine(e); + //TODO handle error + } + } + } + + private async Task ProcessFollowersWithInbox(ExtractedTweet[] tweets, List followerWtInbox, SyncTwitterUser user) + { + foreach (var follower in followerWtInbox) + { + try + { + await _sendTweetsToInboxTask.ExecuteAsync(tweets, follower, user); + } + catch (Exception e) + { + Console.WriteLine(e); + //TODO handle error + } + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToInboxTask.cs b/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToInboxTask.cs new file mode 100644 index 0000000..eb1fb36 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToInboxTask.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; +using BirdsiteLive.Twitter.Models; + +namespace BirdsiteLive.Pipeline.Processors.SubTasks +{ + public interface ISendTweetsToInboxTask + { + Task ExecuteAsync(IEnumerable tweets, Follower follower, SyncTwitterUser user); + } + + public class SendTweetsToInboxTask : ISendTweetsToInboxTask + { + private readonly IActivityPubService _activityPubService; + private readonly IStatusService _statusService; + private readonly IFollowersDal _followersDal; + + #region Ctor + public SendTweetsToInboxTask(IActivityPubService activityPubService, IStatusService statusService, IFollowersDal followersDal) + { + _activityPubService = activityPubService; + _statusService = statusService; + _followersDal = followersDal; + } + #endregion + + public async Task ExecuteAsync(IEnumerable tweets, Follower follower, SyncTwitterUser user) + { + var userId = user.Id; + var fromStatusId = follower.FollowingsSyncStatus[userId]; + var tweetsToSend = tweets + .Where(x => x.Id > fromStatusId) + .OrderBy(x => x.Id) + .ToList(); + + var inbox = follower.InboxRoute; + + var syncStatus = fromStatusId; + try + { + foreach (var tweet in tweetsToSend) + { + var note = _statusService.GetStatus(user.Acct, tweet); + var result = await _activityPubService.PostNewNoteActivity(note, user.Acct, tweet.Id.ToString(), follower.Host, inbox); + + if (result == HttpStatusCode.Accepted) + syncStatus = tweet.Id; + else + throw new Exception("Posting new note activity failed"); + } + } + finally + { + if (syncStatus != fromStatusId) + { + follower.FollowingsSyncStatus[userId] = syncStatus; + await _followersDal.UpdateFollowerAsync(follower); + } + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToSharedInboxTask.cs b/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToSharedInboxTask.cs new file mode 100644 index 0000000..bdebdcd --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/SubTasks/SendTweetsToSharedInboxTask.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; +using BirdsiteLive.Twitter.Models; + +namespace BirdsiteLive.Pipeline.Processors.SubTasks +{ + public interface ISendTweetsToSharedInboxTask + { + Task ExecuteAsync(ExtractedTweet[] tweets, SyncTwitterUser user, string host, Follower[] followersPerInstance); + } + + public class SendTweetsToSharedInboxTask : ISendTweetsToSharedInboxTask + { + private readonly IStatusService _statusService; + private readonly IActivityPubService _activityPubService; + private readonly IFollowersDal _followersDal; + + #region Ctor + public SendTweetsToSharedInboxTask(IActivityPubService activityPubService, IStatusService statusService, IFollowersDal followersDal) + { + _activityPubService = activityPubService; + _statusService = statusService; + _followersDal = followersDal; + } + #endregion + + public async Task ExecuteAsync(ExtractedTweet[] tweets, SyncTwitterUser user, string host, Follower[] followersPerInstance) + { + var userId = user.Id; + var inbox = followersPerInstance.First().SharedInboxRoute; + + var fromStatusId = followersPerInstance + .Max(x => x.FollowingsSyncStatus[userId]); + + var tweetsToSend = tweets + .Where(x => x.Id > fromStatusId) + .OrderBy(x => x.Id) + .ToList(); + + var syncStatus = fromStatusId; + try + { + foreach (var tweet in tweetsToSend) + { + var note = _statusService.GetStatus(user.Acct, tweet); + var result = + await _activityPubService.PostNewNoteActivity(note, user.Acct, tweet.Id.ToString(), host, inbox); + + if (result == HttpStatusCode.Accepted) + syncStatus = tweet.Id; + else + throw new Exception("Posting new note activity failed"); + } + } + finally + { + if (syncStatus != fromStatusId) + { + foreach (var f in followersPerInstance) + { + f.FollowingsSyncStatus[userId] = syncStatus; + await _followersDal.UpdateFollowerAsync(f); + } + } + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/StatusPublicationPipeline.cs b/src/BirdsiteLive.Pipeline/StatusPublicationPipeline.cs new file mode 100644 index 0000000..bcb896f --- /dev/null +++ b/src/BirdsiteLive.Pipeline/StatusPublicationPipeline.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline +{ + public interface IStatusPublicationPipeline + { + Task ExecuteAsync(CancellationToken ct); + } + + public class StatusPublicationPipeline : IStatusPublicationPipeline + { + private readonly IRetrieveTwitterUsersProcessor _retrieveTwitterAccountsProcessor; + private readonly IRetrieveTweetsProcessor _retrieveTweetsProcessor; + private readonly IRetrieveFollowersProcessor _retrieveFollowersProcessor; + private readonly ISendTweetsToFollowersProcessor _sendTweetsToFollowersProcessor; + + #region Ctor + public StatusPublicationPipeline(IRetrieveTweetsProcessor retrieveTweetsProcessor, IRetrieveTwitterUsersProcessor retrieveTwitterAccountsProcessor, IRetrieveFollowersProcessor retrieveFollowersProcessor, ISendTweetsToFollowersProcessor sendTweetsToFollowersProcessor) + { + _retrieveTweetsProcessor = retrieveTweetsProcessor; + _retrieveTwitterAccountsProcessor = retrieveTwitterAccountsProcessor; + _retrieveFollowersProcessor = retrieveFollowersProcessor; + _sendTweetsToFollowersProcessor = sendTweetsToFollowersProcessor; + } + #endregion + + public async Task ExecuteAsync(CancellationToken ct) + { + // Create blocks + var twitterUsersBufferBlock = new BufferBlock(new DataflowBlockOptions { BoundedCapacity = 1, CancellationToken = ct}); + var retrieveTweetsBlock = new TransformBlock(async x => await _retrieveTweetsProcessor.ProcessAsync(x, ct)); + var retrieveTweetsBufferBlock = new BufferBlock(new DataflowBlockOptions { BoundedCapacity = 1, CancellationToken = ct }); + var retrieveFollowersBlock = new TransformManyBlock(async x => await _retrieveFollowersProcessor.ProcessAsync(x, ct)); + var retrieveFollowersBufferBlock = new BufferBlock(new DataflowBlockOptions { BoundedCapacity = 20, CancellationToken = ct }); + var sendTweetsToFollowersBlock = new ActionBlock(async x => await _sendTweetsToFollowersProcessor.ProcessAsync(x, ct), new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 5, CancellationToken = ct}); + + // Link pipeline + twitterUsersBufferBlock.LinkTo(retrieveTweetsBlock, new DataflowLinkOptions {PropagateCompletion = true}); + retrieveTweetsBlock.LinkTo(retrieveTweetsBufferBlock, new DataflowLinkOptions { PropagateCompletion = true }); + retrieveTweetsBufferBlock.LinkTo(retrieveFollowersBlock, new DataflowLinkOptions { PropagateCompletion = true }); + retrieveFollowersBlock.LinkTo(retrieveFollowersBufferBlock, new DataflowLinkOptions { PropagateCompletion = true }); + retrieveFollowersBufferBlock.LinkTo(sendTweetsToFollowersBlock, new DataflowLinkOptions { PropagateCompletion = true }); + + // Launch twitter user retriever + var retrieveTwitterAccountsTask = _retrieveTwitterAccountsProcessor.GetTwitterUsersAsync(twitterUsersBufferBlock, ct); + + // Wait + await Task.WhenAny(new []{ retrieveTwitterAccountsTask , sendTweetsToFollowersBlock.Completion}); + + var foreground = Console.ForegroundColor; + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("An error occured, pipeline stopped"); + Console.ForegroundColor = foreground; + } + } +} diff --git a/src/BirdsiteLive.Twitter/Extractors/TweetExtractor.cs b/src/BirdsiteLive.Twitter/Extractors/TweetExtractor.cs new file mode 100644 index 0000000..2cb7dde --- /dev/null +++ b/src/BirdsiteLive.Twitter/Extractors/TweetExtractor.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BirdsiteLive.Twitter.Models; +using Tweetinvi.Models; +using Tweetinvi.Models.Entities; + +namespace BirdsiteLive.Twitter.Extractors +{ + public interface ITweetExtractor + { + ExtractedTweet Extract(ITweet tweet); + } + + public class TweetExtractor : ITweetExtractor + { + public ExtractedTweet Extract(ITweet tweet) + { + var extractedTweet = new ExtractedTweet + { + Id = tweet.Id, + InReplyToStatusId = tweet.InReplyToStatusId, + InReplyToAccount = tweet.InReplyToScreenName, + MessageContent = ExtractMessage(tweet), + Media = ExtractMedia(tweet.Media), + CreatedAt = tweet.CreatedAt.ToUniversalTime() + }; + return extractedTweet; + } + + public string ExtractMessage(ITweet tweet) + { + var tweetUrls = tweet.Media.Select(x => x.URL).Distinct(); + var message = tweet.FullText; + foreach (var tweetUrl in tweetUrls) + message = message.Replace(tweetUrl, string.Empty).Trim(); + + if (tweet.QuotedTweet != null) message = $"[Quote RT]{Environment.NewLine}{message}"; + if (tweet.IsRetweet) + { + if (tweet.RetweetedTweet != null) + message = $"[RT @{tweet.RetweetedTweet.CreatedBy.ScreenName}]{Environment.NewLine}{tweet.RetweetedTweet.FullText}"; + else + message = message.Replace("RT", "[RT]"); + } + + return message; + } + + public ExtractedMedia[] ExtractMedia(List media) + { + var result = new List(); + + foreach (var m in media) + { + var mediaUrl = GetMediaUrl(m); + var mediaType = GetMediaType(m.MediaType, mediaUrl); + if (mediaType == null) continue; + + var att = new ExtractedMedia + { + MediaType = mediaType, + Url = mediaUrl + }; + result.Add(att); + } + + return result.ToArray(); + } + + public string GetMediaUrl(IMediaEntity media) + { + switch (media.MediaType) + { + case "photo": return media.MediaURLHttps; + case "animated_gif": return media.VideoDetails.Variants[0].URL; + case "video": return media.VideoDetails.Variants.OrderByDescending(x => x.Bitrate).First().URL; + default: return null; + } + } + + public string GetMediaType(string mediaType, string mediaUrl) + { + switch (mediaType) + { + case "photo": + var ext = Path.GetExtension(mediaUrl); + switch (ext) + { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + } + return null; + + case "animated_gif": + return "image/gif"; + + case "video": + return "video/mp4"; + } + return null; + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Twitter/Models/ExtractedMedia.cs b/src/BirdsiteLive.Twitter/Models/ExtractedMedia.cs new file mode 100644 index 0000000..cdab034 --- /dev/null +++ b/src/BirdsiteLive.Twitter/Models/ExtractedMedia.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.Twitter.Models +{ + public class ExtractedMedia + { + public string MediaType { get; set; } + public string Url { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Twitter/Models/ExtractedTweet.cs b/src/BirdsiteLive.Twitter/Models/ExtractedTweet.cs new file mode 100644 index 0000000..0363973 --- /dev/null +++ b/src/BirdsiteLive.Twitter/Models/ExtractedTweet.cs @@ -0,0 +1,15 @@ +using System; +using System.Net.Sockets; + +namespace BirdsiteLive.Twitter.Models +{ + public class ExtractedTweet + { + public long Id { get; set; } + public long? InReplyToStatusId { get; set; } + public string MessageContent { get; set; } + public ExtractedMedia[] Media { get; set; } + public DateTime CreatedAt { get; set; } + public string InReplyToAccount { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Twitter/TwitterService.cs b/src/BirdsiteLive.Twitter/TwitterService.cs index 3bec34a..f49f089 100644 --- a/src/BirdsiteLive.Twitter/TwitterService.cs +++ b/src/BirdsiteLive.Twitter/TwitterService.cs @@ -1,33 +1,41 @@ using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using System.Threading.Tasks; using BirdsiteLive.Common.Settings; +using BirdsiteLive.Twitter.Extractors; using BirdsiteLive.Twitter.Models; using Tweetinvi; using Tweetinvi.Models; +using Tweetinvi.Models.Entities; +using Tweetinvi.Parameters; namespace BirdsiteLive.Twitter { public interface ITwitterService { TwitterUser GetUser(string username); - ITweet GetTweet(long statusId); + ExtractedTweet GetTweet(long statusId); + ExtractedTweet[] GetTimeline(string username, int nberTweets, long fromTweetId = -1); } public class TwitterService : ITwitterService { private readonly TwitterSettings _settings; + private readonly ITweetExtractor _tweetExtractor; #region Ctor - public TwitterService(TwitterSettings settings) + public TwitterService(TwitterSettings settings, ITweetExtractor tweetExtractor) { _settings = settings; + _tweetExtractor = tweetExtractor; + Auth.SetApplicationOnlyCredentials(_settings.ConsumerKey, _settings.ConsumerSecret, true); } #endregion public TwitterUser GetUser(string username) { - //Auth.SetUserCredentials(_settings.ConsumerKey, _settings.ConsumerSecret, _settings.AccessToken, _settings.AccessTokenSecret); - Auth.SetApplicationOnlyCredentials(_settings.ConsumerKey, _settings.ConsumerSecret, true); var user = User.GetUserFromScreenName(username); if (user == null) return null; @@ -43,11 +51,37 @@ namespace BirdsiteLive.Twitter }; } - public ITweet GetTweet(long statusId) + public ExtractedTweet GetTweet(long statusId) { - Auth.SetApplicationOnlyCredentials(_settings.ConsumerKey, _settings.ConsumerSecret, true); + TweetinviConfig.CurrentThreadSettings.TweetMode = TweetMode.Extended; var tweet = Tweet.GetTweet(statusId); - return tweet; + return _tweetExtractor.Extract(tweet); + } + + public ExtractedTweet[] GetTimeline(string username, int nberTweets, long fromTweetId = -1) + { + TweetinviConfig.CurrentThreadSettings.TweetMode = TweetMode.Extended; + + var user = User.GetUserFromScreenName(username); + var tweets = new List(); + if (fromTweetId == -1) + { + var timeline = Timeline.GetUserTimeline(user.Id, nberTweets); + if (timeline != null) tweets.AddRange(timeline); + } + else + { + var timelineRequestParameters = new UserTimelineParameters + { + SinceId = fromTweetId, + MaximumNumberOfTweetsToRetrieve = nberTweets + }; + var timeline = Timeline.GetUserTimeline(user.Id, timelineRequestParameters); + if (timeline != null) tweets.AddRange(timeline); + } + + return tweets.Select(_tweetExtractor.Extract).ToArray(); + //return tweets.Where(x => returnReplies || string.IsNullOrWhiteSpace(x.InReplyToScreenName)).ToArray(); } } } diff --git a/src/BirdsiteLive.sln b/src/BirdsiteLive.sln index d600aa6..bf78d55 100644 --- a/src/BirdsiteLive.sln +++ b/src/BirdsiteLive.sln @@ -25,11 +25,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.ActivityPub.Te EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DataAccessLayers", "DataAccessLayers", "{CFAB3509-3931-42DB-AC97-4F91FC2D849C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.DAL", "DataAccessLayers\BirdsiteLive.DAL\BirdsiteLive.DAL.csproj", "{47058CAB-DC43-4DD1-8F68-D3D625332905}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.DAL", "DataAccessLayers\BirdsiteLive.DAL\BirdsiteLive.DAL.csproj", "{47058CAB-DC43-4DD1-8F68-D3D625332905}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.DAL.Postgres", "DataAccessLayers\BirdsiteLive.DAL.Postgres\BirdsiteLive.DAL.Postgres.csproj", "{87E46519-BBF2-437C-8A5B-CF6CDE7CDAA6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.DAL.Postgres", "DataAccessLayers\BirdsiteLive.DAL.Postgres\BirdsiteLive.DAL.Postgres.csproj", "{87E46519-BBF2-437C-8A5B-CF6CDE7CDAA6}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BirdsiteLive.DAL.Postgres.Tests", "Tests\BirdsiteLive.DAL.Postgres.Tests\BirdsiteLive.DAL.Postgres.Tests.csproj", "{CD9489BF-69C8-4705-8774-81C45F4F8FE1}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.DAL.Postgres.Tests", "Tests\BirdsiteLive.DAL.Postgres.Tests\BirdsiteLive.DAL.Postgres.Tests.csproj", "{CD9489BF-69C8-4705-8774-81C45F4F8FE1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pipeline", "Pipeline", "{DA3C160C-4811-4E26-A5AD-42B81FAF2D7C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Pipeline", "BirdsiteLive.Pipeline\BirdsiteLive.Pipeline.csproj", "{2A8CC30D-D775-47D1-9388-F72A5C32DE2A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Domain.Tests", "Tests\BirdsiteLive.Domain.Tests\BirdsiteLive.Domain.Tests.csproj", "{F544D745-89A8-4DEA-B61C-A7E6C53C1D63}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BirdsiteLive.Pipeline.Tests", "Tests\BirdsiteLive.Pipeline.Tests\BirdsiteLive.Pipeline.Tests.csproj", "{BF51CA81-5A7A-46F8-B4FB-861C6BE59298}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -81,6 +89,18 @@ Global {CD9489BF-69C8-4705-8774-81C45F4F8FE1}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD9489BF-69C8-4705-8774-81C45F4F8FE1}.Release|Any CPU.ActiveCfg = Release|Any CPU {CD9489BF-69C8-4705-8774-81C45F4F8FE1}.Release|Any CPU.Build.0 = Release|Any CPU + {2A8CC30D-D775-47D1-9388-F72A5C32DE2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A8CC30D-D775-47D1-9388-F72A5C32DE2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A8CC30D-D775-47D1-9388-F72A5C32DE2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A8CC30D-D775-47D1-9388-F72A5C32DE2A}.Release|Any CPU.Build.0 = Release|Any CPU + {F544D745-89A8-4DEA-B61C-A7E6C53C1D63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F544D745-89A8-4DEA-B61C-A7E6C53C1D63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F544D745-89A8-4DEA-B61C-A7E6C53C1D63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F544D745-89A8-4DEA-B61C-A7E6C53C1D63}.Release|Any CPU.Build.0 = Release|Any CPU + {BF51CA81-5A7A-46F8-B4FB-861C6BE59298}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF51CA81-5A7A-46F8-B4FB-861C6BE59298}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF51CA81-5A7A-46F8-B4FB-861C6BE59298}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF51CA81-5A7A-46F8-B4FB-861C6BE59298}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -96,6 +116,9 @@ Global {47058CAB-DC43-4DD1-8F68-D3D625332905} = {CFAB3509-3931-42DB-AC97-4F91FC2D849C} {87E46519-BBF2-437C-8A5B-CF6CDE7CDAA6} = {CFAB3509-3931-42DB-AC97-4F91FC2D849C} {CD9489BF-69C8-4705-8774-81C45F4F8FE1} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} + {2A8CC30D-D775-47D1-9388-F72A5C32DE2A} = {DA3C160C-4811-4E26-A5AD-42B81FAF2D7C} + {F544D745-89A8-4DEA-B61C-A7E6C53C1D63} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} + {BF51CA81-5A7A-46F8-B4FB-861C6BE59298} = {A32D3458-09D0-4E0A-BA4B-8C411B816B94} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {69E8DCAD-4C37-4010-858F-5F94E6FBABCE} diff --git a/src/BirdsiteLive/BirdsiteLive.csproj b/src/BirdsiteLive/BirdsiteLive.csproj index 332831e..8b92b7c 100644 --- a/src/BirdsiteLive/BirdsiteLive.csproj +++ b/src/BirdsiteLive/BirdsiteLive.csproj @@ -4,6 +4,7 @@ netcoreapp3.1 d21486de-a812-47eb-a419-05682bb68856 Linux + 0.1.0 @@ -16,7 +17,9 @@ + + diff --git a/src/BirdsiteLive/Controllers/DebugingController.cs b/src/BirdsiteLive/Controllers/DebugingController.cs index 47196c7..12ac90e 100644 --- a/src/BirdsiteLive/Controllers/DebugingController.cs +++ b/src/BirdsiteLive/Controllers/DebugingController.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Text; using System.Threading.Tasks; using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Models; using BirdsiteLive.Common.Settings; using BirdsiteLive.Domain; using Microsoft.AspNetCore.Mvc; @@ -92,8 +93,8 @@ namespace BirdsiteLive.Controllers //cc = new [] { apPublic }, sensitive = false, content = "

Woooot

", - attachment = new string[0], - tag = new string[0] + attachment = new Attachment[0], + tag = new Tag[0] } }; diff --git a/src/BirdsiteLive/Controllers/HomeController.cs b/src/BirdsiteLive/Controllers/HomeController.cs index 7270240..ef41b65 100644 --- a/src/BirdsiteLive/Controllers/HomeController.cs +++ b/src/BirdsiteLive/Controllers/HomeController.cs @@ -33,5 +33,11 @@ namespace BirdsiteLive.Controllers { return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } + + [HttpPost] + public IActionResult Index(string handle) + { + return RedirectToAction("Index", "Users", new {id = handle}); + } } } diff --git a/src/BirdsiteLive/Controllers/InboxController.cs b/src/BirdsiteLive/Controllers/InboxController.cs index 82de7b3..a0d8748 100644 --- a/src/BirdsiteLive/Controllers/InboxController.cs +++ b/src/BirdsiteLive/Controllers/InboxController.cs @@ -19,12 +19,11 @@ namespace BirdsiteLive.Controllers using (var reader = new StreamReader(Request.Body)) { var body = await reader.ReadToEndAsync(); - + //System.IO.File.WriteAllText($@"C:\apdebug\inbox\{Guid.NewGuid()}.json", body); } - - throw new NotImplementedException(); + return Accepted(); } } } \ No newline at end of file diff --git a/src/BirdsiteLive/Controllers/UsersController.cs b/src/BirdsiteLive/Controllers/UsersController.cs index 38f87ee..dd1b081 100644 --- a/src/BirdsiteLive/Controllers/UsersController.cs +++ b/src/BirdsiteLive/Controllers/UsersController.cs @@ -2,10 +2,15 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Mime; +using System.Runtime.InteropServices.WindowsRuntime; using System.Threading; using System.Threading.Tasks; using BirdsiteLive.ActivityPub; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.Common.Settings; using BirdsiteLive.Domain; +using BirdsiteLive.Models; using BirdsiteLive.Twitter; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -18,12 +23,16 @@ namespace BirdsiteLive.Controllers { private readonly ITwitterService _twitterService; private readonly IUserService _userService; + private readonly IStatusService _statusService; + private readonly InstanceSettings _instanceSettings; #region Ctor - public UsersController(ITwitterService twitterService, IUserService userService) + public UsersController(ITwitterService twitterService, IUserService userService, IStatusService statusService, InstanceSettings instanceSettings) { _twitterService = twitterService; _userService = userService; + _statusService = statusService; + _instanceSettings = instanceSettings; } #endregion @@ -42,7 +51,17 @@ namespace BirdsiteLive.Controllers return Content(jsonApUser, "application/activity+json; charset=utf-8"); } - return View(user); + var displayableUser = new DisplayTwitterUser + { + Name = user.Name, + Description = user.Description, + Acct = user.Acct, + Url = user.Url, + ProfileImageUrl = user.ProfileImageUrl, + + InstanceHandle = $"@{user.Acct}@{_instanceSettings.Domain}" + }; + return View(displayableUser); } [Route("/@{id}/{statusId}")] @@ -54,15 +73,15 @@ namespace BirdsiteLive.Controllers { if (!long.TryParse(statusId, out var parsedStatusId)) return NotFound(); - + var tweet = _twitterService.GetTweet(parsedStatusId); - if(tweet == null) + if (tweet == null) return NotFound(); - var user = _twitterService.GetUser(id); - if (user == null) return NotFound(); + //var user = _twitterService.GetUser(id); + //if (user == null) return NotFound(); - var status = _userService.GetStatus(user, tweet); + var status = _statusService.GetStatus(id, tweet); var jsonApUser = JsonConvert.SerializeObject(status); return Content(jsonApUser, "application/activity+json; charset=utf-8"); } @@ -78,24 +97,54 @@ namespace BirdsiteLive.Controllers using (var reader = new StreamReader(Request.Body)) { var body = await reader.ReadToEndAsync(); + //System.IO.File.WriteAllText($@"C:\apdebug\{Guid.NewGuid()}.json", body); + var activity = ApDeserializer.ProcessActivity(body); // Do something + var signature = r.Headers["Signature"].First(); - switch (activity.type) + Console.WriteLine(body); + Console.WriteLine(); + + switch (activity?.type) { case "Follow": - var succeeded = await _userService.FollowRequestedAsync(r.Headers["Signature"].First(), r.Method, r.Path, r.QueryString.ToString(), RequestHeaders(r.Headers), activity as ActivityFollow); - if (succeeded) return Accepted(); - else return Unauthorized(); - break; + { + var succeeded = await _userService.FollowRequestedAsync(signature, r.Method, r.Path, + r.QueryString.ToString(), RequestHeaders(r.Headers), activity as ActivityFollow, body); + if (succeeded) return Accepted(); + else return Unauthorized(); + } case "Undo": + if (activity is ActivityUndoFollow) + { + var succeeded = await _userService.UndoFollowRequestedAsync(signature, r.Method, r.Path, + r.QueryString.ToString(), RequestHeaders(r.Headers), activity as ActivityUndoFollow, body); + if (succeeded) return Accepted(); + else return Unauthorized(); + } return Accepted(); default: return Accepted(); } } - return Ok(); + return Accepted(); + } + + [Route("/users/{id}/followers")] + [HttpGet] + public async Task Followers(string id) + { + var r = Request.Headers["Accept"].First(); + if (!r.Contains("application/activity+json")) return NotFound(); + + var followers = new Followers + { + id = $"https://{_instanceSettings.Domain}/users/{id}/followers" + }; + var jsonApUser = JsonConvert.SerializeObject(followers); + return Content(jsonApUser, "application/activity+json; charset=utf-8"); } private Dictionary RequestHeaders(IHeaderDictionary header) diff --git a/src/BirdsiteLive/Controllers/WellKnownController.cs b/src/BirdsiteLive/Controllers/WellKnownController.cs index 613b948..9ac3bb3 100644 --- a/src/BirdsiteLive/Controllers/WellKnownController.cs +++ b/src/BirdsiteLive/Controllers/WellKnownController.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using BirdsiteLive.Common.Settings; using BirdsiteLive.Models; +using BirdsiteLive.Models.WellKnownModels; using BirdsiteLive.Twitter; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -17,10 +18,10 @@ namespace BirdsiteLive.Controllers private readonly InstanceSettings _settings; #region Ctor - public WellKnownController(IOptions settings, ITwitterService twitterService) + public WellKnownController(InstanceSettings settings, ITwitterService twitterService) { _twitterService = twitterService; - _settings = settings.Value; + _settings = settings; } #endregion @@ -35,39 +36,95 @@ namespace BirdsiteLive.Controllers { rel = "http://nodeinfo.diaspora.software/ns/schema/2.0", href = $"https://{_settings.Domain}/nodeinfo/2.0.json" + }, + new Link() + { + rel = "http://nodeinfo.diaspora.software/ns/schema/2.1", + href = $"https://{_settings.Domain}/nodeinfo/2.1.json" } } }; return new JsonResult(nodeInfo); } - [Route("/nodeinfo/2.0.json")] - public IActionResult NodeInfo() + [Route("/nodeinfo/{id}.json")] + public IActionResult NodeInfo(string id) { - var nodeInfo = new NodeInfo - { - version = "2.0", - usage = new Usage() - { - localPosts = 0, - users = new Users() - { - total = 0 - } - }, - software = new Software() - { - name = "BirdsiteLive", - version = "0.1.0" - }, - protocols = new [] - { - "activitypub" - }, - openRegistrations = false - }; + var version = System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString(3); - return new JsonResult(nodeInfo); + if (id == "2.0") + { + var nodeInfo = new NodeInfoV20 + { + version = "2.0", + usage = new Usage() + { + localPosts = 0, + users = new Users() + { + total = 0 + } + }, + software = new Software() + { + name = "birdsitelive", + version = version + }, + protocols = new[] + { + "activitypub" + }, + openRegistrations = false, + services = new Models.WellKnownModels.Services() + { + inbound = new object[0], + outbound = new object[0] + }, + metadata = new Metadata() + { + email = _settings.AdminEmail + } + }; + return new JsonResult(nodeInfo); + } + if (id == "2.1") + { + var nodeInfo = new NodeInfoV21 + { + version = "2.1", + usage = new Usage() + { + localPosts = 0, + users = new Users() + { + total = 0 + } + }, + software = new SoftwareV21() + { + name = "birdsitelive", + version = version, + repository = "https://github.com/NicolasConstant/BirdsiteLive" + }, + protocols = new[] + { + "activitypub" + }, + openRegistrations = false, + services = new Models.WellKnownModels.Services() + { + inbound = new object[0], + outbound = new object[0] + }, + metadata = new Metadata() + { + email = _settings.AdminEmail + } + }; + return new JsonResult(nodeInfo); + } + + return NotFound(); } [Route("/.well-known/webfinger")] @@ -130,63 +187,4 @@ namespace BirdsiteLive.Controllers return new JsonResult(result); } } - - public class WebFingerResult - { - public string subject { get; set; } - public string[] aliases { get; set; } - public List links { get; set; } = new List(); - } - - public class WebFingerLink - { - public string rel { get; set; } - public string type { get; set; } - public string href { get; set; } - } - - public class WellKnownNodeInfo - { - public Link[] links { get; set; } - } - - public class Link - { - public string href { get; set; } - public string rel { get; set; } - } - - public class NodeInfo - { - public string version { get; set; } - public string[] protocols { get; set; } - public Software software { get; set; } - public Usage usage { get; set; } - public bool openRegistrations { get; set; } - public Services services { get; set; } - public object metadata { get; set; } - } - - public class Services - { - public object[] inbound { get; set; } - public object[] outbound { get; set; } - } - - public class Software - { - public string name { get; set; } - public string version { get; set; } - } - - public class Usage - { - public int localPosts { get; set; } - public Users users { get; set; } - } - - public class Users - { - public int total { get; set; } - } } \ No newline at end of file diff --git a/src/BirdsiteLive/Models/DisplayTwitterUser.cs b/src/BirdsiteLive/Models/DisplayTwitterUser.cs new file mode 100644 index 0000000..58ba348 --- /dev/null +++ b/src/BirdsiteLive/Models/DisplayTwitterUser.cs @@ -0,0 +1,13 @@ +namespace BirdsiteLive.Models +{ + public class DisplayTwitterUser + { + public string Name { get; set; } + public string Description { get; set; } + public string Acct { get; set; } + public string Url { get; set; } + public string ProfileImageUrl { get; set; } + + public string InstanceHandle { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Link.cs b/src/BirdsiteLive/Models/WellKnownModels/Link.cs new file mode 100644 index 0000000..e4bedfe --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Link.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Link + { + public string href { get; set; } + public string rel { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Metadata.cs b/src/BirdsiteLive/Models/WellKnownModels/Metadata.cs new file mode 100644 index 0000000..9f5007e --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Metadata.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Metadata + { + public string email { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV20.cs b/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV20.cs new file mode 100644 index 0000000..032fc51 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV20.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace BirdsiteLive.Models.WellKnownModels +{ + public class NodeInfoV20 + { + public string version { get; set; } + public string[] protocols { get; set; } + public Software software { get; set; } + public Usage usage { get; set; } + public bool openRegistrations { get; set; } + public Services services { get; set; } + public Metadata metadata { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV21.cs b/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV21.cs new file mode 100644 index 0000000..ce397cb --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/NodeInfoV21.cs @@ -0,0 +1,13 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class NodeInfoV21 + { + public string version { get; set; } + public string[] protocols { get; set; } + public Usage usage { get; set; } + public bool openRegistrations { get; set; } + public SoftwareV21 software { get; set; } + public Services services { get; set; } + public Metadata metadata { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Services.cs b/src/BirdsiteLive/Models/WellKnownModels/Services.cs new file mode 100644 index 0000000..fa25074 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Services.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Services + { + public object[] inbound { get; set; } + public object[] outbound { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Software.cs b/src/BirdsiteLive/Models/WellKnownModels/Software.cs new file mode 100644 index 0000000..9cbefa6 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Software.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Software + { + public string name { get; set; } + public string version { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/SoftwareV21.cs b/src/BirdsiteLive/Models/WellKnownModels/SoftwareV21.cs new file mode 100644 index 0000000..c6fa851 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/SoftwareV21.cs @@ -0,0 +1,9 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class SoftwareV21 + { + public string name { get; set; } + public string repository { get; set; } + public string version { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Usage.cs b/src/BirdsiteLive/Models/WellKnownModels/Usage.cs new file mode 100644 index 0000000..693875f --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Usage.cs @@ -0,0 +1,8 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Usage + { + public int localPosts { get; set; } + public Users users { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/Users.cs b/src/BirdsiteLive/Models/WellKnownModels/Users.cs new file mode 100644 index 0000000..3abdb70 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/Users.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class Users + { + public int total { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/WebFingerLink.cs b/src/BirdsiteLive/Models/WellKnownModels/WebFingerLink.cs new file mode 100644 index 0000000..9945336 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/WebFingerLink.cs @@ -0,0 +1,9 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class WebFingerLink + { + public string rel { get; set; } + public string type { get; set; } + public string href { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/WebFingerResult.cs b/src/BirdsiteLive/Models/WellKnownModels/WebFingerResult.cs new file mode 100644 index 0000000..96c2e84 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/WebFingerResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace BirdsiteLive.Models.WellKnownModels +{ + public class WebFingerResult + { + public string subject { get; set; } + public string[] aliases { get; set; } + public List links { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Models/WellKnownModels/WellKnownNodeInfo.cs b/src/BirdsiteLive/Models/WellKnownModels/WellKnownNodeInfo.cs new file mode 100644 index 0000000..d34abe6 --- /dev/null +++ b/src/BirdsiteLive/Models/WellKnownModels/WellKnownNodeInfo.cs @@ -0,0 +1,7 @@ +namespace BirdsiteLive.Models.WellKnownModels +{ + public class WellKnownNodeInfo + { + public Link[] links { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Program.cs b/src/BirdsiteLive/Program.cs index c109ad2..d238b02 100644 --- a/src/BirdsiteLive/Program.cs +++ b/src/BirdsiteLive/Program.cs @@ -2,9 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using BirdsiteLive.Services; using Lamar.Microsoft.DependencyInjection; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -23,6 +25,10 @@ namespace BirdsiteLive .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup(); + }) + .ConfigureServices(services => + { + services.AddHostedService(); }); } } diff --git a/src/BirdsiteLive/Services/FederationService.cs b/src/BirdsiteLive/Services/FederationService.cs new file mode 100644 index 0000000..f2c2e94 --- /dev/null +++ b/src/BirdsiteLive/Services/FederationService.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Pipeline; +using Microsoft.Extensions.Hosting; + +namespace BirdsiteLive.Services +{ + public class FederationService : BackgroundService + { + private readonly IDbInitializerDal _dbInitializerDal; + private readonly IStatusPublicationPipeline _statusPublicationPipeline; + + #region Ctor + public FederationService(IDbInitializerDal dbInitializerDal, IStatusPublicationPipeline statusPublicationPipeline) + { + _dbInitializerDal = dbInitializerDal; + _statusPublicationPipeline = statusPublicationPipeline; + } + #endregion + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await DbInitAsync(); + await _statusPublicationPipeline.ExecuteAsync(stoppingToken); + } + + private async Task DbInitAsync() + { + var currentVersion = await _dbInitializerDal.GetCurrentDbVersionAsync(); + var mandatoryVersion = _dbInitializerDal.GetMandatoryDbVersion(); + + if (currentVersion == null) + { + await _dbInitializerDal.InitDbAsync(); + } + else if (currentVersion != mandatoryVersion) + { + throw new NotImplementedException(); + } + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive/Startup.cs b/src/BirdsiteLive/Startup.cs index 6d07aaa..c2d7cb0 100644 --- a/src/BirdsiteLive/Startup.cs +++ b/src/BirdsiteLive/Startup.cs @@ -3,6 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BirdsiteLive.Common.Settings; +using BirdsiteLive.Common.Structs; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Postgres.DataAccessLayers; +using BirdsiteLive.DAL.Postgres.Settings; using BirdsiteLive.Models; using Lamar; using Microsoft.AspNetCore.Builder; @@ -34,7 +38,7 @@ namespace BirdsiteLive // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.Configure(Configuration.GetSection("Instance")); + //services.Configure(Configuration.GetSection("Instance")); //services.Configure(Configuration.GetSection("Twitter")); services.AddControllersWithViews(); @@ -48,15 +52,39 @@ namespace BirdsiteLive var instanceSettings = Configuration.GetSection("Instance").Get(); services.For().Use(x => instanceSettings); + var dbSettings = Configuration.GetSection("Db").Get(); + services.For().Use(x => dbSettings); + + if (string.Equals(dbSettings.Type, DbTypes.Postgres, StringComparison.OrdinalIgnoreCase)) + { + var connString = $"Host={dbSettings.Host};Username={dbSettings.User};Password={dbSettings.Password};Database={dbSettings.Name}"; + var postgresSettings = new PostgresSettings + { + ConnString = connString + }; + services.For().Use(x => postgresSettings); + + services.For().Use().Singleton(); + services.For().Use().Singleton(); + services.For().Use().Singleton(); + } + else + { + throw new NotImplementedException($"{dbSettings.Type} is not supported"); + } + services.Scan(_ => { _.Assembly("BirdsiteLive.Twitter"); _.Assembly("BirdsiteLive.Domain"); + _.Assembly("BirdsiteLive.DAL"); + _.Assembly("BirdsiteLive.DAL.Postgres"); + _.Assembly("BirdsiteLive.Pipeline"); _.TheCallingAssembly(); //_.AssemblyContainingType(); //_.Exclude(type => type.Name.Contains("Settings")); - + _.WithDefaultConventions(); _.LookForRegistries(); diff --git a/src/BirdsiteLive/Views/Debuging/Index.cshtml b/src/BirdsiteLive/Views/Debuging/Index.cshtml index 04ea0bf..cb56c56 100644 --- a/src/BirdsiteLive/Views/Debuging/Index.cshtml +++ b/src/BirdsiteLive/Views/Debuging/Index.cshtml @@ -5,14 +5,14 @@

Debug

-
+
-
+ diff --git a/src/BirdsiteLive/Views/Home/Index.cshtml b/src/BirdsiteLive/Views/Home/Index.cshtml index 4c62fc0..866a295 100644 --- a/src/BirdsiteLive/Views/Home/Index.cshtml +++ b/src/BirdsiteLive/Views/Home/Index.cshtml @@ -5,11 +5,28 @@

Welcome

-

Learn about building Web apps with ASP.NET Core.

+

+
+ BirdsiteLIVE is a Twitter to ActivityPub bridge.
+ Find a Twitter account below: +

+ + + @*
+ + + We'll never share your email with anyone else. +
*@ +
+ @**@ + +
+ + - @if (HtmlHelperExtensions.IsDebug()) + @*@if (HtmlHelperExtensions.IsDebug()) { Debug - } + }*@
diff --git a/src/BirdsiteLive/Views/Shared/_Layout.cshtml b/src/BirdsiteLive/Views/Shared/_Layout.cshtml index 426caa2..5275267 100644 --- a/src/BirdsiteLive/Views/Shared/_Layout.cshtml +++ b/src/BirdsiteLive/Views/Shared/_Layout.cshtml @@ -3,7 +3,7 @@ - @ViewData["Title"] - BirdsiteLive + @ViewData["Title"] - BirdsiteLIVE @@ -12,8 +12,8 @@
@@ -36,11 +36,14 @@ -
-
- © 2020 - BirdsiteLive - Privacy -
-
+
+
+ + Github @*Privacy*@ + + BirdsiteLIVE @System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString(3) +
+
diff --git a/src/BirdsiteLive/Views/Users/Index.cshtml b/src/BirdsiteLive/Views/Users/Index.cshtml index 97344a8..2f3c727 100644 --- a/src/BirdsiteLive/Views/Users/Index.cshtml +++ b/src/BirdsiteLive/Views/Users/Index.cshtml @@ -1,18 +1,37 @@ -@model BirdsiteLive.Twitter.Models.TwitterUser +@using Tweetinvi.Streams.Model.AccountActivity +@model DisplayTwitterUser @{ ViewData["Title"] = "User"; } -
-
- -

@ViewData.Model.Name

-

@@@ViewData.Model.Acct

+ \ No newline at end of file diff --git a/src/BirdsiteLive/appsettings.json b/src/BirdsiteLive/appsettings.json index 6de0ca3..88712f8 100644 --- a/src/BirdsiteLive/appsettings.json +++ b/src/BirdsiteLive/appsettings.json @@ -8,12 +8,18 @@ }, "AllowedHosts": "*", "Instance": { - "Domain": "domain.name" + "Domain": "domain.name", + "AdminEmail": "me@domain.name" + }, + "Db": { + "Type": "postgres", + "Host": "127.0.0.1", + "Name": "mydb", + "User": "username", + "Password": "password" }, "Twitter": { "ConsumerKey": "twitter.api.key", - "ConsumerSecret": "twitter.api.key", - "AccessToken": "twitter.api.key", - "AccessTokenSecret": "twitter.api.key" + "ConsumerSecret": "twitter.api.key" } } diff --git a/src/BirdsiteLive/wwwroot/css/birdsite.css b/src/BirdsiteLive/wwwroot/css/birdsite.css index 91b0dc6..5b6023c 100644 --- a/src/BirdsiteLive/wwwroot/css/birdsite.css +++ b/src/BirdsiteLive/wwwroot/css/birdsite.css @@ -1,43 +1,73 @@ -.profile { - border: 1px #dddddd solid; - border-radius: 5px; - background-repeat: no-repeat; - /*background-attachment: fixed;*/ - background-position: center; +.nounderline { + text-decoration: none !important } +.logo { + width: 25px; + height: 25px; + float: right; +} + +.logo-twitter { + filter: invert(51%) sepia(92%) saturate(1166%) hue-rotate(180deg) brightness(94%) contrast(98%); + /*background: #349fef;*/ +} + +.profile { + border: 1px #dddddd solid; + border-radius: 15px; + /*background-repeat: no-repeat;*/ + /*background-attachment: fixed;*/ + /*background-position: center;*/ + color: black; +} + + .profile:hover { + transition: all .2s; + background-color: #f5f8fa; + } + .profile h1 { - font-size: 32px; - margin-left: 120px; - padding-top: 8px; + font-size: 18px; + margin-left: 60px; + padding-top: 0px; } .profile h2 { font-size: 20px; - margin-left: 120px; + margin-left: 0px; } - .profile a { +/*.profile a { color: black; } - .profile a:hover { - color: #555555; - } + .profile a:hover { + color: #555555; + }*/ + +.handle { + color: gray; + font-weight: normal; +} .sub-profile { - padding: 20px; - background-color: rgba(255, 255, 255, 0.7); + padding: 10px 15px; + min-height: 80px; } +/*.sub-profile a { + color: black; + }*/ + .avatar { float: left; - width: 100px; + width: 50px; border-radius: 50%; } .description { - margin-top: 40px; - margin-left: 20px; - font-weight: bold; + margin-top: 0px; + margin-left: 60px; + /*font-weight: bold;*/ } diff --git a/src/BirdsiteLive/wwwroot/lib/bootstrap/dist/css/bootstrap.css b/src/BirdsiteLive/wwwroot/lib/bootstrap/dist/css/bootstrap.css index 8f47589..6f49c34 100644 --- a/src/BirdsiteLive/wwwroot/lib/bootstrap/dist/css/bootstrap.css +++ b/src/BirdsiteLive/wwwroot/lib/bootstrap/dist/css/bootstrap.css @@ -419,6 +419,18 @@ h6, .h6 { line-height: 1.2; } +.display-5 { + font-size: 2.5rem; + font-weight: 100; + line-height: 1.2; +} + +.display-6 { + font-size: 1.5rem; + font-weight: 300; + line-height: 1.2; +} + hr { margin-top: 1rem; margin-bottom: 1rem; diff --git a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/DbInitializerPostgresDal.cs b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/DbInitializerPostgresDal.cs index 7b36644..c6d5c81 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/DbInitializerPostgresDal.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/DbInitializerPostgresDal.cs @@ -106,8 +106,10 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers followings INTEGER[], followingsSyncStatus JSONB, - acct VARCHAR(50), - host VARCHAR(253), + acct VARCHAR(50) NOT NULL, + host VARCHAR(253) NOT NULL, + inboxRoute VARCHAR(2048) NOT NULL, + sharedInboxRoute VARCHAR(2048), UNIQUE (acct, host) );"; await _tools.ExecuteRequestAsync(createFollowers); diff --git a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs index 7e6c2ac..961aa7c 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/DataAccessLayers/FollowersPostgresDal.cs @@ -20,8 +20,11 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers } #endregion - public async Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary followingSyncStatus) + public async Task CreateFollowerAsync(string acct, string host, string inboxRoute, string sharedInboxRoute, int[] followings = null, Dictionary followingSyncStatus = null) { + if(followings == null) followings = new int[0]; + if(followingSyncStatus == null) followingSyncStatus = new Dictionary(); + var serializedDic = JsonConvert.SerializeObject(followingSyncStatus); acct = acct.ToLowerInvariant(); @@ -32,8 +35,8 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers dbConnection.Open(); await dbConnection.ExecuteAsync( - $"INSERT INTO {_settings.FollowersTableName} (acct,host,followings,followingsSyncStatus) VALUES(@acct,@host,@followings, CAST(@followingsSyncStatus as json))", - new { acct, host, followings, followingsSyncStatus = serializedDic }); + $"INSERT INTO {_settings.FollowersTableName} (acct,host,inboxRoute,sharedInboxRoute,followings,followingsSyncStatus) VALUES(@acct,@host,@inboxRoute,@sharedInboxRoute,@followings,CAST(@followingsSyncStatus as json))", + new { acct, host, inboxRoute, sharedInboxRoute, followings, followingsSyncStatus = serializedDic }); } } @@ -68,18 +71,19 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers } } - public async Task UpdateFollowerAsync(int id, int[] followings, Dictionary followingsSyncStatus) + public async Task UpdateFollowerAsync(Follower follower) { - if (id == default) throw new ArgumentException("id"); + if (follower == default) throw new ArgumentException("follower"); + if (follower.Id == default) throw new ArgumentException("id"); - var serializedDic = JsonConvert.SerializeObject(followingsSyncStatus); + var serializedDic = JsonConvert.SerializeObject(follower.FollowingsSyncStatus); var query = $"UPDATE {_settings.FollowersTableName} SET followings = @followings, followingsSyncStatus = CAST(@followingsSyncStatus as json) WHERE id = @id"; using (var dbConnection = Connection) { dbConnection.Open(); - await dbConnection.QueryAsync(query, new { id, followings, followingsSyncStatus = serializedDic }); + await dbConnection.QueryAsync(query, new { follower.Id, follower.Followings, followingsSyncStatus = serializedDic }); } } @@ -124,7 +128,9 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers Id = follower.Id, Acct = follower.Acct, Host = follower.Host, - Followings = follower.Followings, + InboxRoute = follower.InboxRoute, + SharedInboxRoute = follower.SharedInboxRoute, + Followings = follower.Followings.ToList(), FollowingsSyncStatus = JsonConvert.DeserializeObject>(follower.FollowingsSyncStatus) }; } @@ -138,5 +144,7 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers public string Acct { get; set; } public string Host { get; set; } + public string InboxRoute { get; set; } + public string SharedInboxRoute { get; set; } } } \ No newline at end of file diff --git a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs index 8037a42..c7504ef 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL.Postgres/Settings/PostgresSettings.cs @@ -4,9 +4,9 @@ { public string ConnString { get; set; } - public string DbVersionTableName { get; set; } = "db-version"; - public string TwitterUserTableName { get; set; } = "twitter-users"; + public string DbVersionTableName { get; set; } = "db_version"; + public string TwitterUserTableName { get; set; } = "twitter_users"; public string FollowersTableName { get; set; } = "followers"; - public string CachedTweetsTableName { get; set; } = "cached-tweets"; + public string CachedTweetsTableName { get; set; } = "cached_tweets"; } } \ No newline at end of file diff --git a/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs b/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs index 92e0cb3..8b5e6e1 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL/Contracts/IFollowersDal.cs @@ -7,9 +7,10 @@ namespace BirdsiteLive.DAL.Contracts public interface IFollowersDal { Task GetFollowerAsync(string acct, string host); - Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary followingSyncStatus); + Task CreateFollowerAsync(string acct, string host, string inboxRoute, string sharedInboxRoute, int[] followings = null, + Dictionary followingSyncStatus = null); Task GetFollowersAsync(int followedUserId); - Task UpdateFollowerAsync(int id, int[] followings, Dictionary followingSyncStatus); + Task UpdateFollowerAsync(Follower follower); Task DeleteFollowerAsync(int id); Task DeleteFollowerAsync(string acct, string host); } diff --git a/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs b/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs index 5eedafb..8fbc97b 100644 --- a/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs +++ b/src/DataAccessLayers/BirdsiteLive.DAL/Models/Follower.cs @@ -6,10 +6,12 @@ namespace BirdsiteLive.DAL.Models { public int Id { get; set; } - public int[] Followings { get; set; } + public List Followings { get; set; } public Dictionary FollowingsSyncStatus { get; set; } public string Acct { get; set; } public string Host { get; set; } + public string InboxRoute { get; set; } + public string SharedInboxRoute { get; set; } } } \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs b/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs index f359f06..e12d08a 100644 --- a/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs +++ b/src/Tests/BirdsiteLive.DAL.Postgres.Tests/DataAccessLayers/FollowersPostgresDalTests.cs @@ -45,16 +45,52 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {19, 166L}, {23, 167L} }; + var inboxRoute = "/myhandle/inbox"; + var sharedInboxRoute = "/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); Assert.IsNotNull(result); Assert.AreEqual(acct, result.Acct); Assert.AreEqual(host, result.Host); - Assert.AreEqual(following.Length, result.Followings.Length); + Assert.AreEqual(inboxRoute, result.InboxRoute); + Assert.AreEqual(sharedInboxRoute, result.SharedInboxRoute); + Assert.AreEqual(following.Length, result.Followings.Count); + Assert.AreEqual(following[0], result.Followings[0]); + Assert.AreEqual(followingSync.Count, result.FollowingsSyncStatus.Count); + Assert.AreEqual(followingSync.First().Key, result.FollowingsSyncStatus.First().Key); + Assert.AreEqual(followingSync.First().Value, result.FollowingsSyncStatus.First().Value); + } + + [TestMethod] + public async Task CreateAndGetFollower_NoSharedInbox() + { + var acct = "myhandle"; + var host = "domain.ext"; + var following = new[] { 12, 19, 23 }; + var followingSync = new Dictionary() + { + {12, 165L}, + {19, 166L}, + {23, 167L} + }; + var inboxRoute = "/myhandle/inbox"; + string sharedInboxRoute = null; + + var dal = new FollowersPostgresDal(_settings); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); + + var result = await dal.GetFollowerAsync(acct, host); + + Assert.IsNotNull(result); + Assert.AreEqual(acct, result.Acct); + Assert.AreEqual(host, result.Host); + Assert.AreEqual(inboxRoute, result.InboxRoute); + Assert.AreEqual(sharedInboxRoute, result.SharedInboxRoute); + Assert.AreEqual(following.Length, result.Followings.Count); Assert.AreEqual(following[0], result.Followings[0]); Assert.AreEqual(followingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(followingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -71,19 +107,25 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers var host = "domain.ext"; var following = new[] { 1,2,3 }; var followingSync = new Dictionary(); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + var inboxRoute = "/myhandle1/inbox"; + var sharedInboxRoute = "/inbox"; + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); //User 2 acct = "myhandle2"; host = "domain.ext"; following = new[] { 2, 4, 5 }; - await dal.CreateFollowerAsync(acct, host, following, followingSync); + inboxRoute = "/myhandle2/inbox"; + sharedInboxRoute = "/inbox2"; + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); //User 2 acct = "myhandle3"; host = "domain.ext"; following = new[] { 1 }; - await dal.CreateFollowerAsync(acct, host, following, followingSync); + inboxRoute = "/myhandle3/inbox"; + sharedInboxRoute = "/inbox3"; + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowersAsync(2); Assert.AreEqual(2, result.Length); @@ -107,24 +149,28 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {19, 166L}, {23, 167L} }; + var inboxRoute = "/myhandle/inbox"; + var sharedInboxRoute = "/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); - var updatedFollowing = new[] { 12, 19, 23, 24 }; - var updatedFollowingSync = new Dictionary() - { + var updatedFollowing = new List { 12, 19, 23, 24 }; + var updatedFollowingSync = new Dictionary(){ {12, 170L}, {19, 171L}, {23, 172L}, {24, 173L} }; + result.Followings = updatedFollowing.ToList(); + result.FollowingsSyncStatus = updatedFollowingSync; + - await dal.UpdateFollowerAsync(result.Id, updatedFollowing, updatedFollowingSync); + await dal.UpdateFollowerAsync(result); result = await dal.GetFollowerAsync(acct, host); - Assert.AreEqual(updatedFollowing.Length, result.Followings.Length); + Assert.AreEqual(updatedFollowing.Count, result.Followings.Count); Assert.AreEqual(updatedFollowing[0], result.Followings[0]); Assert.AreEqual(updatedFollowingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(updatedFollowingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -143,9 +189,11 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {19, 166L}, {23, 167L} }; + var inboxRoute = "/myhandle/inbox"; + var sharedInboxRoute = "/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); var updatedFollowing = new[] { 12, 19 }; @@ -154,11 +202,13 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {12, 170L}, {19, 171L} }; + result.Followings = updatedFollowing.ToList(); + result.FollowingsSyncStatus = updatedFollowingSync; - await dal.UpdateFollowerAsync(result.Id, updatedFollowing, updatedFollowingSync); + await dal.UpdateFollowerAsync(result); result = await dal.GetFollowerAsync(acct, host); - Assert.AreEqual(updatedFollowing.Length, result.Followings.Length); + Assert.AreEqual(updatedFollowing.Length, result.Followings.Count); Assert.AreEqual(updatedFollowing[0], result.Followings[0]); Assert.AreEqual(updatedFollowingSync.Count, result.FollowingsSyncStatus.Count); Assert.AreEqual(updatedFollowingSync.First().Key, result.FollowingsSyncStatus.First().Key); @@ -177,9 +227,11 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {19, 166L}, {23, 167L} }; + var inboxRoute = "/myhandle/inbox"; + var sharedInboxRoute = "/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); Assert.IsNotNull(result); @@ -201,9 +253,11 @@ namespace BirdsiteLive.DAL.Postgres.Tests.DataAccessLayers {19, 166L}, {23, 167L} }; + var inboxRoute = "/myhandle/inbox"; + var sharedInboxRoute = "/inbox"; var dal = new FollowersPostgresDal(_settings); - await dal.CreateFollowerAsync(acct, host, following, followingSync); + await dal.CreateFollowerAsync(acct, host, inboxRoute, sharedInboxRoute, following, followingSync); var result = await dal.GetFollowerAsync(acct, host); Assert.IsNotNull(result); diff --git a/src/Tests/BirdsiteLive.Domain.Tests/BirdsiteLive.Domain.Tests.csproj b/src/Tests/BirdsiteLive.Domain.Tests/BirdsiteLive.Domain.Tests.csproj new file mode 100644 index 0000000..8762be8 --- /dev/null +++ b/src/Tests/BirdsiteLive.Domain.Tests/BirdsiteLive.Domain.Tests.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + diff --git a/src/Tests/BirdsiteLive.Domain.Tests/StatusServiceTests.cs b/src/Tests/BirdsiteLive.Domain.Tests/StatusServiceTests.cs new file mode 100644 index 0000000..e6178c3 --- /dev/null +++ b/src/Tests/BirdsiteLive.Domain.Tests/StatusServiceTests.cs @@ -0,0 +1,48 @@ +using System; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BirdsiteLive.Domain.Tests +{ + [TestClass] + public class StatusServiceTests + { + private readonly InstanceSettings _settings; + + #region Ctor + public StatusServiceTests() + { + _settings = new InstanceSettings + { + Domain = "domain.name" + }; + } + #endregion + +// [TestMethod] +// public void ExtractMentionsTest() +// { +// #region Stubs +// var username = "MyUserName"; +// var extractedTweet = new ExtractedTweet +// { +// Id = 124L, +// CreatedAt = DateTime.UtcNow, +// MessageContent = @"Getting ready for the weekend...have a great one everyone! +//⁠ +//Photo by Tim Tronckoe | @timtronckoe +//⁠ +//#archenemy #michaelamott #alissawhitegluz #jeffloomis #danielerlandsson #sharleedangelo⁠" +// }; +// #endregion + +// var service = new StatusService(_settings); +// var result = service.GetStatus(username, extractedTweet); + +// #region Validations + +// #endregion +// } + } +} diff --git a/src/Tests/BirdsiteLive.Domain.Tests/Tools/StatusExtractorTests.cs b/src/Tests/BirdsiteLive.Domain.Tests/Tools/StatusExtractorTests.cs new file mode 100644 index 0000000..5728cc5 --- /dev/null +++ b/src/Tests/BirdsiteLive.Domain.Tests/Tools/StatusExtractorTests.cs @@ -0,0 +1,318 @@ +using System; +using System.Linq; +using BirdsiteLive.Common.Settings; +using BirdsiteLive.Domain.Tools; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BirdsiteLive.Domain.Tests.Tools +{ + [TestClass] + public class StatusExtractorTests + { + private readonly InstanceSettings _settings; + + #region Ctor + public StatusExtractorTests() + { + _settings = new InstanceSettings + { + Domain = "domain.name" + }; + } + #endregion + + [TestMethod] + public void Extract_ReturnLines_Test() + { + #region Stubs + var message = "Bla.\n\n@Mention blo. https://t.co/pgtrJi9600"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.IsTrue(result.content.Contains("Bla.")); + Assert.IsTrue(result.content.Contains("

")); + #endregion + } + + [TestMethod] + public void Extract_ReturnSingleLines_Test() + { + #region Stubs + var message = "Bla.\n@Mention blo. https://t.co/pgtrJi9600"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.IsTrue(result.content.Contains("Bla.")); + Assert.IsTrue(result.content.Contains("
")); + #endregion + } + + [TestMethod] + public void Extract_FormatUrl_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}https://t.co/L8BpyHgg25"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(0, result.tags.Length); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"https://t.co/L8BpyHgg25")); + #endregion + } + + [TestMethod] + public void Extract_FormatUrl_Long_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(0, result.tags.Length); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content")); + #endregion + } + + [TestMethod] + public void Extract_FormatUrl_Exact_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}https://www.eff.org/deeplinks/2020/07/pact"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(0, result.tags.Length); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"https://www.eff.org/deeplinks/2020/07/pact")); + #endregion + } + + [TestMethod] + public void Extract_MultiUrls__Test() + { + #region Stubs + var message = $"https://t.co/L8BpyHgg25 Bla!{Environment.NewLine}https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(0, result.tags.Length); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"https://t.co/L8BpyHgg25")); + + Assert.IsTrue(result.content.Contains(@"https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content")); + #endregion + } + + [TestMethod] + public void Extract_SingleHashTag_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}#mytag⁠"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("#mytag", result.tags.First().name); + Assert.AreEqual("Hashtag", result.tags.First().type); + Assert.AreEqual("https://domain.name/tags/mytag", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"#mytag")); + #endregion + } + + [TestMethod] + public void Extract_SingleHashTag_AtStart_Test() + { + #region Stubs + var message = $"#mytag⁠ Bla!"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("#mytag", result.tags.First().name); + Assert.AreEqual("Hashtag", result.tags.First().type); + Assert.AreEqual("https://domain.name/tags/mytag", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"#mytag")); + #endregion + } + + [TestMethod] + public void Extract_SingleHashTag_SpecialChar_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}#COVIDー19⁠"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("#COVIDー19", result.tags.First().name); + Assert.AreEqual("Hashtag", result.tags.First().type); + Assert.AreEqual("https://domain.name/tags/COVIDー19", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"#COVIDー19")); + #endregion + } + + [TestMethod] + public void Extract_MultiHashTags_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}#mytag #mytag2 #mytag3⁠{Environment.NewLine}Test #bal Test"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(4, result.tags.Length); + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"#mytag")); + Assert.IsTrue(result.content.Contains(@"#mytag2")); + Assert.IsTrue(result.content.Contains(@"#mytag3")); + Assert.IsTrue(result.content.Contains(@"#bal")); + #endregion + } + + [TestMethod] + public void Extract_SingleMentionTag_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}@mynickname⁠"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("@mynickname@domain.name", result.tags.First().name); + Assert.AreEqual("Mention", result.tags.First().type); + Assert.AreEqual("https://domain.name/users/mynickname", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"@mynickname")); + #endregion + } + + [TestMethod] + public void Extract_SingleMentionTag_SpecialChar_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}@my___nickname⁠"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("@my___nickname@domain.name", result.tags.First().name); + Assert.AreEqual("Mention", result.tags.First().type); + Assert.AreEqual("https://domain.name/users/my___nickname", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"@my___nickname")); + #endregion + } + + [TestMethod] + public void Extract_SingleMentionTag_AtStart_Test() + { + #region Stubs + var message = $"@mynickname Bla!"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(1, result.tags.Length); + Assert.AreEqual("@mynickname@domain.name", result.tags.First().name); + Assert.AreEqual("Mention", result.tags.First().type); + Assert.AreEqual("https://domain.name/users/mynickname", result.tags.First().href); + + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"@mynickname")); + #endregion + } + + [TestMethod] + public void Extract_MultiMentionTag_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}@mynickname⁠ @mynickname2 @mynickname3{Environment.NewLine}Test @dada Test"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(4, result.tags.Length); + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"@mynickname")); + Assert.IsTrue(result.content.Contains(@"@mynickname2")); + Assert.IsTrue(result.content.Contains(@"@mynickname3")); + Assert.IsTrue(result.content.Contains(@"@dada")); + #endregion + } + + [TestMethod] + public void Extract_HeterogeneousTag_Test() + { + #region Stubs + var message = $"Bla!{Environment.NewLine}@mynickname⁠ #mytag2 @mynickname3{Environment.NewLine}Test @dada #dada Test"; + #endregion + + var service = new StatusExtractor(_settings); + var result = service.ExtractTags(message); + + #region Validations + Assert.AreEqual(5, result.tags.Length); + Assert.IsTrue(result.content.Contains("Bla!")); + Assert.IsTrue(result.content.Contains(@"@mynickname")); + Assert.IsTrue(result.content.Contains(@"#mytag2")); + Assert.IsTrue(result.content.Contains(@"@mynickname3")); + Assert.IsTrue(result.content.Contains(@"@dada")); + Assert.IsTrue(result.content.Contains(@"#dada")); + #endregion + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/BirdsiteLive.Pipeline.Tests.csproj b/src/Tests/BirdsiteLive.Pipeline.Tests/BirdsiteLive.Pipeline.Tests.csproj new file mode 100644 index 0000000..3dd6984 --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/BirdsiteLive.Pipeline.Tests.csproj @@ -0,0 +1,21 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + + diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveFollowersProcessorTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveFollowersProcessorTests.cs new file mode 100644 index 0000000..98a86bf --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveFollowersProcessorTests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Models; +using BirdsiteLive.Pipeline.Processors; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors +{ + [TestClass] + public class RetrieveFollowersProcessorTests + { + [TestMethod] + public async Task ProcessAsync_Test() + { + #region Stubs + var userId1 = 1; + var userId2 = 2; + + var users = new List + { + new UserWithTweetsToSync + { + User = new SyncTwitterUser + { + Id = userId1 + } + }, + new UserWithTweetsToSync + { + User = new SyncTwitterUser + { + Id = userId2 + } + } + }; + + var followersUser1 = new List + { + new Follower(), + new Follower(), + }; + var followersUser2 = new List + { + new Follower(), + new Follower(), + new Follower(), + }; + #endregion + + #region Mocks + var followersDalMock = new Mock(MockBehavior.Strict); + followersDalMock + .Setup(x => x.GetFollowersAsync(It.Is(y => y == userId1))) + .ReturnsAsync(followersUser1.ToArray()); + + followersDalMock + .Setup(x => x.GetFollowersAsync(It.Is(y => y == userId2))) + .ReturnsAsync(followersUser2.ToArray()); + #endregion + + var processor = new RetrieveFollowersProcessor(followersDalMock.Object); + var result = (await processor.ProcessAsync(users.ToArray(), CancellationToken.None)).ToList(); + + #region Validations + Assert.IsNotNull(result); + Assert.AreEqual(2, result.Count); + Assert.AreEqual(2, result.First(x => x.User.Id == userId1).Followers.Length); + Assert.AreEqual(3, result.First(x => x.User.Id == userId2).Followers.Length); + + followersDalMock.VerifyAll(); + #endregion + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTweetsProcessorTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTweetsProcessorTests.cs new file mode 100644 index 0000000..8873bf7 --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTweetsProcessorTests.cs @@ -0,0 +1,193 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Processors; +using BirdsiteLive.Twitter; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors +{ + [TestClass] + public class RetrieveTweetsProcessorTests + { + [TestMethod] + public async Task ProcessAsync_UserNotSync_Test() + { + #region Stubs + var user1 = new SyncTwitterUser + { + Id = 1, + Acct = "acct", + LastTweetPostedId = -1 + }; + + var users = new[] + { + user1 + }; + + var tweets = new[] + { + new ExtractedTweet + { + Id = 47 + } + }; + #endregion + + #region Mocks + var twitterServiceMock = new Mock(MockBehavior.Strict); + twitterServiceMock + .Setup(x => x.GetTimeline( + It.Is(y => y == user1.Acct), + It.Is(y => y == 1), + It.Is(y => y == -1) + )) + .Returns(tweets); + + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.UpdateTwitterUserAsync( + It.Is(y => y == user1.Id), + It.Is(y => y == tweets.Last().Id), + It.Is(y => y == tweets.Last().Id) + )) + .Returns(Task.CompletedTask); + #endregion + + var processor = new RetrieveTweetsProcessor(twitterServiceMock.Object, twitterUserDalMock.Object); + var usersResult = await processor.ProcessAsync(users, CancellationToken.None); + + #region Validations + twitterServiceMock.VerifyAll(); + twitterUserDalMock.VerifyAll(); + + Assert.AreEqual(0, usersResult.Length); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_UserSync_Test() + { + #region Stubs + var user1 = new SyncTwitterUser + { + Id = 1, + Acct = "acct", + LastTweetPostedId = 46, + LastTweetSynchronizedForAllFollowersId = 46 + }; + + var users = new[] + { + user1 + }; + + var tweets = new[] + { + new ExtractedTweet + { + Id = 47 + }, + new ExtractedTweet + { + Id = 48 + }, + new ExtractedTweet + { + Id = 49 + } + }; + #endregion + + #region Mocks + var twitterServiceMock = new Mock(MockBehavior.Strict); + twitterServiceMock + .Setup(x => x.GetTimeline( + It.Is(y => y == user1.Acct), + It.Is(y => y == 200), + It.Is(y => y == user1.LastTweetSynchronizedForAllFollowersId) + )) + .Returns(tweets); + + var twitterUserDalMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new RetrieveTweetsProcessor(twitterServiceMock.Object, twitterUserDalMock.Object); + var usersResult = await processor.ProcessAsync(users, CancellationToken.None); + + #region Validations + twitterServiceMock.VerifyAll(); + twitterUserDalMock.VerifyAll(); + + Assert.AreEqual(users.Length, usersResult.Length); + Assert.AreEqual(users[0].Acct, usersResult[0].User.Acct); + Assert.AreEqual(tweets.Length, usersResult[0].Tweets.Length); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_UserPartiallySync_Test() + { + #region Stubs + var user1 = new SyncTwitterUser + { + Id = 1, + Acct = "acct", + LastTweetPostedId = 49, + LastTweetSynchronizedForAllFollowersId = 46 + }; + + var users = new[] + { + user1 + }; + + var tweets = new[] + { + new ExtractedTweet + { + Id = 47 + }, + new ExtractedTweet + { + Id = 48 + }, + new ExtractedTweet + { + Id = 49 + } + }; + #endregion + + #region Mocks + var twitterServiceMock = new Mock(MockBehavior.Strict); + twitterServiceMock + .Setup(x => x.GetTimeline( + It.Is(y => y == user1.Acct), + It.Is(y => y == 200), + It.Is(y => y == user1.LastTweetSynchronizedForAllFollowersId) + )) + .Returns(tweets); + + var twitterUserDalMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new RetrieveTweetsProcessor(twitterServiceMock.Object, twitterUserDalMock.Object); + var usersResult = await processor.ProcessAsync(users, CancellationToken.None); + + #region Validations + twitterServiceMock.VerifyAll(); + twitterUserDalMock.VerifyAll(); + + Assert.AreEqual(users.Length, usersResult.Length); + Assert.AreEqual(users[0].Acct, usersResult[0].User.Acct); + Assert.AreEqual(tweets.Length, usersResult[0].Tweets.Length); + #endregion + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTwitterUsersProcessorTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTwitterUsersProcessorTests.cs new file mode 100644 index 0000000..b7a2e2b --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/RetrieveTwitterUsersProcessorTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Processors; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors +{ + [TestClass] + public class RetrieveTwitterUsersProcessorTests + { + [TestMethod] + public async Task GetTwitterUsersAsync_Test() + { + #region Stubs + var buffer = new BufferBlock(); + var users = new[] + { + new SyncTwitterUser(), + new SyncTwitterUser(), + new SyncTwitterUser(), + }; + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.GetAllTwitterUsersAsync()) + .ReturnsAsync(users); + #endregion + + var processor = new RetrieveTwitterUsersProcessor(twitterUserDalMock.Object); + processor.GetTwitterUsersAsync(buffer, CancellationToken.None); + + await Task.Delay(50); + + #region Validations + twitterUserDalMock.VerifyAll(); + Assert.AreEqual(1, buffer.Count); + buffer.TryReceive(out var result); + Assert.AreEqual(3, result.Length); + #endregion + } + + [TestMethod] + public async Task GetTwitterUsersAsync_NoUsers_Test() + { + #region Stubs + var buffer = new BufferBlock(); + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.GetAllTwitterUsersAsync()) + .ReturnsAsync(new SyncTwitterUser[0]); + #endregion + + var processor = new RetrieveTwitterUsersProcessor(twitterUserDalMock.Object); + processor.GetTwitterUsersAsync(buffer, CancellationToken.None); + + await Task.Delay(50); + + #region Validations + twitterUserDalMock.VerifyAll(); + Assert.AreEqual(0, buffer.Count); + #endregion + } + + + [TestMethod] + public async Task GetTwitterUsersAsync_Exception_Test() + { + #region Stubs + var buffer = new BufferBlock(); + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.GetAllTwitterUsersAsync()) + .Throws(new Exception()); + #endregion + + var processor = new RetrieveTwitterUsersProcessor(twitterUserDalMock.Object); + var t = processor.GetTwitterUsersAsync(buffer, CancellationToken.None); + + await Task.WhenAny(t, Task.Delay(50)); + + #region Validations + twitterUserDalMock.VerifyAll(); + Assert.AreEqual(0, buffer.Count); + #endregion + } + + + [TestMethod] + [ExpectedException(typeof(OperationCanceledException))] + public async Task GetTwitterUsersAsync_Cancellation_Test() + { + #region Stubs + var buffer = new BufferBlock(); + var canTokenS = new CancellationTokenSource(); + canTokenS.Cancel(); + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new RetrieveTwitterUsersProcessor(twitterUserDalMock.Object); + await processor.GetTwitterUsersAsync(buffer, canTokenS.Token); + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SaveProgressionProcessorTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SaveProgressionProcessorTests.cs new file mode 100644 index 0000000..d3880e6 --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SaveProgressionProcessorTests.cs @@ -0,0 +1,210 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Models; +using BirdsiteLive.Pipeline.Processors; +using BirdsiteLive.Twitter.Models; +using Castle.DynamicProxy.Contributors; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors +{ + [TestClass] + public class SaveProgressionProcessorTests + { + [TestMethod] + public async Task ProcessAsync_Test() + { + #region Stubs + var user = new SyncTwitterUser + { + Id = 1 + }; + var tweet1 = new ExtractedTweet + { + Id = 36 + }; + var tweet2 = new ExtractedTweet + { + Id = 37 + }; + var follower1 = new Follower + { + FollowingsSyncStatus = new Dictionary + { + {1, 37} + } + }; + + var usersWithTweets = new UserWithTweetsToSync + { + Tweets = new [] + { + tweet1, + tweet2 + }, + Followers = new [] + { + follower1 + }, + User = user + }; + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.UpdateTwitterUserAsync( + It.Is(y => y == user.Id), + It.Is(y => y == tweet2.Id), + It.Is(y => y == tweet2.Id) + )) + .Returns(Task.CompletedTask); + #endregion + + var processor = new SaveProgressionProcessor(twitterUserDalMock.Object); + await processor.ProcessAsync(usersWithTweets, CancellationToken.None); + + #region Validations + twitterUserDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_PartiallySynchronized_Test() + { + #region Stubs + var user = new SyncTwitterUser + { + Id = 1 + }; + var tweet1 = new ExtractedTweet + { + Id = 36 + }; + var tweet2 = new ExtractedTweet + { + Id = 37 + }; + var tweet3 = new ExtractedTweet + { + Id = 38 + }; + var follower1 = new Follower + { + FollowingsSyncStatus = new Dictionary + { + {1, 37} + } + }; + + var usersWithTweets = new UserWithTweetsToSync + { + Tweets = new[] + { + tweet1, + tweet2, + tweet3 + }, + Followers = new[] + { + follower1 + }, + User = user + }; + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.UpdateTwitterUserAsync( + It.Is(y => y == user.Id), + It.Is(y => y == tweet3.Id), + It.Is(y => y == tweet2.Id) + )) + .Returns(Task.CompletedTask); + #endregion + + var processor = new SaveProgressionProcessor(twitterUserDalMock.Object); + await processor.ProcessAsync(usersWithTweets, CancellationToken.None); + + #region Validations + twitterUserDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_PartiallySynchronized_MultiUsers_Test() + { + #region Stubs + var user = new SyncTwitterUser + { + Id = 1 + }; + var tweet1 = new ExtractedTweet + { + Id = 36 + }; + var tweet2 = new ExtractedTweet + { + Id = 37 + }; + var tweet3 = new ExtractedTweet + { + Id = 38 + }; + var follower1 = new Follower + { + FollowingsSyncStatus = new Dictionary + { + {1, 37} + } + }; + var follower2 = new Follower + { + FollowingsSyncStatus = new Dictionary + { + {1, 38} + } + }; + + var usersWithTweets = new UserWithTweetsToSync + { + Tweets = new[] + { + tweet1, + tweet2, + tweet3 + }, + Followers = new[] + { + follower1, + follower2 + }, + User = user + }; + #endregion + + #region Mocks + var twitterUserDalMock = new Mock(MockBehavior.Strict); + twitterUserDalMock + .Setup(x => x.UpdateTwitterUserAsync( + It.Is(y => y == user.Id), + It.Is(y => y == tweet3.Id), + It.Is(y => y == tweet2.Id) + )) + .Returns(Task.CompletedTask); + #endregion + + var processor = new SaveProgressionProcessor(twitterUserDalMock.Object); + await processor.ProcessAsync(usersWithTweets, CancellationToken.None); + + #region Validations + twitterUserDalMock.VerifyAll(); + #endregion + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SendTweetsToFollowersProcessorTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SendTweetsToFollowersProcessorTests.cs new file mode 100644 index 0000000..034d8d2 --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SendTweetsToFollowersProcessorTests.cs @@ -0,0 +1,426 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Pipeline.Models; +using BirdsiteLive.Pipeline.Processors; +using BirdsiteLive.Pipeline.Processors.SubTasks; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors +{ + [TestClass] + public class SendTweetsToFollowersProcessorTests + { + [TestMethod] + public async Task ProcessAsync_SameInstance_SharedInbox_OneTweet_Test() + { + #region Stubs + var tweetId = 1; + var host = "domain.ext"; + var sharedInbox = "/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new [] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new [] + { + new Follower + { + Id = userId1, + Host = host, + SharedInboxRoute = sharedInbox + }, + new Follower + { + Id = userId2, + Host = host, + SharedInboxRoute = sharedInbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + sendTweetsToSharedInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Acct == userAcct), + It.Is(y => y == host), + It.Is(y => y.Length == 2))) + .Returns(Task.CompletedTask); + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_MultiInstances_SharedInbox_OneTweet_Test() + { + #region Stubs + var tweetId = 1; + var host1 = "domain1.ext"; + var host2 = "domain2.ext"; + var sharedInbox = "/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new[] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new[] + { + new Follower + { + Id = userId1, + Host = host1, + SharedInboxRoute = sharedInbox + }, + new Follower + { + Id = userId2, + Host = host2, + SharedInboxRoute = sharedInbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + foreach (var host in new [] { host1, host2}) + { + sendTweetsToSharedInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Acct == userAcct), + It.Is(y => y == host), + It.Is(y => y.Length == 1))) + .Returns(Task.CompletedTask); + } + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_MultiInstances_SharedInbox_OneTweet_Error_Test() + { + #region Stubs + var tweetId = 1; + var host1 = "domain1.ext"; + var host2 = "domain2.ext"; + var sharedInbox = "/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new[] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new[] + { + new Follower + { + Id = userId1, + Host = host1, + SharedInboxRoute = sharedInbox + }, + new Follower + { + Id = userId2, + Host = host2, + SharedInboxRoute = sharedInbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + sendTweetsToSharedInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Acct == userAcct), + It.Is(y => y == host1), + It.Is(y => y.Length == 1))) + .Returns(Task.CompletedTask); + + sendTweetsToSharedInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Acct == userAcct), + It.Is(y => y == host2), + It.Is(y => y.Length == 1))) + .Throws(new Exception()); + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_SameInstance_Inbox_OneTweet_Test() + { + #region Stubs + var tweetId = 1; + var host = "domain.ext"; + var inbox = "/user/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new[] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new[] + { + new Follower + { + Id = userId1, + Host = host, + InboxRoute = inbox + }, + new Follower + { + Id = userId2, + Host = host, + InboxRoute = inbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + foreach (var userId in new[] { userId1, userId2 }) + { + sendTweetsToInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Id == userId), + It.Is(y => y.Acct == userAcct))) + .Returns(Task.CompletedTask); + } + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_MultiInstances_Inbox_OneTweet_Test() + { + #region Stubs + var tweetId = 1; + var host1 = "domain1.ext"; + var host2 = "domain2.ext"; + var inbox = "/user/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new[] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new[] + { + new Follower + { + Id = userId1, + Host = host1, + InboxRoute = inbox + }, + new Follower + { + Id = userId2, + Host = host2, + InboxRoute = inbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + foreach (var userId in new[] { userId1, userId2 }) + { + sendTweetsToInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Id == userId), + It.Is(y => y.Acct == userAcct))) + .Returns(Task.CompletedTask); + } + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ProcessAsync_MultiInstances_Inbox_OneTweet_Error_Test() + { + #region Stubs + var tweetId = 1; + var host1 = "domain1.ext"; + var host2 = "domain2.ext"; + var inbox = "/user/inbox"; + var userId1 = 2; + var userId2 = 3; + var userAcct = "user"; + + var userWithTweets = new UserWithTweetsToSync() + { + Tweets = new[] + { + new ExtractedTweet + { + Id = tweetId + } + }, + User = new SyncTwitterUser + { + Acct = userAcct + }, + Followers = new[] + { + new Follower + { + Id = userId1, + Host = host1, + InboxRoute = inbox + }, + new Follower + { + Id = userId2, + Host = host2, + InboxRoute = inbox + }, + } + }; + #endregion + + #region Mocks + var sendTweetsToInboxTaskMock = new Mock(MockBehavior.Strict); + sendTweetsToInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Id == userId1), + It.Is(y => y.Acct == userAcct))) + .Returns(Task.CompletedTask); + + sendTweetsToInboxTaskMock + .Setup(x => x.ExecuteAsync( + It.Is(y => y.Length == 1), + It.Is(y => y.Id == userId2), + It.Is(y => y.Acct == userAcct))) + .Throws(new Exception()); + + var sendTweetsToSharedInboxTaskMock = new Mock(MockBehavior.Strict); + #endregion + + var processor = new SendTweetsToFollowersProcessor(sendTweetsToInboxTaskMock.Object, sendTweetsToSharedInboxTaskMock.Object); + var result = await processor.ProcessAsync(userWithTweets, CancellationToken.None); + + #region Validations + sendTweetsToInboxTaskMock.VerifyAll(); + sendTweetsToSharedInboxTaskMock.VerifyAll(); + #endregion + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToInboxTaskTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToInboxTaskTests.cs new file mode 100644 index 0000000..0596162 --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToInboxTaskTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; +using BirdsiteLive.Pipeline.Processors.SubTasks; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors.SubTasks +{ + [TestClass] + public class SendTweetsToInboxTaskTests + { + [TestMethod] + public async Task ExecuteAsync_SingleTweet_Test() + { + #region Stubs + var tweetId = 10; + var tweets = new List + { + new ExtractedTweet + { + Id = tweetId, + } + }; + + var noteId = "noteId"; + var note = new Note() + { + id = noteId + }; + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/user/inbox"; + var follower = new Follower + { + Id = 1, + Host = host, + InboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 9 } } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == noteId), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + + var statusServiceMock = new Mock(MockBehavior.Strict); + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(note); + + var followersDalMock = new Mock(MockBehavior.Strict); + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId))) + .Returns(Task.CompletedTask); + + #endregion + + var task = new SendTweetsToInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + await task.ExecuteAsync(tweets.ToArray(), follower, twitterUser); + + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ExecuteAsync_MultipleTweets_Test() + { + #region Stubs + var tweetId1 = 10; + var tweetId2 = 11; + var tweetId3 = 12; + var tweets = new List(); + foreach (var tweetId in new[] { tweetId1, tweetId2, tweetId3 }) + { + tweets.Add(new ExtractedTweet + { + Id = tweetId + }); + } + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/user/inbox"; + var follower = new Follower + { + Id = 1, + Host = host, + InboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 10 } } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + } + + var statusServiceMock = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(new Note { id = tweetId.ToString() }); + } + + var followersDalMock = new Mock(MockBehavior.Strict); + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId3))) + .Returns(Task.CompletedTask); + + #endregion + + var task = new SendTweetsToInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + await task.ExecuteAsync(tweets.ToArray(), follower, twitterUser); + + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task ExecuteAsync_MultipleTweets_Error_Test() + { + #region Stubs + var tweetId1 = 10; + var tweetId2 = 11; + var tweetId3 = 12; + var tweets = new List(); + foreach (var tweetId in new[] { tweetId1, tweetId2, tweetId3 }) + { + tweets.Add(new ExtractedTweet + { + Id = tweetId + }); + } + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/user/inbox"; + var follower = new Follower + { + Id = 1, + Host = host, + InboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 10 } } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId2.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId2.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId3.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId3.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.InternalServerError); + + var statusServiceMock = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(new Note { id = tweetId.ToString() }); + } + + var followersDalMock = new Mock(MockBehavior.Strict); + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId2))) + .Returns(Task.CompletedTask); + + #endregion + + var task = new SendTweetsToInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + + try + { + await task.ExecuteAsync(tweets.ToArray(), follower, twitterUser); + } + finally + { + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + } + } +} \ No newline at end of file diff --git a/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToSharedInboxTests.cs b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToSharedInboxTests.cs new file mode 100644 index 0000000..a052a5c --- /dev/null +++ b/src/Tests/BirdsiteLive.Pipeline.Tests/Processors/SubTasks/SendTweetsToSharedInboxTests.cs @@ -0,0 +1,322 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BirdsiteLive.ActivityPub.Models; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; +using BirdsiteLive.Domain; +using BirdsiteLive.Pipeline.Processors.SubTasks; +using BirdsiteLive.Twitter.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace BirdsiteLive.Pipeline.Tests.Processors.SubTasks +{ + [TestClass] + public class SendTweetsToSharedInboxTests + { + [TestMethod] + public async Task ExecuteAsync_SingleTweet_Test() + { + #region Stubs + var tweetId = 10; + var tweets = new List + { + new ExtractedTweet + { + Id = tweetId, + } + }; + + var noteId = "noteId"; + var note = new Note() + { + id = noteId + }; + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/inbox"; + var followers = new List + { + new Follower + { + Id = 1, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 9 } } + }, + new Follower + { + Id = 2, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 8 } } + }, + new Follower + { + Id = 3, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary { { twitterUserId, 7 } } + } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == noteId), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + + var statusServiceMock = new Mock(MockBehavior.Strict); + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(note); + + var followersDalMock = new Mock(MockBehavior.Strict); + + foreach (var follower in followers) + { + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId))) + .Returns(Task.CompletedTask); + } + #endregion + + var task = new SendTweetsToSharedInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + await task.ExecuteAsync(tweets.ToArray(), twitterUser, host, followers.ToArray()); + + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + public async Task ExecuteAsync_MultipleTweets_Test() + { + #region Stubs + var tweetId1 = 10; + var tweetId2 = 11; + var tweetId3 = 12; + var tweets = new List(); + foreach (var tweetId in new[] { tweetId1, tweetId2, tweetId3 }) + { + tweets.Add(new ExtractedTweet + { + Id = tweetId + }); + } + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/inbox"; + var followers = new List + { + new Follower + { + Id = 1, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 10}} + }, + new Follower + { + Id = 2, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 8}} + }, + new Follower + { + Id = 3, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 7}} + } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + } + + var statusServiceMock = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(new Note { id = tweetId.ToString() }); + } + + var followersDalMock = new Mock(MockBehavior.Strict); + + foreach (var follower in followers) + { + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId3))) + .Returns(Task.CompletedTask); + } + #endregion + + var task = new SendTweetsToSharedInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + await task.ExecuteAsync(tweets.ToArray(), twitterUser, host, followers.ToArray()); + + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + + [TestMethod] + [ExpectedException(typeof(Exception))] + public async Task ExecuteAsync_MultipleTweets_Error_Test() + { + #region Stubs + var tweetId1 = 10; + var tweetId2 = 11; + var tweetId3 = 12; + var tweets = new List(); + foreach (var tweetId in new[] { tweetId1, tweetId2, tweetId3 }) + { + tweets.Add(new ExtractedTweet + { + Id = tweetId + }); + } + + var twitterHandle = "Test"; + var twitterUserId = 7; + var twitterUser = new SyncTwitterUser + { + Id = twitterUserId, + Acct = twitterHandle + }; + + var host = "domain.ext"; + var inbox = "/inbox"; + var followers = new List + { + new Follower + { + Id = 1, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 10}} + }, + new Follower + { + Id = 2, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 8}} + }, + new Follower + { + Id = 3, + Host = host, + SharedInboxRoute = inbox, + FollowingsSyncStatus = new Dictionary {{twitterUserId, 7}} + } + }; + #endregion + + #region Mocks + var activityPubService = new Mock(MockBehavior.Strict); + + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId2.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId2.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.Accepted); + + activityPubService + .Setup(x => x.PostNewNoteActivity( + It.Is(y => y.id == tweetId3.ToString()), + It.Is(y => y == twitterHandle), + It.Is(y => y == tweetId3.ToString()), + It.Is(y => y == host), + It.Is(y => y == inbox))) + .ReturnsAsync(HttpStatusCode.InternalServerError); + + var statusServiceMock = new Mock(MockBehavior.Strict); + foreach (var tweetId in new[] { tweetId2, tweetId3 }) + { + statusServiceMock + .Setup(x => x.GetStatus( + It.Is(y => y == twitterHandle), + It.Is(y => y.Id == tweetId))) + .Returns(new Note { id = tweetId.ToString() }); + } + + var followersDalMock = new Mock(MockBehavior.Strict); + + foreach (var follower in followers) + { + followersDalMock + .Setup(x => x.UpdateFollowerAsync( + It.Is(y => y.Id == follower.Id && y.FollowingsSyncStatus[twitterUserId] == tweetId2))) + .Returns(Task.CompletedTask); + } + #endregion + + var task = new SendTweetsToSharedInboxTask(activityPubService.Object, statusServiceMock.Object, followersDalMock.Object); + + try + { + await task.ExecuteAsync(tweets.ToArray(), twitterUser, host, followers.ToArray()); + } + finally + { + #region Validations + activityPubService.VerifyAll(); + statusServiceMock.VerifyAll(); + followersDalMock.VerifyAll(); + #endregion + } + } + } +} \ No newline at end of file