diff --git a/.gitignore b/.gitignore index 8a30d25..0bdc52e 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 21dc241..0d431c6 100644 --- a/README.md +++ b/README.md @@ -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); +``` diff --git a/X.Bluesky.sln b/X.Bluesky.sln new file mode 100644 index 0000000..1a26d5f --- /dev/null +++ b/X.Bluesky.sln @@ -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 diff --git a/src/X.Bluesky/BlueskyClient.cs b/src/X.Bluesky/BlueskyClient.cs new file mode 100644 index 0000000..21b3e7a --- /dev/null +++ b/src/X.Bluesky/BlueskyClient.cs @@ -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 +{ + /// + /// Make post with link + /// + /// + /// + Task Post(string text); + + /// + /// Make post with link (page preview will be attached) + /// + /// + /// + /// + Task Post(string text, Uri? uri); + + /// + /// Authorize in Bluesky + /// + /// Bluesky identifier + /// Bluesky application password + /// + Task 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 _languages; + private readonly FileTypeHelper _fileTypeHelper; + + /// + /// + /// + /// + /// Bluesky identifier + /// Bluesky application password + /// Post languages + /// + public BlueskyClient( + IHttpClientFactory httpClientFactory, + string identifier, + string password, + IEnumerable languages, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _identifier = identifier; + _password = password; + _logger = logger; + _fileTypeHelper = new FileTypeHelper(logger); + _languages = languages?.ToImmutableList() ?? ImmutableList.Empty; + } + + /// + /// + /// + /// + /// Bluesky identifier + /// Bluesky application password + public BlueskyClient( + IHttpClientFactory httpClientFactory, + string identifier, + string password) + : this(httpClientFactory, identifier, password, new[] { "en", "en-US" }, NullLogger.Instance) + { + } + + /// + /// + /// + /// Bluesky identifier + /// Bluesky application password + 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); + + /// + /// + /// + /// Account identifier + /// App password + /// + public async Task 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(jsonResponse); + } + + private async Task 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 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(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; + } +} \ No newline at end of file diff --git a/src/X.Bluesky/FileTypeHelper.cs b/src/X.Bluesky/FileTypeHelper.cs new file mode 100644 index 0000000..9e4cdf4 --- /dev/null +++ b/src/X.Bluesky/FileTypeHelper.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.Logging; + +namespace X.Bluesky; + +public class FileTypeHelper +{ + private readonly ILogger _logger; + + public FileTypeHelper(ILogger logger) + { + _logger = logger; + } + + public string GetFileExtensionFromUrl(string url) + { + try + { + var uri = new Uri(url); + var lastIndex = uri.AbsolutePath.LastIndexOf('.'); + + if (lastIndex != -1 && lastIndex < uri.AbsolutePath.Length - 1) + { + // Return the file extension, including the dot + return uri.AbsolutePath.Substring(lastIndex); + } + } + catch (Exception ex) + { + _logger.LogWarning($"Error extracting file extension: {ex.Message}", ex); + } + + // Return an empty string if no extension found or an error occurred + return string.Empty; + } + + public string GetMimeTypeFromUrl(string url) + { + var extension = GetFileExtensionFromUrl(url); + + return GetMimeType(extension); + } + + public string GetMimeTypeFromUrl(Uri uri) + { + return GetMimeTypeFromUrl(uri.ToString()); + } + + public string GetMimeType(string extension) + { + if (string.IsNullOrEmpty(extension)) + { + return "application/octet-stream"; + } + + // Ensure the extension starts with a dot + if (extension[0] != '.') + { + extension = "." + extension; + } + + switch (extension.ToLower()) + { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".png": + return "image/png"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".svg": + return "image/svg+xml"; + case ".tiff": + case ".tif": + return "image/tiff"; + case ".avif": + return "image/avif"; + case ".heic": + return "image/heic"; + case ".bmp": + return "image/bmp"; + case ".ico": + case ".icon": + return "image/x-icon"; + default: + return "application/octet-stream"; // Default MIME type + } + } + + +} \ No newline at end of file diff --git a/src/X.Bluesky/HttpClientFactory.cs b/src/X.Bluesky/HttpClientFactory.cs new file mode 100644 index 0000000..72cb72e --- /dev/null +++ b/src/X.Bluesky/HttpClientFactory.cs @@ -0,0 +1,16 @@ +using System.Net; + +namespace X.Bluesky; + +public class HttpClientFactory : IHttpClientFactory +{ + public HttpClient CreateClient(string name) + { + HttpMessageHandler handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate + }; + + return new HttpClient(handler); + } +} \ No newline at end of file diff --git a/src/X.Bluesky/Models/CreatePostRequest.cs b/src/X.Bluesky/Models/CreatePostRequest.cs new file mode 100644 index 0000000..c079fae --- /dev/null +++ b/src/X.Bluesky/Models/CreatePostRequest.cs @@ -0,0 +1,8 @@ +namespace X.Bluesky.Models; + +public record CreatePostRequest +{ + public string Repo { get; set; }= ""; + public string Collection { get; set; } = ""; + public Post Record { get; set; } = new(); +} \ No newline at end of file diff --git a/src/X.Bluesky/Models/EmbedCard.cs b/src/X.Bluesky/Models/EmbedCard.cs new file mode 100644 index 0000000..780122a --- /dev/null +++ b/src/X.Bluesky/Models/EmbedCard.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; + +namespace X.Bluesky.Models; + +public record EmbedCard +{ + public string Uri { get; set; } = ""; + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public Thumb? Thumb { get; set; } +} + +public record BlobResponse +{ + public Thumb? Blob { get; set; } +} + +public record Thumb +{ + [JsonProperty("$type")] + public string Type { get; set; } = ""; + + [JsonProperty("ref")] + public ThumbRef? Ref { get; set; } + + [JsonProperty("mimeType")] + public string MimeType { get; set; } = ""; + + [JsonProperty("size")] + public int Size { get; set; } +} + +public class ThumbRef +{ + [JsonProperty("$link")] + public string Link { get; set; } = ""; +} \ No newline at end of file diff --git a/src/X.Bluesky/Models/Post.cs b/src/X.Bluesky/Models/Post.cs new file mode 100644 index 0000000..b9c5284 --- /dev/null +++ b/src/X.Bluesky/Models/Post.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; + +namespace X.Bluesky.Models; + +public record Post +{ + [JsonProperty("$type")] + public string Type { get; set; } = ""; + + public string Text { get; set; } = ""; + + public string CreatedAt { get; set; } = ""; + + public Embed Embed { get; set; } = new(); + + public List Langs { get; set; } = new(); + + public List? Facets { get; set; } = null; +} + +public record Embed +{ + [JsonProperty("$type")] + public string Type { get; set; } = ""; + + public EmbedCard External { get; set; } = new(); +} + +public record Facet +{ + public FacetIndex Index { get; set; } = new(); + + public List Features { get; set; } = new(); +} + +public record FacetFeature +{ + [JsonProperty("$type")] + public string Type { get; set; } = ""; + + public Uri? Uri { get; set; } +} + +public record FacetIndex +{ + public int ByteStart { get; set; } + + public int ByteEnd { get; set; } +} \ No newline at end of file diff --git a/src/X.Bluesky/Models/Session.cs b/src/X.Bluesky/Models/Session.cs new file mode 100644 index 0000000..83ee0b3 --- /dev/null +++ b/src/X.Bluesky/Models/Session.cs @@ -0,0 +1,10 @@ +namespace X.Bluesky.Models; + +/// +/// Bluesky session +/// +public record Session +{ + public string AccessJwt { get; set; } = ""; + public string Did { get; set; } = ""; +} \ No newline at end of file diff --git a/src/X.Bluesky/X.Bluesky.csproj b/src/X.Bluesky/X.Bluesky.csproj new file mode 100644 index 0000000..ffdf6be --- /dev/null +++ b/src/X.Bluesky/X.Bluesky.csproj @@ -0,0 +1,40 @@ + + + + README.md + enable + enable + Andrew Gubskiy + Simple client for posting to Bluesky + Andrew Gubskiy + https://github.com/ernado-x/X.Bluesky + bluesky, social networks + 1.0.1.0 + 1.0.1.0 + net8.0;netstandard2.1 + default + true + X.Bluesky + https://raw.githubusercontent.com/ernado-x/X.Bluesky/main/LICENSE + icon.png + https://github.com/ernado-x/X.Bluesky + git + 1.0.1 + + + + + + + + + + + + + + + + + + diff --git a/src/X.Bluesky/icon.png b/src/X.Bluesky/icon.png new file mode 100644 index 0000000..7b4e4ec Binary files /dev/null and b/src/X.Bluesky/icon.png differ diff --git a/tests/X.Bluesky.Tests/BlueskyIntegrationTest.cs b/tests/X.Bluesky.Tests/BlueskyIntegrationTest.cs new file mode 100644 index 0000000..73326b0 --- /dev/null +++ b/tests/X.Bluesky.Tests/BlueskyIntegrationTest.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading.Tasks; +using X.Bluesky; +using Xunit; + +namespace Tests; + +public class BlueskyIntegrationTest +{ + [Fact] + public async Task CheckSending() + { + var identifier = "devdigest.bsky.social"; + var password = "{password-here}"; + + IBlueskyClient client = new BlueskyClient(identifier, password); + + var link = new Uri("https://devdigest.today/post/2431"); + + await client.Post("Hello world!", link); + + Assert.True(true); + } +} \ No newline at end of file diff --git a/tests/X.Bluesky.Tests/X.Bluesky.Tests.csproj b/tests/X.Bluesky.Tests/X.Bluesky.Tests.csproj new file mode 100644 index 0000000..cb5895b --- /dev/null +++ b/tests/X.Bluesky.Tests/X.Bluesky.Tests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + false + default + + + + + + + + + + + + + \ No newline at end of file