Merge pull request #20 from NicolasConstant/develop

Develop
This commit is contained in:
Nicolas Constant 2020-12-29 07:04:41 +01:00 committed by GitHub
commit 62e0c3ee79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 4029 additions and 314 deletions

View file

@ -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:

View file

@ -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

39
docker-compose.yml Normal file
View file

@ -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

View file

@ -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<Activity>(json);
switch (activity.type)
try
{
case "Follow":
return JsonConvert.DeserializeObject<ActivityFollow>(json);
case "Undo":
var a = JsonConvert.DeserializeObject<ActivityUndo>(json);
if(a.apObject.type == "Follow")
return JsonConvert.DeserializeObject<ActivityUndoFollow>(json);
break;
case "Accept":
var accept = JsonConvert.DeserializeObject<ActivityAccept>(json);
//var acceptType = JsonConvert.DeserializeObject<Activity>(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<Activity>(json);
switch (activity.type)
{
case "Follow":
return JsonConvert.DeserializeObject<ActivityFollow>(json);
case "Undo":
var a = JsonConvert.DeserializeObject<ActivityUndo>(json);
if(a.apObject.type == "Follow")
return JsonConvert.DeserializeObject<ActivityUndoFollow>(json);
break;
case "Accept":
var accept = JsonConvert.DeserializeObject<ActivityAccept>(json);
//var acceptType = JsonConvert.DeserializeObject<Activity>(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;

View file

@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="System.Text.Json" Version="4.7.2" />
</ItemGroup>

View file

@ -0,0 +1,10 @@
using Newtonsoft.Json;
namespace BirdsiteLive.ActivityPub
{
public class ActivityAcceptUndoFollow : Activity
{
[JsonProperty("object")]
public ActivityUndoFollow apObject { get; set; }
}
}

View file

@ -1,4 +1,5 @@
using System;
using BirdsiteLive.ActivityPub.Models;
using Newtonsoft.Json;
namespace BirdsiteLive.ActivityPub

View file

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

View file

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

View file

@ -0,0 +1,7 @@
namespace BirdsiteLive.ActivityPub
{
public class EndPoints
{
public string sharedInbox { get; set; }
}
}

View file

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

View file

@ -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<string,string> 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<string, string> replies;
}
}

View file

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

View file

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

View file

@ -3,5 +3,6 @@
public class InstanceSettings
{
public string Domain { get; set; }
public string AdminEmail { get; set; }
}
}

View file

@ -4,7 +4,5 @@
{
public string ConsumerKey { get; set; }
public string ConsumerSecret { get; set; }
public string AccessToken { get; set; }
public string AccessTokenSecret { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace BirdsiteLive.Common.Structs
{
public struct DbTypes
{
public static string Postgres = "postgres";
}
}

View file

@ -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<Actor> GetUser(string objectId);
Task<HttpStatusCode> PostDataAsync<T>(T data, string targetHost, string actorUrl, string inbox = null);
Task<HttpStatusCode> 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<HttpStatusCode> 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<HttpStatusCode> PostDataAsync<T>(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;
}
}
}

View file

@ -8,6 +8,7 @@
<ProjectReference Include="..\BirdsiteLive.ActivityPub\BirdsiteLive.ActivityPub.csproj" />
<ProjectReference Include="..\BirdsiteLive.Cryptography\BirdsiteLive.Cryptography.csproj" />
<ProjectReference Include="..\BirdsiteLive.Twitter\BirdsiteLive.Twitter.csproj" />
<ProjectReference Include="..\DataAccessLayers\BirdsiteLive.DAL\BirdsiteLive.DAL.csproj" />
</ItemGroup>
</Project>

View file

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

View file

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

View file

@ -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
/// <param name="actor">in the form of https://domain.io/actor</param>
/// <param name="host">in the form of domain.io</param>
/// <returns></returns>
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);
}
}
}
}

View file

@ -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 = $"<p>{extractedTags.content}</p>",
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();
}
}
}

View file

