diff --git a/src/FishyFlip/ATProtocol.cs b/src/FishyFlip/ATProtocol.cs
index fcc6214c..ef0e9918 100644
--- a/src/FishyFlip/ATProtocol.cs
+++ b/src/FishyFlip/ATProtocol.cs
@@ -133,6 +133,11 @@ public ATProtocol(ATProtocolOptions options)
///
public PlcDirectory PlcDirectory => new(this);
+ ///
+ /// Gets the ATProto Chat Protocol.
+ ///
+ public BlueskyChat Chat => new(this);
+
///
/// Gets a value indicating whether the subscription is active.
///
diff --git a/src/FishyFlip/BlueskyChat.cs b/src/FishyFlip/BlueskyChat.cs
new file mode 100644
index 00000000..a66654ce
--- /dev/null
+++ b/src/FishyFlip/BlueskyChat.cs
@@ -0,0 +1,116 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip;
+
+///
+/// Bluesky Chat.
+///
+public sealed class BlueskyChat
+{
+ private ATProtocol proto;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// .
+ internal BlueskyChat(ATProtocol proto)
+ {
+ this.proto = proto;
+ }
+
+ private ATProtocolOptions Options => this.proto.Options;
+
+ private HttpClient Client => this.proto.Client;
+
+ ///
+ /// Retrieves the messages of a conversation asynchronously.
+ ///
+ /// The unique identifier of the conversation.
+ /// The cursor for pagination in the conversation.
+ /// Limit of conversations to get, defaults to 60.
+ /// An optional token to cancel the asynchronous operation.
+ /// A task that represents the asynchronous operation. The task result contains a that encapsulates the result of the operation.
+ public Task> GetConversationMessagesAsync(string convoId, string? cursor = default, int limit = 60, CancellationToken cancellationToken = default)
+ {
+ var url = $"{Constants.Urls.Bluesky.Chat.Convo.GetMessages}?convoId={convoId}";
+
+ if (!string.IsNullOrWhiteSpace(cursor))
+ {
+ url += $"&cursor={cursor}";
+ }
+
+ if (limit > 0)
+ {
+ url += $"&limit={limit}";
+ }
+
+ var headers = new Dictionary
+ {
+ { Constants.ATProtoProxy.Proxy, Constants.ATProtoProxy.BskyChat },
+ };
+
+ return this.Client.Get(url, this.Options.SourceGenerationContext.ConversationMessages, this.Options.JsonSerializerOptions, cancellationToken, this.Options.Logger, headers);
+ }
+
+ ///
+ /// Retrieves a list of conversations asynchronously.
+ ///
+ /// An optional string that represents the cursor position in the list. Default is an empty string.
+ /// Limit of conversations to get, defaults to 60.
+ /// An optional token to cancel the asynchronous operation.
+ /// A task that represents the asynchronous operation. The task result contains a that encapsulates the result of the operation.
+ public Task> GetConversationsAsync(string cursor = "", int limit = 60, CancellationToken cancellationToken = default)
+ {
+ var url = $"{Constants.Urls.Bluesky.Chat.Convo.ListConvos}?limit={limit}";
+
+ if (!string.IsNullOrWhiteSpace(cursor))
+ {
+ url += $"&cursor={cursor}";
+ }
+
+ var headers = new Dictionary
+ {
+ { Constants.ATProtoProxy.Proxy, Constants.ATProtoProxy.BskyChat },
+ };
+
+ return this.Client.Get(url, this.Options.SourceGenerationContext.ConversationList, this.Options.JsonSerializerOptions, cancellationToken, this.Options.Logger, headers);
+ }
+
+ ///
+ /// Retrieves a conversation asynchronously.
+ ///
+ /// The unique identifier of the conversation.
+ /// An optional token to cancel the asynchronous operation.
+ /// A task that represents the asynchronous operation. The task result contains a that encapsulates the result of the operation.
+ public Task> GetConversationAsync(string convoId, CancellationToken cancellationToken = default)
+ {
+ var url = $"{Constants.Urls.Bluesky.Chat.Convo.GetConvo}?convoId={convoId}";
+ var headers = new Dictionary
+ {
+ { Constants.ATProtoProxy.Proxy, Constants.ATProtoProxy.BskyChat },
+ };
+
+ return this.Client.Get(url, this.Options.SourceGenerationContext.ConversationView, this.Options.JsonSerializerOptions, cancellationToken, this.Options.Logger, headers);
+ }
+
+ ///
+ /// Sends a message asynchronously in a conversation.
+ ///
+ /// The unique identifier of the conversation.
+ /// The message to be sent.
+ /// An optional token to cancel the asynchronous operation.
+ /// A task that represents the asynchronous operation. The task result contains a that encapsulates the result of the operation.
+ public Task> SendMessageAsync(string convoId, string message, CancellationToken cancellationToken = default)
+ {
+ var url = $"{Constants.Urls.Bluesky.Chat.Convo.SendMessage}";
+ var createMessage = new CreateMessage(convoId, new CreateMessageMessage(message));
+ var headers = new Dictionary
+ {
+ { Constants.ATProtoProxy.Proxy, Constants.ATProtoProxy.BskyChat },
+ };
+
+ return this.Client.Post(url, this.Options.SourceGenerationContext.CreateMessage, this.Options.SourceGenerationContext.MessageView, this.Options.JsonSerializerOptions, createMessage, cancellationToken, this.Options.Logger, headers);
+ }
+}
\ No newline at end of file
diff --git a/src/FishyFlip/Constants.cs b/src/FishyFlip/Constants.cs
index 5996a3d8..d11f8464 100644
--- a/src/FishyFlip/Constants.cs
+++ b/src/FishyFlip/Constants.cs
@@ -283,6 +283,29 @@ public static class ActorTypes
public const string SavedFeedsPref = "app.bsky.actor.defs#savedFeedsPref";
}
+ public static class ConversationTypes
+ {
+ public const string MessageView = "chat.bsky.convo.defs#messageView";
+ }
+
+ public static class DeclarationTypes
+ {
+ public const string AllowIncomingPref = "chat.bsky.actor.declaration";
+ }
+
+ public static class ATProtoProxy
+ {
+ public const string Proxy = "atproto-proxy";
+ public const string BskyChat = "did:web:api.bsky.chat#bsky_chat";
+ }
+
+ public static class AllowIncomingTypes
+ {
+ public const string Following = "following";
+ public const string All = "all";
+ public const string None = "none";
+ }
+
public static class GraphTypes
{
public const string ListItem = "app.bsky.graph.listitem";
diff --git a/src/FishyFlip/Models/AllowIncomingPref.cs b/src/FishyFlip/Models/AllowIncomingPref.cs
new file mode 100644
index 00000000..2fd40e44
--- /dev/null
+++ b/src/FishyFlip/Models/AllowIncomingPref.cs
@@ -0,0 +1,29 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Adult Content Preference.
+///
+public class AllowIncomingPref : ATRecord
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Type of chat messages to allow.
+ /// ATRecord Type.
+ [JsonConstructor]
+ public AllowIncomingPref(string allowIncoming, string? type = default)
+ : base(type)
+ {
+ this.AllowIncoming = allowIncoming;
+ this.Type = type ?? Constants.DeclarationTypes.AllowIncomingPref;
+ }
+
+ ///
+ /// Gets a value the type of chat messages to allow.
+ ///
+ public string AllowIncoming { get; }
+}
\ No newline at end of file
diff --git a/src/FishyFlip/Models/ChatSender.cs b/src/FishyFlip/Models/ChatSender.cs
new file mode 100644
index 00000000..b2be4066
--- /dev/null
+++ b/src/FishyFlip/Models/ChatSender.cs
@@ -0,0 +1,11 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Chat Sender.
+///
+/// ATDid.
+public record ChatSender(ATDid Did);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/Conversation.cs b/src/FishyFlip/Models/Conversation.cs
new file mode 100644
index 00000000..12daf163
--- /dev/null
+++ b/src/FishyFlip/Models/Conversation.cs
@@ -0,0 +1,10 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Bluesky Conversation.
+///
+public record Conversation(string Id, string Rev, int UnreadCount, bool Muted);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/ConversationList.cs b/src/FishyFlip/Models/ConversationList.cs
new file mode 100644
index 00000000..ad0a9d07
--- /dev/null
+++ b/src/FishyFlip/Models/ConversationList.cs
@@ -0,0 +1,12 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Represents a list of conversations.
+///
+/// An array of Conversation objects.
+/// An optional string that represents the cursor position in the list. Default is an empty string.
+public record ConversationList(Conversation[] Convos, string Cursor = "");
\ No newline at end of file
diff --git a/src/FishyFlip/Models/ConversationMessages.cs b/src/FishyFlip/Models/ConversationMessages.cs
new file mode 100644
index 00000000..6ff3ef75
--- /dev/null
+++ b/src/FishyFlip/Models/ConversationMessages.cs
@@ -0,0 +1,12 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Represents a collection of messages in a conversation.
+///
+/// An array of instances representing the messages in the conversation.
+/// A string representing the cursor for pagination in the conversation.
+public record ConversationMessages(MessageView[] Messages, string Cursor);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/ConversationView.cs b/src/FishyFlip/Models/ConversationView.cs
new file mode 100644
index 00000000..2f5806ff
--- /dev/null
+++ b/src/FishyFlip/Models/ConversationView.cs
@@ -0,0 +1,11 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// The Conversation View.
+///
+/// The conversation.
+public record ConversationView(Conversation Convo);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/Internal/CreateMessage.cs b/src/FishyFlip/Models/Internal/CreateMessage.cs
new file mode 100644
index 00000000..30bd8cbe
--- /dev/null
+++ b/src/FishyFlip/Models/Internal/CreateMessage.cs
@@ -0,0 +1,12 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models.Internal;
+
+///
+/// Create Message.
+///
+/// Conversation Id.
+/// Message.
+internal record CreateMessage(string ConvoId, CreateMessageMessage Message);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/Internal/CreateMessageMessage.cs b/src/FishyFlip/Models/Internal/CreateMessageMessage.cs
new file mode 100644
index 00000000..0af094ce
--- /dev/null
+++ b/src/FishyFlip/Models/Internal/CreateMessageMessage.cs
@@ -0,0 +1,11 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models.Internal;
+
+///
+/// Create Message, with the message.
+///
+/// Text of message.
+internal record CreateMessageMessage(string Text);
\ No newline at end of file
diff --git a/src/FishyFlip/Models/MessageView.cs b/src/FishyFlip/Models/MessageView.cs
new file mode 100644
index 00000000..8ab17920
--- /dev/null
+++ b/src/FishyFlip/Models/MessageView.cs
@@ -0,0 +1,57 @@
+//
+// Copyright (c) Drastic Actions. All rights reserved.
+//
+
+namespace FishyFlip.Models;
+
+///
+/// Represents a view of a message in a chat conversation.
+///
+public class MessageView : ATRecord
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The unique identifier of the message.
+ /// The revision of the message.
+ /// The sender of the message.
+ /// The text content of the message.
+ /// The date and time when the message was sent.
+ /// The type of the message. If not provided, defaults to .
+ [JsonConstructor]
+ public MessageView(string id, string rev, ChatSender sender, string text, DateTime sentAt, string? type = default)
+ : base(type)
+ {
+ this.Id = id;
+ this.Rev = rev;
+ this.Sender = sender;
+ this.Text = text;
+ this.SentAt = sentAt;
+ this.Type = type ?? Constants.ConversationTypes.MessageView;
+ }
+
+ ///
+ /// Gets the unique identifier of the message.
+ ///
+ public string Id { get; }
+
+ ///
+ /// Gets the revision of the message.
+ ///
+ public string Rev { get; }
+
+ ///
+ /// Gets the sender of the message.
+ ///
+ public ChatSender Sender { get; }
+
+ ///
+ /// Gets the text content of the message.
+ ///
+ public string Text { get; }
+
+ ///
+ /// Gets the date and time when the message was sent.
+ ///
+ public DateTime SentAt { get; }
+}
\ No newline at end of file
diff --git a/src/FishyFlip/SourceGenerationContext.cs b/src/FishyFlip/SourceGenerationContext.cs
index 83bf45ec..5655e998 100644
--- a/src/FishyFlip/SourceGenerationContext.cs
+++ b/src/FishyFlip/SourceGenerationContext.cs
@@ -176,6 +176,14 @@ namespace FishyFlip;
[JsonSerializable(typeof(ActorPreferences))]
[JsonSerializable(typeof(TagSuggestion))]
[JsonSerializable(typeof(TagSuggestions))]
+[JsonSerializable(typeof(MessageView))]
+[JsonSerializable(typeof(ChatSender))]
+[JsonSerializable(typeof(CreateMessage))]
+[JsonSerializable(typeof(CreateMessageMessage))]
+[JsonSerializable(typeof(ConversationMessages))]
+[JsonSerializable(typeof(Conversation))]
+[JsonSerializable(typeof(ConversationView))]
+[JsonSerializable(typeof(ConversationList))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}
\ No newline at end of file
diff --git a/src/FishyFlip/Tools/HttpClientExtensions.cs b/src/FishyFlip/Tools/HttpClientExtensions.cs
index 0450f016..1f553476 100644
--- a/src/FishyFlip/Tools/HttpClientExtensions.cs
+++ b/src/FishyFlip/Tools/HttpClientExtensions.cs
@@ -24,6 +24,7 @@ internal static class HttpClientExtensions
/// The request body.
/// The cancellation token to cancel operation.
/// The logger to use. This is optional and defaults to null.
+ /// Custom headers to include with the request.
/// The Task that represents the asynchronous operation. The value of the TResult parameter contains the Http response message as the result.
internal static async Task> Post(
this HttpClient client,
@@ -33,11 +34,20 @@ internal static async Task> Post(
JsonSerializerOptions options,
T body,
CancellationToken cancellationToken,
- ILogger? logger = default)
+ ILogger? logger = default,
+ Dictionary? headers = default)
{
var jsonContent = JsonSerializer.Serialize(body, typeT);
StringContent content = new(jsonContent, Encoding.UTF8, "application/json");
- logger?.LogDebug($"POST {url}: {jsonContent}");
+ if (headers != null)
+ {
+ foreach (var header in headers)
+ {
+ content.Headers.Add(header.Key, header.Value);
+ }
+ }
+
+ logger?.LogDebug($"POST {client.BaseAddress}{url}: {jsonContent}");
using var message = await client.PostAsync(url, content, cancellationToken);
if (!message.IsSuccessStatusCode)
{
@@ -55,7 +65,7 @@ internal static async Task> Post(
response = "{ }";
}
- logger?.LogDebug($"POST {url}: {response}");
+ logger?.LogDebug($"POST {client.BaseAddress}{url}: {response}");
TK? result = JsonSerializer.Deserialize(response, typeTK);
return result!;
}
@@ -81,7 +91,7 @@ internal static async Task> Post(
CancellationToken cancellationToken,
ILogger? logger = default)
{
- logger?.LogDebug($"POST STREAM {url}: {body.Headers.ContentType}");
+ logger?.LogDebug($"POST STREAM {client.BaseAddress}{url}: {body.Headers.ContentType}");
using var message = await client.PostAsync(url, body, cancellationToken);
if (!message.IsSuccessStatusCode)
{
@@ -99,7 +109,7 @@ internal static async Task> Post(
response = "{ }";
}
- logger?.LogDebug($"POST {url}: {response}");
+ logger?.LogDebug($"POST {client.BaseAddress}{url}: {response}");
TK? result = JsonSerializer.Deserialize(response, type);
return result!;
}
@@ -123,7 +133,7 @@ internal static async Task> Post(
CancellationToken cancellationToken,
ILogger? logger = default)
{
- logger?.LogDebug($"POST {url}");
+ logger?.LogDebug($"POST {client.BaseAddress}{url}");
using var message = await client.PostAsync(url, null, cancellationToken: cancellationToken);
if (!message.IsSuccessStatusCode)
{
@@ -141,7 +151,7 @@ internal static async Task> Post(
response = "{ }";
}
- logger?.LogDebug($"POST {url}: {response}");
+ logger?.LogDebug($"POST {client.BaseAddress}{url}: {response}");
TK? result = JsonSerializer.Deserialize(response, type);
return result!;
}
@@ -162,7 +172,7 @@ internal static async Task> Post(
CancellationToken cancellationToken,
ILogger? logger = default)
{
- logger?.LogDebug($"GET {url}");
+ logger?.LogDebug($"GET {client.BaseAddress}{url}");
using var message = await client.GetAsync(url, cancellationToken);
if (!message.IsSuccessStatusCode)
{
@@ -178,7 +188,7 @@ internal static async Task> Post(
string response = await message.Content.ReadAsStringAsync(cancellationToken);
#endif
- logger?.LogDebug($"GET BLOB {url}: {response}");
+ logger?.LogDebug($"GET BLOB {client.BaseAddress}{url}: {response}");
return new Blob(blob);
}
@@ -200,7 +210,7 @@ internal static async Task> Post(
ILogger? logger = default,
OnCarDecoded? progress = null)
{
- logger?.LogDebug($"GET {url}");
+ logger?.LogDebug($"GET {client.BaseAddress}{url}");
using var message = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!message.IsSuccessStatusCode)
{
@@ -237,7 +247,7 @@ internal static async Task> Post(
CancellationToken cancellationToken,
ILogger? logger = default)
{
- logger?.LogDebug($"GET {url}");
+ logger?.LogDebug($"GET {client.BaseAddress}{url}");
using var message = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
if (!message.IsSuccessStatusCode)
@@ -272,6 +282,7 @@ internal static async Task> Post(
/// The JsonSerializerOptions for the request.
/// The cancellation token to cancel operation.
/// The logger to use. This is optional and defaults to null.
+ /// Custom headers to include with the request.
/// The Task that represents the asynchronous operation. The value of the TResult parameter contains the Http response message as the result.
internal static async Task> Get(
this HttpClient client,
@@ -279,10 +290,21 @@ internal static async Task> Post(
JsonTypeInfo type,
JsonSerializerOptions options,
CancellationToken cancellationToken,
- ILogger? logger = default)
+ ILogger? logger = default,
+ Dictionary? headers = default)
{
- logger?.LogDebug($"GET {url}");
- using var message = await client.GetAsync(url, cancellationToken);
+ logger?.LogDebug($"GET {client.BaseAddress}{url}");
+ var request = new HttpRequestMessage(HttpMethod.Get, url);
+ if (headers != null)
+ {
+ foreach (var header in headers)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+ }
+
+ using var message = await client.SendAsync(request, cancellationToken);
+
if (!message.IsSuccessStatusCode)
{
ATError atError = await CreateError(message!, options, cancellationToken, logger);
@@ -299,7 +321,7 @@ internal static async Task> Post(
response = "{ }";
}
- logger?.LogDebug($"GET {url}: {response}");
+ logger?.LogDebug($"GET {client.BaseAddress}{url}: {response}");
return JsonSerializer.Deserialize(response, type);
}