diff --git a/src/BirdsiteLive.ActivityPub/Models/Attachment.cs b/src/BirdsiteLive.ActivityPub/Models/Attachment.cs new file mode 100644 index 0000000..d7b86dd --- /dev/null +++ b/src/BirdsiteLive.ActivityPub/Models/Attachment.cs @@ -0,0 +1,9 @@ +namespace BirdsiteLive.ActivityPub +{ + public class Attachment + { + public string type { get; set; } + public string mediaType { get; set; } + public string url { get; set; } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.ActivityPub/Models/Note.cs b/src/BirdsiteLive.ActivityPub/Models/Note.cs index cc3d561..b0aba5e 100644 --- a/src/BirdsiteLive.ActivityPub/Models/Note.cs +++ b/src/BirdsiteLive.ActivityPub/Models/Note.cs @@ -24,7 +24,7 @@ namespace BirdsiteLive.ActivityPub //public string conversation { get; set; } public string content { get; set; } //public Dictionary contentMap { get; set; } - public string[] attachment { get; set; } + public Attachment[] attachment { get; set; } public string[] tag { get; set; } //public Dictionary replies; } diff --git a/src/BirdsiteLive.Domain/StatusService.cs b/src/BirdsiteLive.Domain/StatusService.cs new file mode 100644 index 0000000..cc2b688 --- /dev/null +++ b/src/BirdsiteLive.Domain/StatusService.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using BirdsiteLive.ActivityPub; +using BirdsiteLive.Common.Settings; +using Tweetinvi.Models; +using Tweetinvi.Models.Entities; + +namespace BirdsiteLive.Domain +{ + public interface IStatusService + { + Note GetStatus(string username, ITweet tweet); + } + + public class StatusService : IStatusService + { + private readonly InstanceSettings _instanceSettings; + + #region Ctor + public StatusService(InstanceSettings instanceSettings) + { + _instanceSettings = instanceSettings; + } + #endregion + + public Note GetStatus(string username, ITweet 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 note = new Note + { + id = $"{noteId}/activity", + + published = tweet.CreatedAt.ToString("s") + "Z", + url = noteUrl, + attributedTo = actorUrl, + + //to = new [] {to}, + //cc = new [] { apPublic }, + + to = new[] { to }, + cc = new[] { apPublic }, + //cc = new string[0], + + sensitive = false, + content = $"

{tweet.Text}

", + attachment = GetAttachments(tweet.Media), + tag = new string[0] + }; + + + return note; + } + + private Attachment[] GetAttachments(List media) + { + var result = new List(); + + foreach (var m in media) + { + var mediaUrl = GetMediaUrl(m); + var mediaType = GetMediaType(m.MediaType, mediaUrl); + if (mediaType == null) continue; + + var att = new Attachment + { + type = "Document", + mediaType = mediaType, + url = mediaUrl + }; + result.Add(att); + } + + return result.ToArray(); + } + + private 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; + } + } + + private string GetMediaType(string mediaType, string mediaUrl) + { + switch (mediaType) + { + case "photo": + var ext = Path.GetExtension(mediaUrl); + switch (ext) + { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + } + return null; + + case "animated_gif": + return "image/gif"; + + case "video": + return "video/mp4"; + } + return null; + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Domain/UserService.cs b/src/BirdsiteLive.Domain/UserService.cs index 1ca60c1..2c0b045 100644 --- a/src/BirdsiteLive.Domain/UserService.cs +++ b/src/BirdsiteLive.Domain/UserService.cs @@ -18,8 +18,6 @@ namespace BirdsiteLive.Domain public interface IUserService { Actor GetUser(TwitterUser twitterUser); - //Note GetStatus(TwitterUser user, ITweet tweet); - Note GetStatus(string username, ITweet tweet); Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity); Task UndoFollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityUndoFollow activity); } @@ -75,41 +73,6 @@ namespace BirdsiteLive.Domain return user; } - public Note GetStatus(string username, ITweet tweet) - { - //var actor = GetUser(user); - - var actorUrl = $"{_host}/users/{username}"; - var noteId = $"{_host}/users/{username}/statuses/{tweet.Id}"; - var noteUrl = $"{_host}/@{username}/{tweet.Id}"; - - var to = $"{actorUrl}/followers"; - var apPublic = "https://www.w3.org/ns/activitystreams#Public"; - - var note = new Note - { - id = $"{noteId}/activity", - - published = tweet.CreatedAt.ToString("s") + "Z", - url = noteUrl, - attributedTo = actorUrl, - - //to = new [] {to}, - //cc = new [] { apPublic }, - - to = new[] { apPublic }, - cc = new[] { to }, - - sensitive = false, - content = $"

{tweet.Text}

", - attachment = new string[0], - tag = new string[0] - }; - return note; - } - - - public async Task FollowRequestedAsync(string signature, string method, string path, string queryString, Dictionary requestHeaders, ActivityFollow activity) { // Validate diff --git a/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs new file mode 100644 index 0000000..02efaef --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Contracts/ISaveProgressionProcessor.cs @@ -0,0 +1,11 @@ +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Contracts +{ + public interface ISaveProgressionProcessor + { + Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct); + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs index df18fa9..6d55957 100644 --- a/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs +++ b/src/BirdsiteLive.Pipeline/Contracts/ISendTweetsToFollowersProcessor.cs @@ -6,6 +6,6 @@ namespace BirdsiteLive.Pipeline.Contracts { public interface ISendTweetsToFollowersProcessor { - Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct); + Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct); } } \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs new file mode 100644 index 0000000..5b305e7 --- /dev/null +++ b/src/BirdsiteLive.Pipeline/Processors/SaveProgressionProcessor.cs @@ -0,0 +1,29 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.Pipeline.Contracts; +using BirdsiteLive.Pipeline.Models; + +namespace BirdsiteLive.Pipeline.Processors +{ + public class SaveProgressionProcessor : ISaveProgressionProcessor + { + private readonly ITwitterUserDal _twitterUserDal; + + #region Ctor + public SaveProgressionProcessor(ITwitterUserDal twitterUserDal) + { + _twitterUserDal = twitterUserDal; + } + #endregion + + public async Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct) + { + var userId = userWithTweetsToSync.User.Id; + var lastPostedTweet = userWithTweetsToSync.Tweets.Select(x => x.Id).Max(); + var minimumSync = userWithTweetsToSync.Followers.Select(x => x.FollowingsSyncStatus[userId]).Min(); + await _twitterUserDal.UpdateTwitterUserAsync(userId, lastPostedTweet, minimumSync); + } + } +} \ No newline at end of file diff --git a/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs b/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs index 8fa297f..a20f813 100644 --- a/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs +++ b/src/BirdsiteLive.Pipeline/Processors/SendTweetsToFollowersProcessor.cs @@ -1,60 +1,84 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using BirdsiteLive.DAL.Contracts; +using BirdsiteLive.DAL.Models; using BirdsiteLive.Domain; using BirdsiteLive.Pipeline.Contracts; using BirdsiteLive.Pipeline.Models; using BirdsiteLive.Twitter; +using Tweetinvi.Models; namespace BirdsiteLive.Pipeline.Processors { public class SendTweetsToFollowersProcessor : ISendTweetsToFollowersProcessor { private readonly IActivityPubService _activityPubService; - private readonly IUserService _userService; + private readonly IStatusService _statusService; private readonly IFollowersDal _followersDal; - private readonly ITwitterUserDal _twitterUserDal; #region Ctor - public SendTweetsToFollowersProcessor(IActivityPubService activityPubService, IUserService userService, IFollowersDal followersDal, ITwitterUserDal twitterUserDal) + public SendTweetsToFollowersProcessor(IActivityPubService activityPubService, IFollowersDal followersDal, IStatusService statusService) { _activityPubService = activityPubService; - _userService = userService; _followersDal = followersDal; - _twitterUserDal = twitterUserDal; + _statusService = statusService; } #endregion - public async Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct) + public async Task ProcessAsync(UserWithTweetsToSync userWithTweetsToSync, CancellationToken ct) { var user = userWithTweetsToSync.User; var userId = user.Id; foreach (var follower in userWithTweetsToSync.Followers) { - var fromStatusId = follower.FollowingsSyncStatus[userId]; - var tweetsToSend = userWithTweetsToSync.Tweets.Where(x => x.Id > fromStatusId).OrderBy(x => x.Id).ToList(); - - var syncStatus = fromStatusId; - foreach (var tweet in tweetsToSend) + try { - var note = _userService.GetStatus(user.Acct, tweet); - var result = await _activityPubService.PostNewNoteActivity(note, user.Acct, tweet.Id.ToString(), follower.Host, follower.InboxUrl); - if (result == HttpStatusCode.Accepted) - syncStatus = tweet.Id; - else - break; + await ProcessFollowerAsync(userWithTweetsToSync.Tweets, follower, userId, user); + } + catch (Exception e) + { + Console.WriteLine(e); + //TODO handle error } - - follower.FollowingsSyncStatus[userId] = syncStatus; - await _followersDal.UpdateFollowerAsync(follower); } - 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); + return userWithTweetsToSync; + } + + private async Task ProcessFollowerAsync(IEnumerable tweets, Follower follower, int userId, + SyncTwitterUser user) + { + var fromStatusId = follower.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(), follower.Host, + follower.InboxUrl); + + if (result == HttpStatusCode.Accepted) + syncStatus = tweet.Id; + else + throw new Exception("Posting new note activity failed"); + } + } + finally + { + if (syncStatus != fromStatusId) + { + follower.FollowingsSyncStatus[userId] = syncStatus; + await _followersDal.UpdateFollowerAsync(follower); + } + } } } } \ No newline at end of file diff --git a/src/BirdsiteLive/Controllers/DebugingController.cs b/src/BirdsiteLive/Controllers/DebugingController.cs index 47196c7..164bb24 100644 --- a/src/BirdsiteLive/Controllers/DebugingController.cs +++ b/src/BirdsiteLive/Controllers/DebugingController.cs @@ -92,7 +92,7 @@ namespace BirdsiteLive.Controllers //cc = new [] { apPublic }, sensitive = false, content = "

Woooot

", - attachment = new string[0], + attachment = new Attachment[0], tag = new string[0] } }; diff --git a/src/BirdsiteLive/Controllers/UsersController.cs b/src/BirdsiteLive/Controllers/UsersController.cs index 7320eb7..5a98538 100644 --- a/src/BirdsiteLive/Controllers/UsersController.cs +++ b/src/BirdsiteLive/Controllers/UsersController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Mime; using System.Threading; using System.Threading.Tasks; using BirdsiteLive.ActivityPub; @@ -18,12 +19,14 @@ namespace BirdsiteLive.Controllers { private readonly ITwitterService _twitterService; private readonly IUserService _userService; + private readonly IStatusService _statusService; #region Ctor - public UsersController(ITwitterService twitterService, IUserService userService) + public UsersController(ITwitterService twitterService, IUserService userService, IStatusService statusService) { _twitterService = twitterService; _userService = userService; + _statusService = statusService; } #endregion @@ -62,7 +65,7 @@ namespace BirdsiteLive.Controllers //var user = _twitterService.GetUser(id); //if (user == null) return NotFound(); - var status = _userService.GetStatus(id, tweet); + var status = _statusService.GetStatus(id, tweet); var jsonApUser = JsonConvert.SerializeObject(status); return Content(jsonApUser, "application/activity+json; charset=utf-8"); } @@ -78,6 +81,8 @@ 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();