@ -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\-\.,@?^=%&amp;:/~\+#]*[\w\-\@?^=%&amp;/~\+#])?)");
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<Tag>();
messageContent = $" {messageContent} ";
// Replace return lines
messageContent = Regex.Replace(messageContent, @"\r\n\r\n?|\n\n", "</p><p> ");
messageContent = Regex.Replace(messageContent, @"\r\n?|\n", "<br/> ");
// 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(),
$@" <a href=""{url}"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">{protocol}</span><span class=""ellipsis"">{firstPart}</span><span class=""invisible"">{secondPart}</span></a>");
}
// 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(),
$@" <a href=""{url}"" class=""mention hashtag"" rel=""tag"">#<span>{tag}</span></a>");
}
// 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(),
$@" <span class=""h-card""><a href=""https://{_instanceSettings.Domain}/@{mention}"" class=""u-url mention"">@<span>{mention}</span></a></span>");
}
// Clean up return lines
messageContent = Regex.Replace(messageContent, @"<p> ", "<p>");
messageContent = Regex.Replace(messageContent, @"<br/> ", "<br/>");
return (messageContent.Trim(), tags.ToArray());
}
private IEnumerable<Match> OrderByLength(MatchCollection matches)
{
var result = new List<Match>();
foreach (Match m in matches) result.Add(m);
result = result.OrderByDescending(x => x.Length).ToList();
return result;
}
}
}

View file

@ -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<bool> FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary<string, string> requestHeaders, ActivityFollow activity);
Note GetStatus(TwitterUser user, ITweet tweet);
Task<bool> FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary<string, string> requestHeaders, ActivityFollow activity, string body);
Task<bool> UndoFollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary<string, string> 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 = $"<p>{tweet.Text}</p>",
attachment = new string[0],
tag = new string[0]
};
return note;
}
public async Task<bool> FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary<string, string> requestHeaders, ActivityFollow activity)
public async Task<bool> FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary<string, string> 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<bool> ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary<string, string> 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<bool> UndoFollowRequestedAsync(string signature, string method, string path, string queryString,
Dictionary<string, string> 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<SignatureValidationResult> ValidateSignature(string actor, string rawSig, string method, string path, string queryString, Dictionary<string, string> 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<string, string>();
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; }
}
}

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="1.1.1" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="4.11.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BirdsiteLive.Domain\BirdsiteLive.Domain.csproj" />
<ProjectReference Include="..\BirdsiteLive.Twitter\BirdsiteLive.Twitter.csproj" />
<ProjectReference Include="..\DataAccessLayers\BirdsiteLive.DAL\BirdsiteLive.DAL.csproj" />
</ItemGroup>
</Project>

View file

@ -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<IEnumerable<UserWithTweetsToSync>> ProcessAsync(UserWithTweetsToSync[] userWithTweetsToSyncs, CancellationToken ct);
//IAsyncEnumerable<UserWithTweetsToSync> ProcessAsync(UserWithTweetsToSync[] userWithTweetsToSyncs, CancellationToken ct);
}
}

View file

@ -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<UserWithTweetsToSync[]> ProcessAsync(SyncTwitterUser[] syncTwitterUsers, CancellationToken ct);
}
}

View file

@ -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<SyncTwitterUser[]> twitterUsersBufferBlock, CancellationToken ct);
}
}

View file

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

View file

@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
using BirdsiteLive.Pipeline.Models;
namespace BirdsiteLive.Pipeline.Contracts
{
public interface ISendTweetsToFollowersProcessor
{
Task<UserWithTweetsToSync> ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct);
}
}

View file

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

View file

@ -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<IEnumerable<UserWithTweetsToSync>> 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;
}
}
}

View file

@ -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<UserWithTweetsToSync[]> ProcessAsync(SyncTwitterUser[] syncTwitterUsers, CancellationToken ct)
{
var usersWtTweets = new List<UserWithTweetsToSync>();
//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;
}
}
}

View file

@ -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<SyncTwitterUser[]> 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);
}
}
}
}

View file

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

View file

@ -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<UserWithTweetsToSync> 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<Follower> 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<Follower> followerWtInbox, SyncTwitterUser user)
{
foreach (var follower in followerWtInbox)
{
try
{
await _sendTweetsToInboxTask.ExecuteAsync(tweets, follower, user);
}
catch (Exception e)
{
Console.WriteLine(e);
//TODO handle error
}
}
}
}
}

View file

@ -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<ExtractedTweet> 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<ExtractedTweet> 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);
}
}
}
}
}

View file

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

View file

