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); }