Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Facet detection #14

Merged
merged 15 commits into from
Sep 12, 2024
File renamed without changes.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ By leveraging the Bluesky API, this project allows for straightforward integrati

## Features

- Post messages directly to Bluesky.
- Attach links to posts, allowing for page previews within the Bluesky feed.
- Authenticate with Bluesky using an identifier and password.
- Post messages directly to Bluesky
- Attach links to posts, allowing for page previews within the Bluesky feed
- Authenticate with Bluesky using an identifier and password
- Automatically generate tags, mentions and url cards

## Getting Started

Expand All @@ -31,7 +32,8 @@ var password = "your-password-here";

IBlueskyClient client = new BlueskyClient(identifier, password);

var link = new Uri("https://yourlink.com/post/123");
await client.Post($"Read this post from #devdigest: https://yourlink.com/post/123");

await client.Post("Hello world!", link);
await client.Post($"Read this post!", new Uri("https://yourlink.com/post/123");
```

3 changes: 3 additions & 0 deletions X.Bluesky.sln
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
VisualStudioVersion = 15.0.27004.2008
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5B9BA258-2A81-4F93-808A-80F0823F7EDB}"
ProjectSection(SolutionItems) = preProject
src\Directory.Build.props = src\Directory.Build.props
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4E157A7F-77D1-43D9-B7D2-88E846AB091F}"
EndProject
Expand Down
24 changes: 24 additions & 0 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project>

<PropertyGroup>
<GeneratePackageOnBuild>True</GeneratePackageOnBuild>

<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE.md</PackageLicenseFile>
<PackageIcon>x.png</PackageIcon>

<Authors>Andrew Gubskiy</Authors>
<Copyright>Andrew Gubskiy © 2024</Copyright>
<Company>Ukrainian .NET Developer Community</Company>

<Version>1.1.0</Version>
<AssemblyVersion>1.1.0</AssemblyVersion>
<FileVersion>1.1.0</FileVersion>
<PackageVersion>1.1.0</PackageVersion>

<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/ernado-x/X.Bluesky.git</RepositoryUrl>
<PackageProjectUrl>https://andrew.gubskiy.com/open-source</PackageProjectUrl>
</PropertyGroup>

</Project>
59 changes: 59 additions & 0 deletions src/X.Bluesky/AuthorizationClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Text;
using Newtonsoft.Json;
using X.Bluesky.Models;

namespace X.Bluesky;

public interface IAuthorizationClient
{
Task<Session> GetSession();
}

public class AuthorizationClient : IAuthorizationClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _identifier;
private readonly string _password;

public AuthorizationClient(string identifier, string password)
: this(new BlueskyHttpClientFactory(), identifier, password)
{
}

public AuthorizationClient(IHttpClientFactory httpClientFactory, string identifier, string password)
{
_httpClientFactory = httpClientFactory;
_identifier = identifier;
_password = password;
}

/// <summary>
/// Authorize in Bluesky
/// </summary>
/// <returns>
/// Instance of authorized session
/// </returns>
public async Task<Session> GetSession()
{
var requestData = new
{
identifier = _identifier,
password = _password
};

var json = JsonConvert.SerializeObject(requestData);

var content = new StringContent(json, Encoding.UTF8, "application/json");

var httpClient = _httpClientFactory.CreateClient();

var uri = "https://bsky.social/xrpc/com.atproto.server.createSession";
var response = await httpClient.PostAsync(uri, content);

response.EnsureSuccessStatusCode();

var jsonResponse = await response.Content.ReadAsStringAsync();

return JsonConvert.DeserializeObject<Session>(jsonResponse)!;
}
}
122 changes: 60 additions & 62 deletions src/X.Bluesky/BlueskyClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,15 @@ public interface IBlueskyClient
/// <param name="text"></param>
/// <returns></returns>
Task Post(string text);

/// <summary>
/// Make post with link (page preview will be attached)
/// </summary>
/// <param name="text"></param>
/// <param name="uri"></param>
/// <returns></returns>

Task Post(string text, Uri uri);
}

public class BlueskyClient : IBlueskyClient
{
private readonly string _identifier;
private readonly string _password;
private readonly ILogger _logger;
private readonly IAuthorizationClient _authorizationClient;
private readonly IMentionResolver _mentionResolver;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IReadOnlyCollection<string> _languages;

Expand All @@ -54,10 +48,10 @@ public BlueskyClient(
ILogger<BlueskyClient> logger)
{
_httpClientFactory = httpClientFactory;
_identifier = identifier;
_password = password;
_authorizationClient = new AuthorizationClient(httpClientFactory, identifier, password);
_logger = logger;
_languages = languages.ToImmutableList();
_mentionResolver = new MentionResolver(_httpClientFactory);
}

/// <summary>
Expand All @@ -80,13 +74,29 @@ public BlueskyClient(
/// <param name="identifier">Bluesky identifier</param>
/// <param name="password">Bluesky application password</param>
public BlueskyClient(string identifier, string password)
: this(new HttpClientFactory(), identifier, password)
: this(new BlueskyHttpClientFactory(), identifier, password)
{
}

/// <summary>
/// Create post
/// </summary>
/// <param name="text">Post text</param>
/// <returns></returns>
public Task Post(string text) => CreatePost(text, null);

public Task Post(string text, Uri uri) => CreatePost(text, uri);


/// <summary>
/// Create post
/// </summary>
/// <param name="text">Post text</param>
/// <param name="url"></param>
/// <returns></returns>
private async Task CreatePost(string text, Uri? url)
{
var session = await Authorize(_identifier, _password);
var session = await _authorizationClient.GetSession();

if (session == null)
{
Expand All @@ -95,23 +105,51 @@ private async Task CreatePost(string text, Uri? url)

// Fetch the current time in ISO 8601 format, with "Z" to denote UTC
var now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
var facetBuilder = new FacetBuilder();

var facets = facetBuilder.GetFacets(text);

foreach (var facet in facets)
{
foreach (var facetFeature in facet.Features)
{
if (facetFeature is FacetFeatureMention facetFeatureMention)
{
var resolveDid = await _mentionResolver.ResolveMention(facetFeatureMention.Did);

facetFeatureMention.ResolveDid(resolveDid);
}
}
}

// Required fields for the post
var post = new Post
{
Type = "app.bsky.feed.post",
Text = text,
CreatedAt = now,
Langs = _languages.ToList()
Langs = _languages.ToList(),
Facets = facets.ToList()
};

if (url == null)
{
//If no link was defined we're trying to get link from facets
url = facets
.SelectMany(facet => facet.Features)
.Where(feature => feature is FacetFeatureLink)
.Cast<FacetFeatureLink>()
.Select(f => f.Uri)
.FirstOrDefault();
}

if (url != null)
{
var embedCardBuilder = new EmbedCardBuilder(_httpClientFactory, _logger);
var embedCardBuilder = new EmbedCardBuilder(_httpClientFactory, session, _logger);

post.Embed = new Embed
{
External = await embedCardBuilder.Create(url, session.AccessJwt),
External = await embedCardBuilder.GetEmbedCard(url),
Type = "app.bsky.embed.external"
};
}
Expand All @@ -122,7 +160,7 @@ private async Task CreatePost(string text, Uri? url)
{
Repo = session.Did,
Collection = "app.bsky.feed.post",
Record = post
Record = post,
};

var jsonRequest = JsonConvert.SerializeObject(requestData, Formatting.Indented, new JsonSerializerSettings
Expand All @@ -141,54 +179,14 @@ private async Task CreatePost(string text, Uri? url)

var response = await httpClient.PostAsync(requestUri, content);

// This throws an exception if the HTTP response status is an error code.
response.EnsureSuccessStatusCode();
}

/// <summary>
/// Create post
/// </summary>
/// <param name="text">Post text</param>
/// <returns></returns>
public Task Post(string text) => CreatePost(text, null);

/// <summary>
/// Create post with attached link
/// </summary>
/// <param name="text">Post text</param>
/// <param name="uri">Link to webpage</param>
/// <returns></returns>
public Task Post(string text, Uri uri) => CreatePost(text, uri);

/// <summary>
/// Authorize in Bluesky
/// </summary>
/// <param name="identifier">Bluesky identifier</param>
/// <param name="password">Bluesky application password</param>
/// <returns>
/// Instance of authorized session
/// </returns>
public async Task<Session?> Authorize(string identifier, string password)
{
var requestData = new
if (!response.IsSuccessStatusCode)
{
identifier = identifier,
password = password
};

var json = JsonConvert.SerializeObject(requestData);
var responseContent = await response.Content.ReadAsStringAsync();

var content = new StringContent(json, Encoding.UTF8, "application/json");

var httpClient = _httpClientFactory.CreateClient();

var uri = "https://bsky.social/xrpc/com.atproto.server.createSession";
var response = await httpClient.PostAsync(uri, content);
_logger.LogError(responseContent);
}

// This throws an exception if the HTTP response status is an error code.
response.EnsureSuccessStatusCode();

var jsonResponse = await response.Content.ReadAsStringAsync();

return JsonConvert.DeserializeObject<Session>(jsonResponse);
}
}
23 changes: 23 additions & 0 deletions src/X.Bluesky/BlueskyHttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Net;

namespace X.Bluesky;

public class BlueskyHttpClientFactory : IHttpClientFactory
{
private readonly HttpClient _client;

public BlueskyHttpClientFactory()
{
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};

_client = new HttpClient(handler);
}

public HttpClient CreateClient(string name)
{
return _client;
}
}
17 changes: 9 additions & 8 deletions src/X.Bluesky/EmbedCardBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,22 @@ public class EmbedCardBuilder
private readonly ILogger _logger;
private readonly FileTypeHelper _fileTypeHelper;
private readonly IHttpClientFactory _httpClientFactory;

public EmbedCardBuilder(IHttpClientFactory httpClientFactory, ILogger logger)
private readonly Session _session;

public EmbedCardBuilder(IHttpClientFactory httpClientFactory, Session session, ILogger logger)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_session = session;
_fileTypeHelper = new FileTypeHelper(logger);
}

/// <summary>
/// Create embed card
/// </summary>
/// <param name="url"></param>
/// <param name="accessToken"></param>
/// <returns></returns>
public async Task<EmbedCard> Create(Uri url, string accessToken)
public async Task<EmbedCard> GetEmbedCard(Uri url)
{
var extractor = new Web.MetaExtractor.Extractor();
var metadata = await extractor.ExtractAsync(url);
Expand All @@ -44,11 +45,11 @@ public async Task<EmbedCard> Create(Uri url, string accessToken)
{
if (!imgUrl.Contains("://"))
{
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(url, imgUrl), accessToken);
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(url, imgUrl));
}
else
{
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(imgUrl), accessToken);
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(imgUrl));
}

_logger.LogInformation("EmbedCard created");
Expand All @@ -58,7 +59,7 @@ public async Task<EmbedCard> Create(Uri url, string accessToken)
return card;
}

private async Task<Thumb?> UploadImageAndSetThumbAsync(Uri imageUrl, string accessToken)
private async Task<Thumb?> UploadImageAndSetThumbAsync(Uri imageUrl)
{
var httpClient = _httpClientFactory.CreateClient();

Expand All @@ -76,7 +77,7 @@ public async Task<EmbedCard> Create(Uri url, string accessToken)
};

// Add the Authorization header with the access token to the request message
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _session.AccessJwt);

var response = await httpClient.SendAsync(request);

Expand Down
Loading