@ -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<SyncTwitterUser[]>(new DataflowBlockOptions { BoundedCapacity = 1, CancellationToken = ct});
var retrieveTweetsBlock = new TransformBlock<SyncTwitterUser[], UserWithTweetsToSync[]>(async x => await _retrieveTweetsProcessor.ProcessAsync(x, ct));
var retrieveTweetsBufferBlock = new BufferBlock<UserWithTweetsToSync[]>(new DataflowBlockOptions { BoundedCapacity = 1, CancellationToken = ct });
var retrieveFollowersBlock = new TransformManyBlock<UserWithTweetsToSync[], UserWithTweetsToSync>(async x => await _retrieveFollowersProcessor.ProcessAsync(x, ct));
var retrieveFollowersBufferBlock = new BufferBlock<UserWithTweetsToSync>(new DataflowBlockOptions { BoundedCapacity = 20, CancellationToken = ct });
var sendTweetsToFollowersBlock = new ActionBlock<UserWithTweetsToSync>(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;
}
}
}

View file

@ -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<IMediaEntity> media)
{
var result = new List<ExtractedMedia>();
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;
}
}
}

View file

@ -0,0 +1,8 @@
namespace BirdsiteLive.Twitter.Models
{
public class ExtractedMedia
{
public string MediaType { get; set; }
public string Url { get; set; }
}
}

View file

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

View file

@ -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<ITweet>();
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();
}
}
}

View file

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

View file

@ -4,6 +4,7 @@
<TargetFramework>netcoreapp3.1</TargetFramework>
<UserSecretsId>d21486de-a812-47eb-a419-05682bb68856</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<Version>0.1.0</Version>
</PropertyGroup>
<ItemGroup>
@ -16,7 +17,9 @@
<ProjectReference Include="..\BirdsiteLive.Common\BirdsiteLive.Common.csproj" />
<ProjectReference Include="..\BirdsiteLive.Cryptography\BirdsiteLive.Cryptography.csproj" />
<ProjectReference Include="..\BirdsiteLive.Domain\BirdsiteLive.Domain.csproj" />
<ProjectReference Include="..\BirdsiteLive.Pipeline\BirdsiteLive.Pipeline.csproj" />
<ProjectReference Include="..\BirdsiteLive.Twitter\BirdsiteLive.Twitter.csproj" />
<ProjectReference Include="..\DataAccessLayers\BirdsiteLive.DAL.Postgres\BirdsiteLive.DAL.Postgres.csproj" />
</ItemGroup>

View file

@ -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 = "<p>Woooot</p>",
attachment = new string[0],
tag = new string[0]
attachment = new Attachment[0],
tag = new Tag[0]
}
};

View file

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

View file

@ -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();
}
}
}

View file

@ -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<IActionResult> 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<string, string> RequestHeaders(IHeaderDictionary header)

View file

@ -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<InstanceSettings> 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<WebFingerLink> links { get; set; } = new List<WebFingerLink>();
}
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; }
}
}

View file

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

View file

@ -0,0 +1,8 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Link
{
public string href { get; set; }
public string rel { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Metadata
{
public string email { get; set; }
}
}

View file

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

View file

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

View file

@ -0,0 +1,8 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Services
{
public object[] inbound { get; set; }
public object[] outbound { get; set; }
}
}

View file

@ -0,0 +1,8 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Software
{
public string name { get; set; }
public string version { get; set; }
}
}

View file

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

View file

@ -0,0 +1,8 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Usage
{
public int localPosts { get; set; }
public Users users { get; set; }
}
}

View file

@ -0,0 +1,7 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class Users
{
public int total { get; set; }
}
}

View file

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

View file

@ -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<WebFingerLink> links { get; set; } = new List<WebFingerLink>();
}
}

View file

@ -0,0 +1,7 @@
namespace BirdsiteLive.Models.WellKnownModels
{
public class WellKnownNodeInfo
{
public Link[] links { get; set; }
}
}

View file

@ -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<Startup>();
})
.ConfigureServices(services =>
{
services.AddHostedService<FederationService>();
});
}
}

View file

@ -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();
}
}
}
}

View file

