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