Skip to content

Commit

Permalink
Merge pull request #1 from ernado-x/dev
Browse files Browse the repository at this point in the history
Initial release
  • Loading branch information
ernado-x authored Feb 6, 2024
2 parents da64f3c + d70bb17 commit 67e7279
Show file tree
Hide file tree
Showing 14 changed files with 633 additions and 1 deletion.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,4 @@ FodyWeavers.xsd

# JetBrains Rider
*.sln.iml
.idea/
35 changes: 34 additions & 1 deletion README.md
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);
```
39 changes: 39 additions & 0 deletions X.Bluesky.sln
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
264 changes: 264 additions & 0 deletions src/X.Bluesky/BlueskyClient.cs
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;
}
}
Loading

0 comments on commit 67e7279

Please sign in to comment.