@ -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<InstanceSettings>(Configuration.GetSection("Instance"));
//services.Configure<InstanceSettings>(Configuration.GetSection("Instance"));
//services.Configure<TwitterSettings>(Configuration.GetSection("Twitter"));
services.AddControllersWithViews();
@ -48,15 +52,39 @@ namespace BirdsiteLive
var instanceSettings = Configuration.GetSection("Instance").Get<InstanceSettings>();
services.For<InstanceSettings>().Use(x => instanceSettings);
var dbSettings = Configuration.GetSection("Db").Get<DbSettings>();
services.For<DbSettings>().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<PostgresSettings>().Use(x => postgresSettings);
services.For<ITwitterUserDal>().Use<TwitterUserPostgresDal>().Singleton();
services.For<IFollowersDal>().Use<FollowersPostgresDal>().Singleton();
services.For<IDbInitializerDal>().Use<DbInitializerPostgresDal>().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<IDal>();
//_.Exclude(type => type.Name.Contains("Settings"));
_.WithDefaultConventions();
_.LookForRegistries();

View file

@ -5,14 +5,14 @@
<h1>Debug</h1>
<form asp-controller="Debug" asp-action="Follow" method="post">
<form asp-controller="Debuging" asp-action="Follow" method="post">
<!-- Input and Submit elements -->
<button type="submit" value="Submit">Follow</button>
</form>
<form asp-controller="Debug" asp-action="PostNote" method="post">
<form asp-controller="Debuging" asp-action="PostNote" method="post">
<!-- Input and Submit elements -->
<button type="submit" value="Submit">Post Note</button>

View file

@ -5,11 +5,28 @@
<div class="text-center">
<h1 class="display-4">Welcome</h1>
<p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
<p>
<br />
BirdsiteLIVE is a Twitter to ActivityPub bridge.<br />
Find a Twitter account below:
</p>
<form method="POST">
@*<div class="form-group">
<label for="exampleInputEmail1">Email address</label>
<input type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp" placeholder="Enter email">
<small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small>
</div>*@
<div class="form-group">
@*<label for="exampleInputPassword1">Password</label>*@
<input type="text" class="form-control col-4 mx-auto" id="handle" name="handle" autocomplete="off" placeholder="Twitter Handle">
</div>
<button type="submit" class="btn btn-primary">Show</button>
</form>
@if (HtmlHelperExtensions.IsDebug())
@*@if (HtmlHelperExtensions.IsDebug())
{
<a class="nav-link text-dark" asp-area="" asp-controller="Debuging" asp-action="Index">Debug</a>
}
}*@
</div>

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - BirdsiteLive</title>
<title>@ViewData["Title"] - BirdsiteLIVE</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />
<link rel="stylesheet" href="~/css/birdsite.css" />
@ -12,8 +12,8 @@
<header>
<nav class="navbar navbar-expand-sm navbar-toggleable-sm navbar-light bg-white border-bottom box-shadow mb-3">
<div class="container">
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">BirdsiteLive</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
<a class="navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">BirdsiteLIVE</a>
@*<button class="navbar-toggler" type="button" data-toggle="collapse" data-target=".navbar-collapse" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
@ -26,7 +26,7 @@
<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>
</ul>
</div>
</div>*@
</div>
</nav>
</header>
@ -36,11 +36,14 @@
</main>
</div>
<footer class="border-top footer text-muted">
<div class="container">
&copy; 2020 - BirdsiteLive - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</div>
</footer>
<footer class="border-top footer text-muted">
<div class="container">
<a href="https://github.com/NicolasConstant/BirdsiteLive">Github</a> @*<a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>*@
<span style="float: right;">BirdsiteLIVE @System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString(3)</span>
</div>
</footer>
<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>

View file

