Skip to content

Commit

Permalink
Add Threads support
Browse files Browse the repository at this point in the history
It's finally here 🥲
  • Loading branch information
golf1052 committed Jun 20, 2024
1 parent 6a82e1d commit bd67240
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 6 deletions.
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path to .nupkg> -k <NuGet API key>`
8 changes: 7 additions & 1 deletion SeattleCarsInBikeLanes.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
134 changes: 130 additions & 4 deletions SeattleCarsInBikeLanes/Controllers/AdminPageController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
using golf1052.Mastodon;
using golf1052.Mastodon.Models.Statuses;
using golf1052.Mastodon.Models.Statuses.Media;
using golf1052.ThreadsAPI;

Check failure on line 14 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)

Check failure on line 14 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)
using golf1052.ThreadsAPI.Models;

Check failure on line 15 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)

Check failure on line 15 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)
using Imgur.API.Endpoints;
using Imgur.API.Models;
using LinqToTwitter;
Expand Down Expand Up @@ -43,6 +45,7 @@ public class AdminPageController : ControllerBase
private readonly MastodonClientProvider mastodonClientProvider;
private readonly FeedProvider feedProvider;
private readonly BlueskyClientProvider blueskyClientProvider;
private readonly ThreadsClient threadsClient;

Check failure on line 48 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsClient' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 48 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsClient' could not be found (are you missing a using directive or an assembly reference?)

public AdminPageController(ILogger<AdminPageController> logger,
HelperMethods helperMethods,
Expand All @@ -54,7 +57,8 @@ public AdminPageController(ILogger<AdminPageController> logger,
MapsSearchClient mapsSearchClient,
MastodonClientProvider mastodonClientProvider,
FeedProvider feedProvider,
BlueskyClientProvider blueskyClientProvider)
BlueskyClientProvider blueskyClientProvider,
ThreadsClient threadsClient)

Check failure on line 61 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsClient' could not be found (are you missing a using directive or an assembly reference?)

Check failure on line 61 in SeattleCarsInBikeLanes/Controllers/AdminPageController.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsClient' could not be found (are you missing a using directive or an assembly reference?)
{
this.logger = logger;
this.helperMethods = helperMethods;
Expand All @@ -66,6 +70,7 @@ public AdminPageController(ILogger<AdminPageController> logger,
this.mastodonClientProvider = mastodonClientProvider;
this.feedProvider = feedProvider;
this.blueskyClientProvider = blueskyClientProvider;
this.threadsClient = threadsClient;

SingleUserAuthorizer auth = new SingleUserAuthorizer()
{
Expand Down Expand Up @@ -471,12 +476,14 @@ public async Task<IActionResult> PostTweet([FromBody] PostTweetRequest request)
tweetText += $"\n{request.QuoteTweetLink}";
}

List<string> tweetImageLinks = new List<string>();
List<Stream> pictureStreams = new List<Stream>();
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"))
{
Expand Down Expand Up @@ -605,6 +612,54 @@ public async Task<IActionResult> 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<string> containerIds = new List<string>(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);
Expand Down Expand Up @@ -802,6 +857,8 @@ public async Task<IActionResult> 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);
Expand Down Expand Up @@ -984,7 +1041,7 @@ public async Task<IActionResult> PostMonthlyStats([FromBody] PostMonthlyStatsReq
{
firstToot = await mastodonClient.PublishStatus($"{introText}");
}

MastodonStatus latestToot = firstToot;
if (!skipMostCars)
{
Expand All @@ -1005,7 +1062,7 @@ public async Task<IActionResult> 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
{
Expand Down Expand Up @@ -1058,7 +1115,7 @@ public async Task<IActionResult> PostMonthlyStats([FromBody] PostMonthlyStatsReq
}
});
}

CreateRecordResponse latestSkeet = firstSkeet;
if (mostCars.Count > 1)
{
Expand Down Expand Up @@ -1187,6 +1244,59 @@ public async Task<IActionResult> 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();
}

Expand All @@ -1206,6 +1316,22 @@ public async Task<IActionResult> 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<ReportedItem?> FindReportedItem(string postIdentifier)
{
string? identifier = null;
Expand Down
6 changes: 6 additions & 0 deletions SeattleCarsInBikeLanes/Controllers/RedirectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@ public IActionResult GetMastodon()
{
return File("mastodonredirect.html", "text/html");
}

[HttpGet("/threadsredirect")]
public IActionResult GetThreads()
{
return File("threadsredirect.html", "text/html");
}
}
}
29 changes: 29 additions & 0 deletions SeattleCarsInBikeLanes/Controllers/ThreadsController.cs
Original file line number Diff line number Diff line change
@@ -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";
}
}
}
}
1 change: 1 addition & 0 deletions SeattleCarsInBikeLanes/Database/Models/ReportedItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
3 changes: 2 additions & 1 deletion SeattleCarsInBikeLanes/Database/ReportedItemsDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@ public async Task<bool> 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<ReportedItem> iterator = query.ToFeedIterator();
return await ProcessIterator(iterator);
Expand Down
14 changes: 14 additions & 0 deletions SeattleCarsInBikeLanes/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Azure.Security.KeyVault.Secrets;
using Azure.Storage.Blobs;
using golf1052.Mastodon;
using golf1052.ThreadsAPI;

Check failure on line 8 in SeattleCarsInBikeLanes/Program.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)

Check failure on line 8 in SeattleCarsInBikeLanes/Program.cs

View workflow job for this annotation

GitHub Actions / build

The type or namespace name 'ThreadsAPI' does not exist in the namespace 'golf1052' (are you missing an assembly reference?)
using idunno.Authentication.Basic;
using ImageMagick;
using Imgur.API.Authentication;
Expand Down Expand Up @@ -228,6 +229,19 @@ public static void Main(string[] args)
c.GetRequiredService<HttpClient>());
});
services.AddSingleton<GuessGameManager>();
services.AddSingleton(c =>
{
SecretClient secretClient = c.GetRequiredService<SecretClient>();
ThreadsClient threadsClient = new ThreadsClient(secretClient.GetSecret("threads-client-id").Value.Value,
secretClient.GetSecret("threads-client-secret").Value.Value,
c.GetRequiredService<HttpClient>())
{
LongLivedAccessToken = secretClient.GetSecret("threads-access-token").Value.Value,
UserId = secretClient.GetSecret("threads-userid").Value.Value
};
return threadsClient;
});

var app = builder.Build();
Logger = app.Logger;
Expand Down
30 changes: 30 additions & 0 deletions SeattleCarsInBikeLanes/wwwroot/js/threads-redirect.js
Original file line number Diff line number Diff line change
@@ -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');
}
11 changes: 11 additions & 0 deletions SeattleCarsInBikeLanes/wwwroot/threadsredirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Threads Redirect</title>
</head>
<body>
<script src="js/threads-redirect.js"></script>
</body>
</html>

0 comments on commit bd67240

Please sign in to comment.