-
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from ernado-x/dev
Initial release
- Loading branch information
Showing
14 changed files
with
633 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -396,3 +396,4 @@ FodyWeavers.xsd | |
|
||
# JetBrains Rider | ||
*.sln.iml | ||
.idea/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,35 @@ | ||
# X.Bluesky | ||
Client for Bluesky | ||
|
||
The X.Bluesky is a .NET library designed to make it easy for developers to post messages to Bluesky, a decentralized social network. | ||
|
||
By leveraging the Bluesky API, this project allows for straightforward integration into .NET applications, enabling posts to be made programmatically. | ||
|
||
## 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. | ||
|
||
## Getting Started | ||
|
||
### Prerequisites | ||
|
||
- .NET SDK (compatible with the version used by the project) | ||
- An account on Bluesky with an identifier and password | ||
|
||
### Installation | ||
|
||
To use the X.Bluesky library in your project, include it as a dependency in your project's file (e.g., `csproj`). Documentation on how to do this will be provided based on the package hosting solution used (e.g., NuGet). | ||
|
||
### Usage | ||
|
||
```csharp | ||
var identifier = "your.bluesky.identifier"; | ||
var password = "your-password-here"; | ||
|
||
IBlueskyClient client = new BlueskyClient(identifier, password); | ||
|
||
var link = new Uri("https://yourlink.com/post/123"); | ||
|
||
await client.Post("Hello world!", link); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
|
||
Microsoft Visual Studio Solution File, Format Version 12.00 | ||
# Visual Studio 15 | ||
VisualStudioVersion = 15.0.27004.2008 | ||
MinimumVisualStudioVersion = 10.0.40219.1 | ||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5B9BA258-2A81-4F93-808A-80F0823F7EDB}" | ||
EndProject | ||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4E157A7F-77D1-43D9-B7D2-88E846AB091F}" | ||
EndProject | ||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "X.Bluesky", "src\X.Bluesky\X.Bluesky.csproj", "{D29A4941-24B9-433D-B0D1-3DACB346F9F0}" | ||
EndProject | ||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "X.Bluesky.Tests", "tests\X.Bluesky.Tests\X.Bluesky.Tests.csproj", "{864796E8-0D53-4B04-AAE7-A3454D634687}" | ||
EndProject | ||
Global | ||
GlobalSection(SolutionConfigurationPlatforms) = preSolution | ||
Debug|Any CPU = Debug|Any CPU | ||
Release|Any CPU = Release|Any CPU | ||
EndGlobalSection | ||
GlobalSection(ProjectConfigurationPlatforms) = postSolution | ||
{D29A4941-24B9-433D-B0D1-3DACB346F9F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||
{D29A4941-24B9-433D-B0D1-3DACB346F9F0}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||
{D29A4941-24B9-433D-B0D1-3DACB346F9F0}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||
{D29A4941-24B9-433D-B0D1-3DACB346F9F0}.Release|Any CPU.Build.0 = Release|Any CPU | ||
{864796E8-0D53-4B04-AAE7-A3454D634687}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | ||
{864796E8-0D53-4B04-AAE7-A3454D634687}.Debug|Any CPU.Build.0 = Debug|Any CPU | ||
{864796E8-0D53-4B04-AAE7-A3454D634687}.Release|Any CPU.ActiveCfg = Release|Any CPU | ||
{864796E8-0D53-4B04-AAE7-A3454D634687}.Release|Any CPU.Build.0 = Release|Any CPU | ||
EndGlobalSection | ||
GlobalSection(SolutionProperties) = preSolution | ||
HideSolutionNode = FALSE | ||
EndGlobalSection | ||
GlobalSection(NestedProjects) = preSolution | ||
{D29A4941-24B9-433D-B0D1-3DACB346F9F0} = {5B9BA258-2A81-4F93-808A-80F0823F7EDB} | ||
{864796E8-0D53-4B04-AAE7-A3454D634687} = {4E157A7F-77D1-43D9-B7D2-88E846AB091F} | ||
EndGlobalSection | ||
GlobalSection(ExtensibilityGlobals) = postSolution | ||
SolutionGuid = {0732F54F-7D46-43DF-9EE4-79DBF1324517} | ||
EndGlobalSection | ||
EndGlobal |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,264 @@ | ||
using System.Collections.Immutable; | ||
using System.Net.Http.Headers; | ||
using System.Security.Authentication; | ||
using System.Text; | ||
using JetBrains.Annotations; | ||
using Microsoft.Extensions.Logging; | ||
using Microsoft.Extensions.Logging.Abstractions; | ||
using Newtonsoft.Json; | ||
using Newtonsoft.Json.Serialization; | ||
using X.Bluesky.Models; | ||
|
||
namespace X.Bluesky; | ||
|
||
[PublicAPI] | ||
public interface IBlueskyClient | ||
{ | ||
/// <summary> | ||
/// Make post with link | ||
/// </summary> | ||
/// <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); | ||
|
||
/// <summary> | ||
/// Authorize in Bluesky | ||
/// </summary> | ||
/// <param name="identifier">Bluesky identifier</param> | ||
/// <param name="password">Bluesky application password</param> | ||
/// <returns></returns> | ||
Task<Session?> Authorize(string identifier, string password); | ||
} | ||
|
||
public class BlueskyClient : IBlueskyClient | ||
{ | ||
private readonly string _identifier; | ||
private readonly string _password; | ||
private readonly ILogger _logger; | ||
private readonly IHttpClientFactory _httpClientFactory; | ||
private readonly IReadOnlyCollection<string> _languages; | ||
private readonly FileTypeHelper _fileTypeHelper; | ||
|
||
/// <summary> | ||
/// | ||
/// </summary> | ||
/// <param name="httpClientFactory"></param> | ||
/// <param name="identifier">Bluesky identifier</param> | ||
/// <param name="password">Bluesky application password</param> | ||
/// <param name="languages">Post languages</param> | ||
/// <param name="logger"></param> | ||
public BlueskyClient( | ||
IHttpClientFactory httpClientFactory, | ||
string identifier, | ||
string password, | ||
IEnumerable<string> languages, | ||
ILogger<BlueskyClient> logger) | ||
{ | ||
_httpClientFactory = httpClientFactory; | ||
_identifier = identifier; | ||
_password = password; | ||
_logger = logger; | ||
_fileTypeHelper = new FileTypeHelper(logger); | ||
_languages = languages?.ToImmutableList() ?? ImmutableList<string>.Empty; | ||
} | ||
|
||
/// <summary> | ||
/// | ||
/// </summary> | ||
/// <param name="httpClientFactory"></param> | ||
/// <param name="identifier">Bluesky identifier</param> | ||
/// <param name="password">Bluesky application password</param> | ||
public BlueskyClient( | ||
IHttpClientFactory httpClientFactory, | ||
string identifier, | ||
string password) | ||
: this(httpClientFactory, identifier, password, new[] { "en", "en-US" }, NullLogger<BlueskyClient>.Instance) | ||
{ | ||
} | ||
|
||
/// <summary> | ||
/// | ||
/// </summary> | ||
/// <param name="identifier">Bluesky identifier</param> | ||
/// <param name="password">Bluesky application password</param> | ||
public BlueskyClient(string identifier, string password) | ||
: this(new HttpClientFactory(), identifier, password) | ||
{ | ||
} | ||
|
||
private async Task CreatePost(Session session, 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"); | ||
|
||
// Required fields for the post | ||
var post = new Post | ||
{ | ||
Type = "app.bsky.feed.post", | ||
Text = text, | ||
CreatedAt = now, | ||
Langs = _languages.ToList() | ||
}; | ||
|
||
if (url != null) | ||
{ | ||
post.Embed = new Embed | ||
{ | ||
External = await CreateEmbedCardAsync(url, session.AccessJwt), | ||
Type = "app.bsky.embed.external" | ||
}; | ||
} | ||
|
||
var requestUri = "https://bsky.social/xrpc/com.atproto.repo.createRecord"; | ||
|
||
var requestData = new CreatePostRequest | ||
{ | ||
Repo = session.Did, | ||
Collection = "app.bsky.feed.post", | ||
Record = post | ||
}; | ||
|
||
var jsonRequest = JsonConvert.SerializeObject(requestData, Formatting.Indented, new JsonSerializerSettings | ||
{ | ||
Formatting = Formatting.Indented, | ||
ContractResolver = new CamelCasePropertyNamesContractResolver(), | ||
NullValueHandling = NullValueHandling.Ignore | ||
}); | ||
|
||
var content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"); | ||
|
||
var httpClient = _httpClientFactory.CreateClient(); | ||
|
||
// Add the Authorization header with the bearer token | ||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessJwt); | ||
|
||
var response = await httpClient.PostAsync(requestUri, content); | ||
|
||
var ensureSuccessStatusCode = response.EnsureSuccessStatusCode(); | ||
|
||
// This throws an exception if the HTTP response status is an error code. | ||
} | ||
|
||
public async Task Post(string text, Uri? uri) | ||
{ | ||
var session = await Authorize(_identifier, _password); | ||
|
||
if (session == null) | ||
{ | ||
throw new AuthenticationException(); | ||
} | ||
|
||
await CreatePost(session, text, uri); | ||
} | ||
|
||
public Task Post(string text) => Post(text, null); | ||
|
||
/// <summary> | ||
/// | ||
/// </summary> | ||
/// <param name="identifier">Account identifier</param> | ||
/// <param name="password">App password</param> | ||
/// <returns></returns> | ||
public async Task<Session?> Authorize(string identifier, string password) | ||
{ | ||
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); | ||
} | ||
|
||
private async Task<EmbedCard> CreateEmbedCardAsync(Uri url, string accessToken) | ||
{ | ||
var extractor = new Web.MetaExtractor.Extractor(); | ||
var metadata = await extractor.ExtractAsync(url); | ||
|
||
var card = new EmbedCard | ||
{ | ||
Uri = url.ToString(), | ||
Title = metadata.Title, | ||
Description = metadata.Description | ||
}; | ||
|
||
if (metadata.Images != null && metadata.Images.Any()) | ||
{ | ||
var imgUrl = metadata.Images.FirstOrDefault(); | ||
|
||
if (!string.IsNullOrWhiteSpace(imgUrl)) | ||
{ | ||
if (!imgUrl.Contains("://")) | ||
{ | ||
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(url, imgUrl), accessToken); | ||
} | ||
else | ||
{ | ||
card.Thumb = await UploadImageAndSetThumbAsync(new Uri(imgUrl), accessToken); | ||
} | ||
} | ||
} | ||
|
||
return card; | ||
} | ||
|
||
private async Task<Thumb?> UploadImageAndSetThumbAsync(Uri imageUrl, string accessToken) | ||
{ | ||
var httpClient = _httpClientFactory.CreateClient(); | ||
|
||
var imgResp = await httpClient.GetAsync(imageUrl); | ||
imgResp.EnsureSuccessStatusCode(); | ||
|
||
var mimeType = _fileTypeHelper.GetMimeTypeFromUrl(imageUrl); | ||
|
||
var imageContent = new StreamContent(await imgResp.Content.ReadAsStreamAsync()); | ||
imageContent.Headers.ContentType = new MediaTypeHeaderValue(mimeType); | ||
|
||
var request = new HttpRequestMessage(HttpMethod.Post, "https://bsky.social/xrpc/com.atproto.repo.uploadBlob") | ||
{ | ||
Content = imageContent, | ||
}; | ||
|
||
// Add the Authorization header with the access token to the request message | ||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); | ||
|
||
var response = await httpClient.SendAsync(request); | ||
|
||
response.EnsureSuccessStatusCode(); | ||
|
||
var json = await response.Content.ReadAsStringAsync(); | ||
var blob = JsonConvert.DeserializeObject<BlobResponse>(json); | ||
|
||
var card = blob?.Blob; | ||
|
||
if (card != null) | ||
{ | ||
// ToDo: fix it | ||
// This is hack for fix problem when Type is empty after deserialization | ||
card.Type = "blob"; | ||
} | ||
|
||
return card; | ||
} | ||
} |
Oops, something went wrong.