@ -1,18 +1,37 @@
@model BirdsiteLive.Twitter.Models.TwitterUser
@using Tweetinvi.Streams.Model.AccountActivity
@model DisplayTwitterUser
@{
ViewData["Title"] = "User";
}
<div class="profile" style="background-image: url('@ViewData.Model.ProfileBannerURL');">
<div class="sub-profile">
<img class="avatar" src="@ViewData.Model.ProfileImageUrl" />
<a href="@ViewData.Model.Url"><h1>@ViewData.Model.Name</h1></a>
<h2>@@@ViewData.Model.Acct</h2>
<div class="col-6 mx-auto">
<a href="@ViewData.Model.Url" class="nounderline" title="@ViewData.Model.Url">
@*<div class="profile" style="background-image: url('@ViewData.Model.ProfileBannerURL');">*@
<div class="profile">
<div class="sub-profile">
<div class="logo">
<svg viewBox="0 0 24 24" class="logo-twitter r-13gxpu9 r-4qtqp9 r-yyyyoo r-6zzn7w r-19fsva8 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-q1j0wu"><g><path d="M23.643 4.937c-.835.37-1.732.62-2.675.733.962-.576 1.7-1.49 2.048-2.578-.9.534-1.897.922-2.958 1.13-.85-.904-2.06-1.47-3.4-1.47-2.572 0-4.658 2.086-4.658 4.66 0 .364.042.718.12 1.06-3.873-.195-7.304-2.05-9.602-4.868-.4.69-.63 1.49-.63 2.342 0 1.616.823 3.043 2.072 3.878-.764-.025-1.482-.234-2.11-.583v.06c0 2.257 1.605 4.14 3.737 4.568-.392.106-.803.162-1.227.162-.3 0-.593-.028-.877-.082.593 1.85 2.313 3.198 4.352 3.234-1.595 1.25-3.604 1.995-5.786 1.995-.376 0-.747-.022-1.112-.065 2.062 1.323 4.51 2.093 7.14 2.093 8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602.91-.658 1.7-1.477 2.323-2.41z"></path></g></svg>
</div>
<img class="avatar" src="@ViewData.Model.ProfileImageUrl" />
<h1>@ViewData.Model.Name <span class="handle">@@@ViewData.Model.Acct</span></h1>
@*<h2>@@@ViewData.Model.Acct</h2>*@
<div class="description">
@ViewData.Model.Description
<div class="description">
@ViewData.Model.Description
</div>
</div>
</div>
</div>
</div>
</a>
<br />
<br />
<p>Search this handle to find it in your instance:</p>
<input type="text" name="textbox" value=" @ViewData.Model.InstanceHandle" onclick="this.select()" class="form-control" readonly/>
</div>

View file

@ -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"
}
}

View file

@ -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;*/
}

View file

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

View file

@ -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);

View file

@ -20,8 +20,11 @@ namespace BirdsiteLive.DAL.Postgres.DataAccessLayers
}
#endregion
public async Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary<int, long> followingSyncStatus)
public async Task CreateFollowerAsync(string acct, string host, string inboxRoute, string sharedInboxRoute, int[] followings = null, Dictionary<int, long> followingSyncStatus = null)
{
if(followings == null) followings = new int[0];
if(followingSyncStatus == null) followingSyncStatus = new Dictionary<int, long>();
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<int, long> 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<Dictionary<int,long>>(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; }
}
}

View file

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

View file

@ -7,9 +7,10 @@ namespace BirdsiteLive.DAL.Contracts
public interface IFollowersDal
{
Task<Follower> GetFollowerAsync(string acct, string host);
Task CreateFollowerAsync(string acct, string host, int[] followings, Dictionary<int, long> followingSyncStatus);
Task CreateFollowerAsync(string acct, string host, string inboxRoute, string sharedInboxRoute, int[] followings = null,
Dictionary<int, long> followingSyncStatus = null);
Task<Follower[]> GetFollowersAsync(int followedUserId);
Task UpdateFollowerAsync(int id, int[] followings, Dictionary<int, long> followingSyncStatus);
Task UpdateFollowerAsync(Follower follower);
Task DeleteFollowerAsync(int id);
Task DeleteFollowerAsync(string acct, string host);
}

View file

@ -6,10 +6,12 @@ namespace BirdsiteLive.DAL.Models
{
public int Id { get; set; }
public int[] Followings { get; set; }
public List<int> Followings { get; set; }
public Dictionary<int, long> FollowingsSyncStatus { get; set; }
public string Acct { get; set; }
public string Host { get; set; }
public string InboxRoute { get; set; }
public string SharedInboxRoute { get; set; }
}
}

View file

