Skip to content

Commit

Permalink
Facet detection (#14)
Browse files Browse the repository at this point in the history
* Add FacetBuilder
* Add tests
* Add mention resolver
* Update project parameters
* Update auth handling
* Rename LICENSE to LICENSE.md
  • Loading branch information
ernado-x authored Sep 12, 2024
1 parent c0464ec commit d170aac
Show file tree
Hide file tree
Showing 18 changed files with 513 additions and 122 deletions.
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

0 comments on commit d170aac

Please sign in to comment.