diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce03cc0..4e28b9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,3 +57,9 @@ This token pair lasts forever or until it is revoked. - [Azure Maps Layer & Legend Control module](https://github.com/Azure-Samples/azure-maps-layer-legend) - [Legend Control documentation](https://github.com/Azure-Samples/azure-maps-layer-legend/blob/main/docs/legend_control.md) - [Azure Maps Spider Cluster module (forked)](https://github.com/golf1052/azure-maps-spider-clusters) + +# Publishing NuGet Packages + +1. `dotnet build -c Release` +2. `dotnet pack -c Release` +3. `dotnet nuget push -k ` diff --git a/SeattleCarsInBikeLanes.sln b/SeattleCarsInBikeLanes.sln index 0db44a4..1d9cc07 100644 --- a/SeattleCarsInBikeLanes.sln +++ b/SeattleCarsInBikeLanes.sln @@ -9,7 +9,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "golf1052.Mastodon", "..\Mas EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "golf1052.atproto.net", "..\atproto.net\golf1052.atproto.net\golf1052.atproto.net.csproj", "{A6C4A37C-748C-4CAF-A39A-0B5B983D5723}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeattleCarsInBikeLanes.Tests", "SeattleCarsInBikeLanes.Tests\SeattleCarsInBikeLanes.Tests.csproj", "{4FA8175B-B2E9-40D3-AE58-4071752D2092}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SeattleCarsInBikeLanes.Tests", "SeattleCarsInBikeLanes.Tests\SeattleCarsInBikeLanes.Tests.csproj", "{4FA8175B-B2E9-40D3-AE58-4071752D2092}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "golf1052.ThreadsAPI", "..\ThreadsAPI\golf1052.ThreadsAPI\golf1052.ThreadsAPI.csproj", "{70BB7705-3A18-4113-A19A-4995EFC6E143}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -33,6 +35,10 @@ Global {4FA8175B-B2E9-40D3-AE58-4071752D2092}.Debug|Any CPU.Build.0 = Debug|Any CPU {4FA8175B-B2E9-40D3-AE58-4071752D2092}.Release|Any CPU.ActiveCfg = Release|Any CPU {4FA8175B-B2E9-40D3-AE58-4071752D2092}.Release|Any CPU.Build.0 = Release|Any CPU + {70BB7705-3A18-4113-A19A-4995EFC6E143}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70BB7705-3A18-4113-A19A-4995EFC6E143}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70BB7705-3A18-4113-A19A-4995EFC6E143}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70BB7705-3A18-4113-A19A-4995EFC6E143}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SeattleCarsInBikeLanes/Controllers/AdminPageController.cs b/SeattleCarsInBikeLanes/Controllers/AdminPageController.cs index 6db91c3..d4bba77 100644 --- a/SeattleCarsInBikeLanes/Controllers/AdminPageController.cs +++ b/SeattleCarsInBikeLanes/Controllers/AdminPageController.cs @@ -11,6 +11,8 @@ using golf1052.Mastodon; using golf1052.Mastodon.Models.Statuses; using golf1052.Mastodon.Models.Statuses.Media; +using golf1052.ThreadsAPI; +using golf1052.ThreadsAPI.Models; using Imgur.API.Endpoints; using Imgur.API.Models; using LinqToTwitter; @@ -43,6 +45,7 @@ public class AdminPageController : ControllerBase private readonly MastodonClientProvider mastodonClientProvider; private readonly FeedProvider feedProvider; private readonly BlueskyClientProvider blueskyClientProvider; + private readonly ThreadsClient threadsClient; public AdminPageController(ILogger logger, HelperMethods helperMethods, @@ -54,7 +57,8 @@ public AdminPageController(ILogger logger, MapsSearchClient mapsSearchClient, MastodonClientProvider mastodonClientProvider, FeedProvider feedProvider, - BlueskyClientProvider blueskyClientProvider) + BlueskyClientProvider blueskyClientProvider, + ThreadsClient threadsClient) { this.logger = logger; this.helperMethods = helperMethods; @@ -66,6 +70,7 @@ public AdminPageController(ILogger logger, this.mastodonClientProvider = mastodonClientProvider; this.feedProvider = feedProvider; this.blueskyClientProvider = blueskyClientProvider; + this.threadsClient = threadsClient; SingleUserAuthorizer auth = new SingleUserAuthorizer() { @@ -471,12 +476,14 @@ public async Task PostTweet([FromBody] PostTweetRequest request) tweetText += $"\n{request.QuoteTweetLink}"; } + List tweetImageLinks = new List(); List pictureStreams = new List(); if (!string.IsNullOrWhiteSpace(request.TweetImages)) { string[] splitTweetImages = request.TweetImages.Split('\n'); foreach (string imageLink in splitTweetImages) { + tweetImageLinks.Add(imageLink); Url imageLinkUrl = new Url(imageLink); if (imageLinkUrl.Host.Contains("twimg") && imageLinkUrl.QueryParams.Contains("name")) { @@ -605,6 +612,54 @@ public async Task PostTweet([FromBody] PostTweetRequest request) return StatusCode((int)HttpStatusCode.InternalServerError, error); } + // It's Threads time + if (tweetImageLinks.Count == 1) + { + string threadsMediaContainerId = await threadsClient.CreateThreadsMediaContainer("IMAGE", + tweetText, + tweetImageLinks[0]); + // Threads API recommends waiting 30 seconds between creating the media container and publishing it + await Task.Delay(TimeSpan.FromSeconds(30)); + string threadsPostId = await threadsClient.PublishThreadsMediaContainer(threadsMediaContainerId); + ThreadsMediaObject uploadedThreadPost = await threadsClient.GetThreadsMediaObject(threadsPostId, + "id,permalink"); + + foreach (var reportedItem in reportedItems) + { + reportedItem.ThreadsLink = uploadedThreadPost.Permalink; + } + } + else if (tweetImageLinks.Count > 1) + { + List containerIds = new List(tweetImageLinks.Count); + foreach (var imageLink in tweetImageLinks) + { + string threadsMediaContainerId = await threadsClient.CreateThreadsMediaContainer("IMAGE", + null, + imageLink, + null, + null, + true); + containerIds.Add(threadsMediaContainerId); + } + string carouselContainerId = await threadsClient.CreateThreadsMediaContainer("CAROUSEL", + tweetText, + null, + null, + null, + null, + containerIds); + await Task.Delay(TimeSpan.FromSeconds(30)); + string threadsPostId = await threadsClient.PublishThreadsMediaContainer(carouselContainerId); + ThreadsMediaObject uploadedThreadsPost = await threadsClient.GetThreadsMediaObject(threadsPostId, + "id,permalink"); + + foreach (var reportedItem in reportedItems) + { + reportedItem.ThreadsLink = uploadedThreadsPost.Permalink; + } + } + foreach (var reportedItem in reportedItems) { bool addedItem = await reportedItemsDatabase.AddReportedItem(reportedItem); @@ -802,6 +857,8 @@ public async Task DeletePost([FromBody] DeletePostRequest request await blueskyClient.DeleteRecord(deleteRecordRequest); } + // TODO: Add Threads deletion support once Threads API supports deletion + await feedProvider.RemoveReportedItemFromFeed(reportedItem); bool deletedFromDatabase = await reportedItemsDatabase.DeleteItem(reportedItem); @@ -984,7 +1041,7 @@ public async Task PostMonthlyStats([FromBody] PostMonthlyStatsReq { firstToot = await mastodonClient.PublishStatus($"{introText}"); } - + MastodonStatus latestToot = firstToot; if (!skipMostCars) { @@ -1005,7 +1062,7 @@ public async Task PostMonthlyStats([FromBody] PostMonthlyStatsReq logger.LogError(ex, "Failed to toot stats."); } - // Next post to Bluesky (we'll probably post to Threads in the future so there's no finally 🙃) + // Next post to Bluesky AtProtoClient blueskyClient = await blueskyClientProvider.GetClient(); try { @@ -1058,7 +1115,7 @@ public async Task PostMonthlyStats([FromBody] PostMonthlyStatsReq } }); } - + CreateRecordResponse latestSkeet = firstSkeet; if (mostCars.Count > 1) { @@ -1187,6 +1244,59 @@ public async Task PostMonthlyStats([FromBody] PostMonthlyStatsReq logger.LogError(ex, "Failed to skeet status."); } + // Finally post to Threads + try + { + string firstThreadsPostId; + if (!skipMostCars) + { + string firstCreationId = await threadsClient.CreateThreadsMediaContainer("TEXT", + $"{introText}{mostCarsText} {GetSocialLinkForThreads(mostCars[0])}"); + await Task.Delay(TimeSpan.FromSeconds(30)); + firstThreadsPostId = await threadsClient.PublishThreadsMediaContainer(firstCreationId); + } + else + { + string firstCreationId = await threadsClient.CreateThreadsMediaContainer("TEXT", + $"{introText}"); + await Task.Delay(TimeSpan.FromSeconds(30)); + firstThreadsPostId = await threadsClient.PublishThreadsMediaContainer(firstCreationId); + } + + string latestThreadsPostId = firstThreadsPostId; + if (!skipMostCars) + { + if (mostCars.Count > 1) + { + for (int i = 1; i < mostCars.Count; i++) + { + var item = mostCars[i]; + string creationId = await threadsClient.CreateThreadsMediaContainer("TEXT", + $"{mostCarsText} {GetSocialLinkForThreads(item)}", + replyToId: latestThreadsPostId); + await Task.Delay(TimeSpan.FromSeconds(30)); + latestThreadsPostId = await threadsClient.PublishThreadsMediaContainer(creationId); + } + } + } + + string secondCreationId = await threadsClient.CreateThreadsMediaContainer("TEXT", + $"{mostRidiculousText} {GetSocialLinkForThreads(mostRidiculousReportedItem)}", + replyToId: latestThreadsPostId); + await Task.Delay(TimeSpan.FromSeconds(30)); + string secondThreadsPostId = await threadsClient.PublishThreadsMediaContainer(secondCreationId); + + string thirdCreationId = await threadsClient.CreateThreadsMediaContainer("TEXT", + $"{worstIntersectionText}", + replyToId: secondThreadsPostId); + await Task.Delay(TimeSpan.FromSeconds(30)); + string thirdThreadsPostId = await threadsClient.PublishThreadsMediaContainer(thirdCreationId); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to post stats to Threads."); + } + return NoContent(); } @@ -1206,6 +1316,22 @@ public async Task PostMonthlyStats([FromBody] PostMonthlyStatsReq } } + private string? GetSocialLinkForThreads(ReportedItem item) + { + if (!string.IsNullOrWhiteSpace(item.ThreadsLink)) + { + return item.ThreadsLink; + } + else if (!string.IsNullOrWhiteSpace(item.MastodonLink)) + { + return item.MastodonLink; + } + else + { + return item.TwitterLink; + } + } + private async Task FindReportedItem(string postIdentifier) { string? identifier = null; diff --git a/SeattleCarsInBikeLanes/Controllers/RedirectController.cs b/SeattleCarsInBikeLanes/Controllers/RedirectController.cs index a8531ef..b9a3ba1 100644 --- a/SeattleCarsInBikeLanes/Controllers/RedirectController.cs +++ b/SeattleCarsInBikeLanes/Controllers/RedirectController.cs @@ -17,5 +17,11 @@ public IActionResult GetMastodon() { return File("mastodonredirect.html", "text/html"); } + + [HttpGet("/threadsredirect")] + public IActionResult GetThreads() + { + return File("threadsredirect.html", "text/html"); + } } } diff --git a/SeattleCarsInBikeLanes/Controllers/ThreadsController.cs b/SeattleCarsInBikeLanes/Controllers/ThreadsController.cs new file mode 100644 index 0000000..15041ed --- /dev/null +++ b/SeattleCarsInBikeLanes/Controllers/ThreadsController.cs @@ -0,0 +1,29 @@ +using Azure.Security.KeyVault.Secrets; +using Microsoft.AspNetCore.Mvc; + +namespace SeattleCarsInBikeLanes.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class ThreadsController : ControllerBase + { + private readonly HttpClient httpClient; + private readonly string redirectUri; + + public ThreadsController(IWebHostEnvironment environment, + HttpClient httpClient, + SecretClient secretClient) + { + this.httpClient = httpClient; + + if (environment.IsDevelopment()) + { + redirectUri = "https://localhost:7152/threadsredirect"; + } + else + { + redirectUri = "https://seattle.carinbikelane.com/threadsredirect"; + } + } + } +} diff --git a/SeattleCarsInBikeLanes/Database/Models/ReportedItem.cs b/SeattleCarsInBikeLanes/Database/Models/ReportedItem.cs index 77bed67..7a13230 100644 --- a/SeattleCarsInBikeLanes/Database/Models/ReportedItem.cs +++ b/SeattleCarsInBikeLanes/Database/Models/ReportedItem.cs @@ -18,6 +18,7 @@ public class ReportedItem public string? TwitterLink { get; set; } public string? MastodonLink { get; set; } public string? BlueskyLink { get; set; } + public string? ThreadsLink { get; set; } public bool Latest { get; set; } = false; } } diff --git a/SeattleCarsInBikeLanes/Database/ReportedItemsDatabase.cs b/SeattleCarsInBikeLanes/Database/ReportedItemsDatabase.cs index 59baa40..c085850 100644 --- a/SeattleCarsInBikeLanes/Database/ReportedItemsDatabase.cs +++ b/SeattleCarsInBikeLanes/Database/ReportedItemsDatabase.cs @@ -142,7 +142,8 @@ public async Task UpdateReportedItem(ReportedItem item) .Where(i => i.TweetId.Contains(identifier) || (i.TwitterLink != null && i.TwitterLink.Contains(identifier)) || (i.MastodonLink != null && i.MastodonLink.Contains(identifier)) || - (i.BlueskyLink != null && i.BlueskyLink.Contains(identifier))); + (i.BlueskyLink != null && i.BlueskyLink.Contains(identifier)) || + (i.ThreadsLink != null && i.ThreadsLink.Contains(identifier))); using FeedIterator iterator = query.ToFeedIterator(); return await ProcessIterator(iterator); diff --git a/SeattleCarsInBikeLanes/Program.cs b/SeattleCarsInBikeLanes/Program.cs index 75f5657..b496e6d 100644 --- a/SeattleCarsInBikeLanes/Program.cs +++ b/SeattleCarsInBikeLanes/Program.cs @@ -5,6 +5,7 @@ using Azure.Security.KeyVault.Secrets; using Azure.Storage.Blobs; using golf1052.Mastodon; +using golf1052.ThreadsAPI; using idunno.Authentication.Basic; using ImageMagick; using Imgur.API.Authentication; @@ -228,6 +229,19 @@ public static void Main(string[] args) c.GetRequiredService()); }); services.AddSingleton(); + services.AddSingleton(c => + { + SecretClient secretClient = c.GetRequiredService(); + + ThreadsClient threadsClient = new ThreadsClient(secretClient.GetSecret("threads-client-id").Value.Value, + secretClient.GetSecret("threads-client-secret").Value.Value, + c.GetRequiredService()) + { + LongLivedAccessToken = secretClient.GetSecret("threads-access-token").Value.Value, + UserId = secretClient.GetSecret("threads-userid").Value.Value + }; + return threadsClient; + }); var app = builder.Build(); Logger = app.Logger; diff --git a/SeattleCarsInBikeLanes/wwwroot/js/threads-redirect.js b/SeattleCarsInBikeLanes/wwwroot/js/threads-redirect.js new file mode 100644 index 0000000..14b87fb --- /dev/null +++ b/SeattleCarsInBikeLanes/wwwroot/js/threads-redirect.js @@ -0,0 +1,30 @@ +function displayError(errorMessage) { + const errorElement = document.createElement('h1'); + errorElement.append(errorMessage); + document.getElementsByTagName('body')[0].append(errorElement); +} + +const url = new URL(window.location.href); +if (url.searchParams.has('code')) { + fetch(`api/Threads/Redirect?code=${url.searchParams.get('code')}`, { + method: 'POST' + }) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to get access token from Threads ${response}`); + } + + return response.json(); + }) + .then(response => { + localStorage.setItem('threadsAccessToken', response.accessToken); + localStorage.setItem('threadsRefreshToken', response.refreshToken); + localStorage.setItem('threadsExpiresAt', response.expiresAt); + window.location.href = '/'; + }) + .catch(error => { + displayError(error.message); + }); +} else { + displayError('Redirect failed, no code from Threads'); +} diff --git a/SeattleCarsInBikeLanes/wwwroot/threadsredirect.html b/SeattleCarsInBikeLanes/wwwroot/threadsredirect.html new file mode 100644 index 0000000..bf1a25c --- /dev/null +++ b/SeattleCarsInBikeLanes/wwwroot/threadsredirect.html @@ -0,0 +1,11 @@ + + + + + + Threads Redirect + + + + +