@ -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<int, long>()
{
{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<int, long>();
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<int, long>()
{
var updatedFollowing = new List<int> { 12, 19, 23, 24 };
var updatedFollowingSync = new Dictionary<int, long>(){
{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);

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.0" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.0" />
<PackageReference Include="coverlet.collector" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\BirdsiteLive.Domain\BirdsiteLive.Domain.csproj" />
</ItemGroup>
</Project>

View file

@ -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
// }
}
}

View file

@ -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("</p><p>"));
#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("<br/>"));
#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(@"<a href=""https://t.co/L8BpyHgg25"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">https://</span><span class=""ellipsis"">t.co/L8BpyHgg25</span><span class=""invisible""></span></a>"));
#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(@"<a href=""https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">https://www.</span><span class=""ellipsis"">eff.org/deeplinks/2020/07/pact</span><span class=""invisible"">-act-not-solution-problem-harmful-online-content</span></a>"));
#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(@"<a href=""https://www.eff.org/deeplinks/2020/07/pact"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">https://www.</span><span class=""ellipsis"">eff.org/deeplinks/2020/07/pact</span><span class=""invisible""></span></a>"));
#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(@"<a href=""https://t.co/L8BpyHgg25"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">https://</span><span class=""ellipsis"">t.co/L8BpyHgg25</span><span class=""invisible""></span></a>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://www.eff.org/deeplinks/2020/07/pact-act-not-solution-problem-harmful-online-content"" rel=""nofollow noopener noreferrer"" target=""_blank""><span class=""invisible"">https://www.</span><span class=""ellipsis"">eff.org/deeplinks/2020/07/pact</span><span class=""invisible"">-act-not-solution-problem-harmful-online-content</span></a>"));
#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(@"<a href=""https://domain.name/tags/mytag"" class=""mention hashtag"" rel=""tag"">#<span>mytag</span></a>"));
#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(@"<a href=""https://domain.name/tags/mytag"" class=""mention hashtag"" rel=""tag"">#<span>mytag</span></a>"));
#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(@"<a href=""https://domain.name/tags/COVIDー19"" class=""mention hashtag"" rel=""tag"">#<span>COVIDー19</span></a>"));
#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(@"<a href=""https://domain.name/tags/mytag"" class=""mention hashtag"" rel=""tag"">#<span>mytag</span></a>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://domain.name/tags/mytag2"" class=""mention hashtag"" rel=""tag"">#<span>mytag2</span></a>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://domain.name/tags/mytag3"" class=""mention hashtag"" rel=""tag"">#<span>mytag3</span></a>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://domain.name/tags/bal"" class=""mention hashtag"" rel=""tag"">#<span>bal</span></a>"));
#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(@"<span class=""h-card""><a href=""https://domain.name/@mynickname"" class=""u-url mention"">@<span>mynickname</span></a></span>"));
#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(@"<span class=""h-card""><a href=""https://domain.name/@my___nickname"" class=""u-url mention"">@<span>my___nickname</span></a></span>"));
#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(@"<span class=""h-card""><a href=""https://domain.name/@mynickname"" class=""u-url mention"">@<span>mynickname</span></a></span>"));
#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(@"<span class=""h-card""><a href=""https://domain.name/@mynickname"" class=""u-url mention"">@<span>mynickname</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<span class=""h-card""><a href=""https://domain.name/@mynickname2"" class=""u-url mention"">@<span>mynickname2</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<span class=""h-card""><a href=""https://domain.name/@mynickname3"" class=""u-url mention"">@<span>mynickname3</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<span class=""h-card""><a href=""https://domain.name/@dada"" class=""u-url mention"">@<span>dada</span></a></span>"));
#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(@"<span class=""h-card""><a href=""https://domain.name/@mynickname"" class=""u-url mention"">@<span>mynickname</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://domain.name/tags/mytag2"" class=""mention hashtag"" rel=""tag"">#<span>mytag2</span></a>"));
Assert.IsTrue(result.content.Contains(@"<span class=""h-card""><a href=""https://domain.name/@mynickname3"" class=""u-url mention"">@<span>mynickname3</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<span class=""h-card""><a href=""https://domain.name/@dada"" class=""u-url mention"">@<span>dada</span></a></span>"));
Assert.IsTrue(result.content.Contains(@"<a href=""https://domain.name/tags/dada"" class=""mention hashtag"" rel=""tag"">#<span>dada</span></a>"));
#endregion
}
}
}

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
<PackageReference Include="Moq" Version="4.14.5" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.0" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.0" />
<PackageReference Include="coverlet.collector" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\BirdsiteLive.Pipeline\BirdsiteLive.Pipeline.csproj" />
</ItemGroup>
</Project>

View file

@ -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<UserWithTweetsToSync>
{
new UserWithTweetsToSync
{
User = new SyncTwitterUser
{
Id = userId1
}
},
new UserWithTweetsToSync
{
User = new SyncTwitterUser
{
Id = userId2
}
}
};
var followersUser1 = new List<Follower>
{
new Follower(),
new Follower(),
};
var followersUser2 = new List<Follower>
{
new Follower(),
new Follower(),
new Follower(),
};
#endregion
#region Mocks
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
followersDalMock
.Setup(x => x.GetFollowersAsync(It.Is<int>(y => y == userId1)))
.ReturnsAsync(followersUser1.ToArray());
followersDalMock
.Setup(x => x.GetFollowersAsync(It.Is<int>(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
}
}
}

View file

@ -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<ITwitterService>(MockBehavior.Strict);
twitterServiceMock
.Setup(x => x.GetTimeline(
It.Is<string>(y => y == user1.Acct),
It.Is<int>(y => y == 1),
It.Is<long>(y => y == -1)
))
.Returns(tweets);
var twitterUserDalMock = new Mock<ITwitterUserDal>(MockBehavior.Strict);
twitterUserDalMock
.Setup(x => x.UpdateTwitterUserAsync(
It.Is<int>(y => y == user1.Id),
It.Is<long>(y => y == tweets.Last().Id),
It.Is<long>(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<ITwitterService>(MockBehavior.Strict);
twitterServiceMock
.Setup(x => x.GetTimeline(
It.Is<string>(y => y == user1.Acct),
It.Is<int>(y => y == 200),
It.Is<long>(y => y == user1.LastTweetSynchronizedForAllFollowersId)
))
.Returns(tweets);
var twitterUserDalMock = new Mock<ITwitterUserDal>(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<ITwitterService>(MockBehavior.Strict);
twitterServiceMock
.Setup(x => x.GetTimeline(
It.Is<string>(y => y == user1.Acct),
It.Is<int>(y => y == 200),
It.Is<long>(y => y == user1.LastTweetSynchronizedForAllFollowersId)
))
.Returns(tweets);
var twitterUserDalMock = new Mock<ITwitterUserDal>(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
}
}
}

View file

@ -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<SyncTwitterUser[]>();
var users = new[]
{
new SyncTwitterUser(),
new SyncTwitterUser(),
new SyncTwitterUser(),
};
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(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<SyncTwitterUser[]>();
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(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<SyncTwitterUser[]>();
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(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<SyncTwitterUser[]>();
var canTokenS = new CancellationTokenSource();
canTokenS.Cancel();
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(MockBehavior.Strict);
#endregion
var processor = new RetrieveTwitterUsersProcessor(twitterUserDalMock.Object);
await processor.GetTwitterUsersAsync(buffer, canTokenS.Token);
}
}
}

View file

@ -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<int, long>
{
{1, 37}
}
};
var usersWithTweets = new UserWithTweetsToSync
{
Tweets = new []
{
tweet1,
tweet2
},
Followers = new []
{
follower1
},
User = user
};
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(MockBehavior.Strict);
twitterUserDalMock
.Setup(x => x.UpdateTwitterUserAsync(
It.Is<int>(y => y == user.Id),
It.Is<long>(y => y == tweet2.Id),
It.Is<long>(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<int, long>
{
{1, 37}
}
};
var usersWithTweets = new UserWithTweetsToSync
{
Tweets = new[]
{
tweet1,
tweet2,
tweet3
},
Followers = new[]
{
follower1
},
User = user
};
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(MockBehavior.Strict);
twitterUserDalMock
.Setup(x => x.UpdateTwitterUserAsync(
It.Is<int>(y => y == user.Id),
It.Is<long>(y => y == tweet3.Id),
It.Is<long>(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<int, long>
{
{1, 37}
}
};
var follower2 = new Follower
{
FollowingsSyncStatus = new Dictionary<int, long>
{
{1, 38}
}
};
var usersWithTweets = new UserWithTweetsToSync
{
Tweets = new[]
{
tweet1,
tweet2,
tweet3
},
Followers = new[]
{
follower1,
follower2
},
User = user
};
#endregion
#region Mocks
var twitterUserDalMock = new Mock<ITwitterUserDal>(MockBehavior.Strict);
twitterUserDalMock
.Setup(x => x.UpdateTwitterUserAsync(
It.Is<int>(y => y == user.Id),
It.Is<long>(y => y == tweet3.Id),
It.Is<long>(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
}
}
}

View file

@ -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<ISendTweetsToInboxTask>(MockBehavior.Strict);
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(MockBehavior.Strict);
sendTweetsToSharedInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct),
It.Is<string>(y => y == host),
It.Is<Follower[]>(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<ISendTweetsToInboxTask>(MockBehavior.Strict);
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(MockBehavior.Strict);
foreach (var host in new [] { host1, host2})
{
sendTweetsToSharedInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct),
It.Is<string>(y => y == host),
It.Is<Follower[]>(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<ISendTweetsToInboxTask>(MockBehavior.Strict);
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(MockBehavior.Strict);
sendTweetsToSharedInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct),
It.Is<string>(y => y == host1),
It.Is<Follower[]>(y => y.Length == 1)))
.Returns(Task.CompletedTask);
sendTweetsToSharedInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct),
It.Is<string>(y => y == host2),
It.Is<Follower[]>(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<ISendTweetsToInboxTask>(MockBehavior.Strict);
foreach (var userId in new[] { userId1, userId2 })
{
sendTweetsToInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<Follower>(y => y.Id == userId),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct)))
.Returns(Task.CompletedTask);
}
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(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<ISendTweetsToInboxTask>(MockBehavior.Strict);
foreach (var userId in new[] { userId1, userId2 })
{
sendTweetsToInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<Follower>(y => y.Id == userId),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct)))
.Returns(Task.CompletedTask);
}
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(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<ISendTweetsToInboxTask>(MockBehavior.Strict);
sendTweetsToInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<Follower>(y => y.Id == userId1),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct)))
.Returns(Task.CompletedTask);
sendTweetsToInboxTaskMock
.Setup(x => x.ExecuteAsync(
It.Is<ExtractedTweet[]>(y => y.Length == 1),
It.Is<Follower>(y => y.Id == userId2),
It.Is<SyncTwitterUser>(y => y.Acct == userAcct)))
.Throws(new Exception());
var sendTweetsToSharedInboxTaskMock = new Mock<ISendTweetsToSharedInboxTask>(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
}
}
}

View file

@ -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<ExtractedTweet>
{
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<int, long> { { twitterUserId, 9 } }
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == noteId),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(note);
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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<ExtractedTweet>();
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<int, long> { { twitterUserId, 10 } }
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
}
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(new Note { id = tweetId.ToString() });
}
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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<ExtractedTweet>();
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<int, long> { { twitterUserId, 10 } }
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId2.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId2.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId3.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId3.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.InternalServerError);
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(new Note { id = tweetId.ToString() });
}
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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
}
}
}
}

View file

@ -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<ExtractedTweet>
{
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<Follower>
{
new Follower
{
Id = 1,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> { { twitterUserId, 9 } }
},
new Follower
{
Id = 2,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> { { twitterUserId, 8 } }
},
new Follower
{
Id = 3,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> { { twitterUserId, 7 } }
}
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == noteId),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(note);
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
foreach (var follower in followers)
{
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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<ExtractedTweet>();
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<Follower>
{
new Follower
{
Id = 1,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 10}}
},
new Follower
{
Id = 2,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 8}}
},
new Follower
{
Id = 3,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 7}}
}
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
}
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(new Note { id = tweetId.ToString() });
}
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
foreach (var follower in followers)
{
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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<ExtractedTweet>();
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<Follower>
{
new Follower
{
Id = 1,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 10}}
},
new Follower
{
Id = 2,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 8}}
},
new Follower
{
Id = 3,
Host = host,
SharedInboxRoute = inbox,
FollowingsSyncStatus = new Dictionary<int, long> {{twitterUserId, 7}}
}
};
#endregion
#region Mocks
var activityPubService = new Mock<IActivityPubService>(MockBehavior.Strict);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId2.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId2.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.Accepted);
activityPubService
.Setup(x => x.PostNewNoteActivity(
It.Is<Note>(y => y.id == tweetId3.ToString()),
It.Is<string>(y => y == twitterHandle),
It.Is<string>(y => y == tweetId3.ToString()),
It.Is<string>(y => y == host),
It.Is<string>(y => y == inbox)))
.ReturnsAsync(HttpStatusCode.InternalServerError);
var statusServiceMock = new Mock<IStatusService>(MockBehavior.Strict);
foreach (var tweetId in new[] { tweetId2, tweetId3 })
{
statusServiceMock
.Setup(x => x.GetStatus(
It.Is<string>(y => y == twitterHandle),
It.Is<ExtractedTweet>(y => y.Id == tweetId)))
.Returns(new Note { id = tweetId.ToString() });
}
var followersDalMock = new Mock<IFollowersDal>(MockBehavior.Strict);
foreach (var follower in followers)
{
followersDalMock
.Setup(x => x.UpdateFollowerAsync(
It.Is<Follower>(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
}
}
}
}