From 5e45b0a317474c89acaa998e5569c93de21e2399 Mon Sep 17 00:00:00 2001 From: Quin Lynch Date: Mon, 18 Apr 2022 04:50:35 -0300 Subject: [PATCH 1/2] initial implementation --- src/Discord.Net.Core/DiscordConfig.cs | 49 ++- .../Entities/Channels/IMessageChannel.cs | 10 + .../MessageComponents/ComponentBuilder.cs | 2 +- .../Entities/Messages/Message.cs | 77 +++++ .../Entities/Messages/MessageBuilder.cs | 315 ++++++++++++++++++ .../Extensions/MessageExtensions.cs | 24 +- .../Extensions/UserExtensions.cs | 15 + .../Entities/Channels/ChannelHelper.cs | 14 + .../Entities/Channels/IRestMessageChannel.cs | 5 +- .../Entities/Channels/RestDMChannel.cs | 8 + .../Entities/Channels/RestGroupChannel.cs | 10 +- .../Entities/Channels/RestTextChannel.cs | 8 + .../Interactions/InteractionHelper.cs | 4 +- .../Entities/Channels/SocketDMChannel.cs | 8 + .../Entities/Channels/SocketGroupChannel.cs | 8 + .../Entities/Channels/SocketTextChannel.cs | 9 +- .../MockedEntities/MockedDMChannel.cs | 1 + .../MockedEntities/MockedGroupChannel.cs | 1 + .../MockedEntities/MockedTextChannel.cs | 1 + 19 files changed, 544 insertions(+), 25 deletions(-) create mode 100644 src/Discord.Net.Core/Entities/Messages/Message.cs create mode 100644 src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs diff --git a/src/Discord.Net.Core/DiscordConfig.cs b/src/Discord.Net.Core/DiscordConfig.cs index 067c552255..4e9ac1f8ad 100644 --- a/src/Discord.Net.Core/DiscordConfig.cs +++ b/src/Discord.Net.Core/DiscordConfig.cs @@ -132,6 +132,21 @@ public class DiscordConfig /// public const int MaxAuditLogEntriesPerBatch = 100; + /// + /// Returns the max number of stickers that can be sent on a message. + /// + public const int MaxStickersPerMessage = 3; + + /// + /// Returns the max numbe of files that can be sent on a message. + /// + public const int MaxFilesPerMessage = 10; + + /// + /// Returns the max number of embeds that can be sent on a message. + /// + public const int MaxEmbedsPerMessage = 10; + /// /// Gets or sets how a request should act in the case of an error, by default. /// @@ -165,23 +180,23 @@ public class DiscordConfig /// internal bool DisplayInitialLog { get; set; } = true; - /// - /// Gets or sets whether or not rate-limits should use the system clock. - /// - /// - /// If set to false, we will use the X-RateLimit-Reset-After header - /// to determine when a rate-limit expires, rather than comparing the - /// X-RateLimit-Reset timestamp to the system time. - /// - /// This should only be changed to false if the system is known to have - /// a clock that is out of sync. Relying on the Reset-After header will - /// incur network lag. - /// - /// Regardless of this property, we still rely on the system's wall-clock - /// to determine if a bucket is rate-limited; we do not use any monotonic - /// clock. Your system will still need a stable clock. - /// - public bool UseSystemClock { get; set; } = true; + /// + /// Gets or sets whether or not rate-limits should use the system clock. + /// + /// + /// If set to false, we will use the X-RateLimit-Reset-After header + /// to determine when a rate-limit expires, rather than comparing the + /// X-RateLimit-Reset timestamp to the system time. + /// + /// This should only be changed to false if the system is known to have + /// a clock that is out of sync. Relying on the Reset-After header will + /// incur network lag. + /// + /// Regardless of this property, we still rely on the system's wall-clock + /// to determine if a bucket is rate-limited; we do not use any monotonic + /// clock. Your system will still need a stable clock. + /// + public bool UseSystemClock { get; set; } = true; /// /// Gets or sets whether or not the internal experation check uses the system date diff --git a/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs index 60a7c75757..ed1fb264d6 100644 --- a/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs +++ b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs @@ -38,6 +38,16 @@ public interface IMessageChannel : IChannel /// Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); /// + /// Sends a message to this message channel. + /// + /// The created from a . + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendMessageAsync(Message message, RequestOptions options = null); + /// /// Sends a file to this message channel with an optional caption. /// /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs index 7becca0e07..e0597f6f39 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs @@ -92,7 +92,7 @@ internal void AddComponent(IMessageComponent component, int row) /// The max values of the placeholder. /// Whether or not the menu is disabled. /// The row to add the menu to. - /// + /// The current builder. public ComponentBuilder WithSelectMenu(string customId, List options, string placeholder = null, int minValues = 1, int maxValues = 1, bool disabled = false, int row = 0) { diff --git a/src/Discord.Net.Core/Entities/Messages/Message.cs b/src/Discord.Net.Core/Entities/Messages/Message.cs new file mode 100644 index 0000000000..6341c54bef --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Message.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a message created by a that can be sent to a channel. + /// + public sealed class Message + { + /// + /// Gets the content of the message. + /// + public string Content { get; internal set; } + + /// + /// Gets whether or not this message should be read by a text-to-speech engine. + /// + public bool IsTTS { get; internal set; } + + /// + /// Gets a collection of embeds sent along with this message. + /// + public IReadOnlyCollection Embeds { get; internal set; } + + /// + /// Gets the allowed mentions for this message. + /// + public AllowedMentions AllowedMentions { get; internal set; } + + /// + /// Gets the message reference (reply to) for this message. + /// + public MessageReference MessageReference { get; internal set; } + + /// + /// Gets the components of this message. + /// + public MessageComponent Components { get; internal set; } + + /// + /// Gets a collection of sticker ids that will be sent with this message. + /// + public IReadOnlyCollection Stickers { get; internal set; } + + /// + /// Gets a collection of files sent with this message. + /// + public IReadOnlyCollection Attachments { get; internal set; } + + /// + /// Gets the message flags for this message. + /// + public MessageFlags Flags { get; internal set; } + + public bool HasFiles + => Attachments?.Any() ?? false; + + internal Message(string content, bool istts, IReadOnlyCollection embeds, AllowedMentions allowedMentions, + MessageReference messagereference, MessageComponent components, IReadOnlyCollection stickers, + IReadOnlyCollection attachments, MessageFlags flags) + { + Content = content; + IsTTS = istts; + Embeds = embeds; + AllowedMentions = allowedMentions; + MessageReference = messagereference; + Components = components; + Stickers = stickers; + Attachments = attachments; + Flags = flags; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs b/src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs new file mode 100644 index 0000000000..c03f380294 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic message builder that can build s. + /// + public class MessageBuilder + { + private string _content; + private List _stickers = new(); + private List _embeds = new(); + private List _files = new(); + /// + /// Gets or sets the content of this message + /// + /// The content is bigger than the . + public string Content + { + get => _content; + set + { + if (_content?.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentOutOfRangeException(nameof(value), $"Message size must be less than or equal to {DiscordConfig.MaxMessageSize} characters"); + + _content = value; + } + } + + /// + /// Gets or sets whether or not this message is TTS. + /// + public bool IsTTS { get; set; } + + /// + /// Gets or sets the embeds of this message. + /// + public List Embeds + { + get => _embeds; + set + { + if (value?.Count > DiscordConfig.MaxEmbedsPerMessage) + throw new ArgumentOutOfRangeException(nameof(value), $"Embed count must be less than or equal to {DiscordConfig.MaxEmbedsPerMessage}"); + + _embeds = value; + } + } + + /// + /// Gets or sets the allowed mentions of this message. + /// + public AllowedMentions AllowedMentions { get; set; } + + /// + /// Gets or sets the message reference (reply to) of this message. + /// + public MessageReference MessageReference { get; set; } + + /// + /// Gets or sets the components of this message. + /// + public ComponentBuilder Components { get; set; } = new(); + + /// + /// Gets or sets the stickers sent with this message. + /// + public List Stickers + { + get => _stickers; + set + { + if (value?.Count > DiscordConfig.MaxStickersPerMessage) + throw new ArgumentOutOfRangeException(nameof(value), $"Sticker count must be less than or equal to {DiscordConfig.MaxStickersPerMessage}"); + + _stickers = value; + } + } + + /// + /// Gets or sets the files sent with this message. + /// + public List Files + { + get => _files; + set + { + if(value?.Count > DiscordConfig.MaxFilesPerMessage) + throw new ArgumentOutOfRangeException(nameof(value), $"File count must be less than or equal to {DiscordConfig.MaxFilesPerMessage}"); + } + } + + /// + /// Gets or sets the message flags. + /// + public MessageFlags Flags { get; set; } + + /// + /// Sets the of this message. + /// + /// The content of the message. + /// The current builder. + public MessageBuilder WithContent(string content) + { + Content = content; + return this; + } + + /// + /// Sets the of this message. + /// + /// whether or not this message is tts. + /// The current builder. + public MessageBuilder WithTTS(bool isTTS) + { + IsTTS = isTTS; + return this; + } + + /// + /// Sets the of this message. + /// + /// The embeds to be put in this message. + /// The current builder. + /// A message can only contain a maximum of embeds. + public MessageBuilder WithEmbeds(params EmbedBuilder[] embeds) + { + Embeds = new(embeds); + return this; + } + + /// + /// Adds an embed builder to the current . + /// + /// The embed builder to add + /// The current builder. + /// A message can only contain a maximum of embeds. + public MessageBuilder AddEmbed(EmbedBuilder embed) + { + if (_embeds?.Count >= DiscordConfig.MaxEmbedsPerMessage) + throw new ArgumentOutOfRangeException(nameof(embed.Length), $"A message can only contain a maximum of {DiscordConfig.MaxEmbedsPerMessage} embeds"); + + _embeds ??= new(); + + _embeds.Add(embed); + + return this; + } + + /// + /// Sets the for this message. + /// + /// The allowed mentions for this message. + /// The current builder. + public MessageBuilder WithAllowedMentions(AllowedMentions allowedMentions) + { + AllowedMentions = allowedMentions; + return this; + } + + /// + /// Sets the for this message. + /// + /// The message reference (reply-to) for this message. + /// The current builder. + public MessageBuilder WithMessageReference(MessageReference reference) + { + MessageReference = reference; + return this; + } + + /// + /// Sets the for this current message. + /// + /// The message to set as a reference. + /// The current builder. + public MessageBuilder WithMessageReference(IMessage message) + { + if (message != null) + MessageReference = new MessageReference(message.Id, message.Channel?.Id, ((IGuildChannel)message.Channel)?.GuildId); + return this; + } + + /// + /// Sets the for this message. + /// + /// The component builder to set. + /// The current builder. + public MessageBuilder WithComponentBuilder(ComponentBuilder builder) + { + Components = builder; + return this; + } + + /// + /// Adds a button to the current . + /// + /// The button builder to add. + /// The optional row to place the button on. + /// The current builder. + public MessageBuilder WithButton(ButtonBuilder button, int row = 0) + { + Components ??= new(); + Components.WithButton(button, row); + return this; + } + + /// + /// Adds a button to the current . + /// + /// The label text for the newly added button. + /// The style of this newly added button. + /// A to be used with this button. + /// The custom id of the newly added button. + /// A URL to be used only if the is a Link. + /// Whether or not the newly created button is disabled. + /// The row the button should be placed on. + /// The current builder. + public MessageBuilder WithButton( + string label = null, + string customId = null, + ButtonStyle style = ButtonStyle.Primary, + IEmote emote = null, + string url = null, + bool disabled = false, + int row = 0) + { + Components ??= new(); + Components.WithButton(label, customId, style, emote, url, disabled, row); + return this; + } + + /// + /// Adds a select menu to the current . + /// + /// The select menu builder to add. + /// The optional row to place the select menu on. + /// The current builder. + public MessageBuilder WithSelectMenu(SelectMenuBuilder menu, int row = 0) + { + Components ??= new(); + Components.WithSelectMenu(menu, row); + return this; + } + + /// + /// Adds a select menu to the current . + /// + /// The custom id of the menu. + /// The options of the menu. + /// The placeholder of the menu. + /// The min values of the placeholder. + /// The max values of the placeholder. + /// Whether or not the menu is disabled. + /// The row to add the menu to. + /// The current builder. + public MessageBuilder WithSelectMenu(string customId, List options, + string placeholder = null, int minValues = 1, int maxValues = 1, bool disabled = false, int row = 0) + { + Components ??= new(); + Components.WithSelectMenu(customId, options, placeholder, minValues, maxValues, disabled, row); + return this; + } + + /// + /// Sets the of this message. + /// + /// The file collection to set. + /// The current builder. + public MessageBuilder WithFiles(IEnumerable files) + { + Files = new List(files); + return this; + } + + /// + /// Adds a file to the current collection . + /// + /// The file to add. + /// The current builder. + public MessageBuilder AddFile(FileAttachment file) + { + Files.Add(file); + return this; + } + + /// + /// Builds this message builder to a that can be sent across the discord api. + /// + /// A that can be sent to the discord api. + public Message Build() + { + var embeds = _embeds != null && _embeds.Count > 0 + ? _embeds.Select(x => x.Build()).ToImmutableArray() + : ImmutableArray.Empty; + + return new Message( + _content, + IsTTS, + embeds, + AllowedMentions, + MessageReference, + Components?.Build(), + _stickers, + Files?.ToImmutableArray() ?? ImmutableArray.Empty, + Flags + ); + } + } +} diff --git a/src/Discord.Net.Core/Extensions/MessageExtensions.cs b/src/Discord.Net.Core/Extensions/MessageExtensions.cs index cf330c44d1..9441f280de 100644 --- a/src/Discord.Net.Core/Extensions/MessageExtensions.cs +++ b/src/Discord.Net.Core/Extensions/MessageExtensions.cs @@ -93,9 +93,29 @@ public static async Task RemoveReactionsAsync(this IUserMessage msg, IUser user, /// A task that represents an asynchronous send operation for delivering the message. The task result /// contains the sent message. /// - public static async Task ReplyAsync(this IUserMessage msg, string text = null, bool isTTS = false, Embed embed = null, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null) + public static Task ReplyAsync(this IUserMessage msg, string text = null, bool isTTS = false, Embed embed = null, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null) { - return await msg.Channel.SendMessageAsync(text, isTTS, embed, options, allowedMentions, new MessageReference(messageId: msg.Id), components, stickers, embeds).ConfigureAwait(false); + return msg.Channel.SendMessageAsync(text, isTTS, embed, options, allowedMentions, new MessageReference(messageId: msg.Id), components, stickers, embeds); + } + + /// + /// Sends an inline reply that references a message. + /// + /// + /// Any feild set with the will be + /// overwritten by this function. + /// + /// The message that is being replied on. + /// The created from a . + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static Task ReplyAsync(this IUserMessage msg, Message message, RequestOptions options = null) + { + message.MessageReference = new MessageReference(messageId: msg.Id); + return msg.Channel.SendMessageAsync(message, options); } } } diff --git a/src/Discord.Net.Core/Extensions/UserExtensions.cs b/src/Discord.Net.Core/Extensions/UserExtensions.cs index ce914170d1..f20f35ba42 100644 --- a/src/Discord.Net.Core/Extensions/UserExtensions.cs +++ b/src/Discord.Net.Core/Extensions/UserExtensions.cs @@ -49,6 +49,21 @@ public static async Task SendMessageAsync(this IUser user, return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendMessageAsync(text, isTTS, embed, options, allowedMentions, components: components, embeds: embeds).ConfigureAwait(false); } + /// + /// Sends a message via DM. + /// + /// The user to send the DM to. + /// The created from a . + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static async Task SendMessageAsync(this IUser user, Message message, RequestOptions options = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendMessageAsync(message, options).ConfigureAwait(false); + } + /// /// Sends a file to this message channel with an optional caption. /// diff --git a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs index d66fd5e51a..3899d82aa7 100644 --- a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs +++ b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs @@ -265,6 +265,20 @@ public static async Task> GetPinnedMessagesAsyn return builder.ToImmutable(); } + public static Task SendMessageAsync(IMessageChannel channel, BaseDiscordClient client, Message message, RequestOptions options = null) + { + Preconditions.NotNull(message, nameof(message)); + + if (message.HasFiles) + { + return SendFilesAsync(channel, client, message.Attachments, message.Content, message.IsTTS, null, message.AllowedMentions, + message.MessageReference, message.Components, message.Stickers?.ToArray(), options, message.Embeds?.ToArray(), message.Flags); + } + else + return SendMessageAsync(channel, client, message.Content, message.IsTTS, null, message.AllowedMentions, message.MessageReference, + message.Components, message.Stickers?.ToArray(), options, message.Embeds?.ToArray(), message.Flags); + } + /// Message content is too long, length must be less or equal to . /// The only valid are and . public static async Task SendMessageAsync(IMessageChannel channel, BaseDiscordClient client, diff --git a/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs index 0cf92bb04d..e68937e5aa 100644 --- a/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs @@ -11,7 +11,10 @@ public interface IRestMessageChannel : IMessageChannel { /// new Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); - + + /// + new Task SendMessageAsync(Message message, RequestOptions options = null); + /// new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); diff --git a/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs index 3bf43a594d..7000645b49 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs @@ -92,6 +92,10 @@ public IAsyncEnumerable> GetMessagesAsync(IMess public Task> GetPinnedMessagesAsync(RequestOptions options = null) => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -271,6 +275,10 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); #endregion diff --git a/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs index d21852f93f..139c7d06fa 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs @@ -102,6 +102,10 @@ public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) public async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -255,7 +259,11 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); - + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); + #endregion #region IAudioChannel diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs index 76c75ab6e0..6966fe1394 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -101,6 +101,10 @@ public IAsyncEnumerable> GetMessagesAsync(IMess public Task> GetPinnedMessagesAsync(RequestOptions options = null) => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -386,6 +390,10 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); #endregion #region IGuildChannel diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs index e345bfa94a..d6a76764ab 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -352,7 +352,7 @@ public static Task DeleteUnknownApplicationCommandAsync(BaseDiscordClient client #endregion #region Responses - public static async Task ModifyFollowupMessageAsync(BaseDiscordClient client, RestFollowupMessage message, Action func, + public static async Task ModifyFollowupMessageAsync(BaseDiscordClient client, RestFollowupMessage message, Action func, RequestOptions options = null) { var args = new MessageProperties(); @@ -394,7 +394,7 @@ public static async Task ModifyFollowupMessageAsync(BaseDiscordClient c } public static async Task DeleteFollowupMessageAsync(BaseDiscordClient client, RestFollowupMessage message, RequestOptions options = null) => await client.ApiClient.DeleteInteractionFollowupMessageAsync(message.Id, message.Token, options); - public static async Task ModifyInteractionResponseAsync(BaseDiscordClient client, string token, Action func, + public static async Task ModifyInteractionResponseAsync(BaseDiscordClient client, string token, Action func, RequestOptions options = null) { var args = new MessageProperties(); diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs index 17ab4ebe39..1f0f417c84 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs @@ -137,6 +137,10 @@ public IReadOnlyCollection GetCachedMessages(IMessage fromMessage public Task> GetPinnedMessagesAsync(RequestOptions options = null) => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -310,6 +314,10 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); #endregion #region IChannel diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs index 4f068cf810..bf85fb27fb 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs @@ -176,6 +176,10 @@ public IReadOnlyCollection GetCachedMessages(IMessage fromMessage public Task> GetPinnedMessagesAsync(RequestOptions options = null) => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -382,6 +386,10 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); #endregion #region IAudioChannel diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs index e4a299edc0..f13651ef81 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -210,6 +210,10 @@ public IReadOnlyCollection GetCachedMessages(IMessage fromMessage public Task> GetPinnedMessagesAsync(RequestOptions options = null) => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + /// + public Task SendMessageAsync(Message message, RequestOptions options = null) + => ChannelHelper.SendMessageAsync(this, Discord, message, options); + /// /// Message content is too long, length must be less or equal to . /// The only valid are and . @@ -448,7 +452,10 @@ async Task IMessageChannel.SendMessageAsync(string text, bool isTT AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); - + + /// + async Task IMessageChannel.SendMessageAsync(Message message, RequestOptions options) + => await SendMessageAsync(message, options).ConfigureAwait(false); #endregion #region INestedChannel diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs index 2a7f8065a7..9ff0178dbd 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedDMChannel.cs @@ -88,5 +88,6 @@ public Task TriggerTypingAsync(RequestOptions options = null) public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); + public Task SendMessageAsync(Message message, RequestOptions options = null) => throw new NotImplementedException(); } } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs index b7f98f572a..49663f071e 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedGroupChannel.cs @@ -115,5 +115,6 @@ public Task TriggerTypingAsync(RequestOptions options = null) public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent component = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); + public Task SendMessageAsync(Message message, RequestOptions options = null) => throw new NotImplementedException(); } } diff --git a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs index 0dfcab7a5f..969af82451 100644 --- a/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs +++ b/test/Discord.Net.Tests.Unit/MockedEntities/MockedTextChannel.cs @@ -216,5 +216,6 @@ IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mo public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null, MessageFlags flags = MessageFlags.None) => throw new NotImplementedException(); public Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) => throw new NotImplementedException(); public Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) => throw new NotImplementedException(); + public Task SendMessageAsync(Message message, RequestOptions options = null) => throw new NotImplementedException(); } } From c297cd9d4a4a70ca07123f3b79fb774d7943eb35 Mon Sep 17 00:00:00 2001 From: Armano den Boef <68127614+Rozen4334@users.noreply.github.com> Date: Thu, 2 Jun 2022 15:16:12 +0200 Subject: [PATCH 2/2] Introduce message text formatting (#2324) --- .../Messages/Builders/CodeLanguage.cs | 101 ++++ .../Messages/Builders/HeaderFormat.cs | 64 +++ .../Messages/{ => Builders}/MessageBuilder.cs | 104 ++-- .../Messages/Builders/MultiLineBuilder.cs | 125 +++++ .../Entities/Messages/Builders/TextBuilder.cs | 493 ++++++++++++++++++ .../Extensions/MarkdownExtensions.cs | 101 ++++ src/Discord.Net.Core/Format.cs | 42 +- 7 files changed, 973 insertions(+), 57 deletions(-) create mode 100644 src/Discord.Net.Core/Entities/Messages/Builders/CodeLanguage.cs create mode 100644 src/Discord.Net.Core/Entities/Messages/Builders/HeaderFormat.cs rename src/Discord.Net.Core/Entities/Messages/{ => Builders}/MessageBuilder.cs (84%) create mode 100644 src/Discord.Net.Core/Entities/Messages/Builders/MultiLineBuilder.cs create mode 100644 src/Discord.Net.Core/Entities/Messages/Builders/TextBuilder.cs create mode 100644 src/Discord.Net.Core/Extensions/MarkdownExtensions.cs diff --git a/src/Discord.Net.Core/Entities/Messages/Builders/CodeLanguage.cs b/src/Discord.Net.Core/Entities/Messages/Builders/CodeLanguage.cs new file mode 100644 index 0000000000..7cfddbde6f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Builders/CodeLanguage.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a language in which codeblocks can be formatted. + /// + public struct CodeLanguage + { + /// + /// Gets the tag of the language. + /// + public string Tag { get; } + + /// + /// Gets the name of the language. if this was constructed with no name provided. + /// + public string Name { get; } = string.Empty; + + /// + /// Gets the CSharp language format. + /// + public static readonly CodeLanguage CSharp = new("cs", "csharp"); + + /// + /// Gets the Javascript language format. + /// + public static readonly CodeLanguage JavaScript = new("js", "javascript"); + + /// + /// Gets the XML language format. + /// + public static readonly CodeLanguage XML = new("xml", "xml"); + + /// + /// Gets the HTML language format. + /// + public static readonly CodeLanguage HTML = new("html", "html"); + + /// + /// Gets the CSS markdown format. + /// + public static readonly CodeLanguage CSS = new("css", "css"); + + /// + /// Gets a language format that represents none. + /// + public static readonly CodeLanguage None = new("", "none"); + + /// + /// Creates a new language format with name & tag. + /// + /// The tag with which markdown will be formatted. + /// The name of the language. + public CodeLanguage(string tag, string name) + { + Tag = tag; + Name = name; + } + + /// + /// Creates a new language format with a tag. + /// + /// The tag with which markdown will be formatted. + public CodeLanguage(string tag) + => Tag = tag; + + /// + /// Gets the tag of the language. + /// + /// + public static implicit operator string(CodeLanguage language) + => language.Tag; + + /// + /// Gets a language based on the tag. + /// + /// + public static implicit operator CodeLanguage(string tag) + => new(tag); + + /// + /// Creates markdown format for this language. + /// + /// The input string to format. + /// A markdown formatted code-block with this language. + public string ToMarkdown(string input) + => $"```{Tag}\n{input}\n```"; + + /// + /// Gets the tag of the language. + /// + /// + public override string ToString() + => $"{Tag}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/Builders/HeaderFormat.cs b/src/Discord.Net.Core/Entities/Messages/Builders/HeaderFormat.cs new file mode 100644 index 0000000000..029110a1b1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Builders/HeaderFormat.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents the format in which a markdown header should be presented. + /// + public readonly struct HeaderFormat + { + public string Format { get; } + + /// + /// The biggest header type. + /// + public static readonly HeaderFormat H1 = new("#"); + + /// + /// An above-average sized header. + /// + public static readonly HeaderFormat H2 = new("##"); + + /// + /// An average-sized header. + /// + public static readonly HeaderFormat H3 = new("###"); + + /// + /// A subheader. + /// + public static readonly HeaderFormat H4 = new("####"); + + /// + /// A smaller subheader. + /// + public static readonly HeaderFormat H5 = new("#####"); + + /// + /// Slightly bigger than regular bold markdown. + /// + public static readonly HeaderFormat H6 = new("######"); + + private HeaderFormat(string format) + => Format = format; + + /// + /// Formats this header into markdown, appending provided string. + /// + /// The string to turn into a header. + /// A markdown formatted header title. + public string ToMarkdown(string input) + => $"{Format} {input}"; + + /// + /// Gets the markdown format for this header. + /// + /// The markdown format for this header. + public override string ToString() + => $"{Format}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs b/src/Discord.Net.Core/Entities/Messages/Builders/MessageBuilder.cs similarity index 84% rename from src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs rename to src/Discord.Net.Core/Entities/Messages/Builders/MessageBuilder.cs index c03f380294..be740fe268 100644 --- a/src/Discord.Net.Core/Entities/Messages/MessageBuilder.cs +++ b/src/Discord.Net.Core/Entities/Messages/Builders/MessageBuilder.cs @@ -8,35 +8,40 @@ namespace Discord { /// - /// Represents a generic message builder that can build s. + /// Represents a generic message builder that can build 's. /// public class MessageBuilder { - private string _content; - private List _stickers = new(); - private List _embeds = new(); - private List _files = new(); + private readonly List _files; + + private List _stickers; + private List _embeds; + /// /// Gets or sets the content of this message /// - /// The content is bigger than the . - public string Content - { - get => _content; - set - { - if (_content?.Length > DiscordConfig.MaxMessageSize) - throw new ArgumentOutOfRangeException(nameof(value), $"Message size must be less than or equal to {DiscordConfig.MaxMessageSize} characters"); - - _content = value; - } - } + public TextBuilder Content { get; set; } /// /// Gets or sets whether or not this message is TTS. /// public bool IsTTS { get; set; } + /// + /// Gets or sets the allowed mentions of this message. + /// + public AllowedMentions AllowedMentions { get; set; } + + /// + /// Gets or sets the message reference (reply to) of this message. + /// + public MessageReference MessageReference { get; set; } + + /// + /// Gets or sets the components of this message. + /// + public ComponentBuilder Components { get; set; } + /// /// Gets or sets the embeds of this message. /// @@ -52,21 +57,6 @@ public List Embeds } } - /// - /// Gets or sets the allowed mentions of this message. - /// - public AllowedMentions AllowedMentions { get; set; } - - /// - /// Gets or sets the message reference (reply to) of this message. - /// - public MessageReference MessageReference { get; set; } - - /// - /// Gets or sets the components of this message. - /// - public ComponentBuilder Components { get; set; } = new(); - /// /// Gets or sets the stickers sent with this message. /// @@ -100,14 +90,32 @@ public List Files /// public MessageFlags Flags { get; set; } + /// + /// Creates a new based on the value of . + /// + /// The message content to create this from. + public MessageBuilder(string content) + { + Content = new TextBuilder(content); + } + + public MessageBuilder() + { + _embeds = new(); + _stickers = new(); + _files = new(); + + Components = new(); + } + /// /// Sets the of this message. /// /// The content of the message. /// The current builder. - public MessageBuilder WithContent(string content) + public virtual MessageBuilder WithContent(TextBuilder builder) { - Content = content; + Content = builder; return this; } @@ -116,7 +124,7 @@ public MessageBuilder WithContent(string content) /// /// whether or not this message is tts. /// The current builder. - public MessageBuilder WithTTS(bool isTTS) + public virtual MessageBuilder WithTTS(bool isTTS) { IsTTS = isTTS; return this; @@ -128,7 +136,7 @@ public MessageBuilder WithTTS(bool isTTS) /// The embeds to be put in this message. /// The current builder. /// A message can only contain a maximum of embeds. - public MessageBuilder WithEmbeds(params EmbedBuilder[] embeds) + public virtual MessageBuilder WithEmbeds(params EmbedBuilder[] embeds) { Embeds = new(embeds); return this; @@ -140,7 +148,7 @@ public MessageBuilder WithEmbeds(params EmbedBuilder[] embeds) /// The embed builder to add /// The current builder. /// A message can only contain a maximum of embeds. - public MessageBuilder AddEmbed(EmbedBuilder embed) + public virtual MessageBuilder AddEmbed(EmbedBuilder embed) { if (_embeds?.Count >= DiscordConfig.MaxEmbedsPerMessage) throw new ArgumentOutOfRangeException(nameof(embed.Length), $"A message can only contain a maximum of {DiscordConfig.MaxEmbedsPerMessage} embeds"); @@ -157,7 +165,7 @@ public MessageBuilder AddEmbed(EmbedBuilder embed) /// /// The allowed mentions for this message. /// The current builder. - public MessageBuilder WithAllowedMentions(AllowedMentions allowedMentions) + public virtual MessageBuilder WithAllowedMentions(AllowedMentions allowedMentions) { AllowedMentions = allowedMentions; return this; @@ -168,7 +176,7 @@ public MessageBuilder WithAllowedMentions(AllowedMentions allowedMentions) /// /// The message reference (reply-to) for this message. /// The current builder. - public MessageBuilder WithMessageReference(MessageReference reference) + public virtual MessageBuilder WithMessageReference(MessageReference reference) { MessageReference = reference; return this; @@ -179,7 +187,7 @@ public MessageBuilder WithMessageReference(MessageReference reference) /// /// The message to set as a reference. /// The current builder. - public MessageBuilder WithMessageReference(IMessage message) + public virtual MessageBuilder WithMessageReference(IMessage message) { if (message != null) MessageReference = new MessageReference(message.Id, message.Channel?.Id, ((IGuildChannel)message.Channel)?.GuildId); @@ -191,7 +199,7 @@ public MessageBuilder WithMessageReference(IMessage message) /// /// The component builder to set. /// The current builder. - public MessageBuilder WithComponentBuilder(ComponentBuilder builder) + public virtual MessageBuilder WithComponentBuilder(ComponentBuilder builder) { Components = builder; return this; @@ -203,7 +211,7 @@ public MessageBuilder WithComponentBuilder(ComponentBuilder builder) /// The button builder to add. /// The optional row to place the button on. /// The current builder. - public MessageBuilder WithButton(ButtonBuilder button, int row = 0) + public virtual MessageBuilder WithButton(ButtonBuilder button, int row = 0) { Components ??= new(); Components.WithButton(button, row); @@ -221,7 +229,7 @@ public MessageBuilder WithButton(ButtonBuilder button, int row = 0) /// Whether or not the newly created button is disabled. /// The row the button should be placed on. /// The current builder. - public MessageBuilder WithButton( + public virtual MessageBuilder WithButton( string label = null, string customId = null, ButtonStyle style = ButtonStyle.Primary, @@ -241,7 +249,7 @@ public MessageBuilder WithButton( /// The select menu builder to add. /// The optional row to place the select menu on. /// The current builder. - public MessageBuilder WithSelectMenu(SelectMenuBuilder menu, int row = 0) + public virtual MessageBuilder WithSelectMenu(SelectMenuBuilder menu, int row = 0) { Components ??= new(); Components.WithSelectMenu(menu, row); @@ -259,7 +267,7 @@ public MessageBuilder WithSelectMenu(SelectMenuBuilder menu, int row = 0) /// Whether or not the menu is disabled. /// The row to add the menu to. /// The current builder. - public MessageBuilder WithSelectMenu(string customId, List options, + public virtual MessageBuilder WithSelectMenu(string customId, List options, string placeholder = null, int minValues = 1, int maxValues = 1, bool disabled = false, int row = 0) { Components ??= new(); @@ -272,7 +280,7 @@ public MessageBuilder WithSelectMenu(string customId, List /// The file collection to set. /// The current builder. - public MessageBuilder WithFiles(IEnumerable files) + public virtual MessageBuilder WithFiles(IEnumerable files) { Files = new List(files); return this; @@ -283,7 +291,7 @@ public MessageBuilder WithFiles(IEnumerable files) /// /// The file to add. /// The current builder. - public MessageBuilder AddFile(FileAttachment file) + public virtual MessageBuilder AddFile(FileAttachment file) { Files.Add(file); return this; @@ -300,7 +308,7 @@ public Message Build() : ImmutableArray.Empty; return new Message( - _content, + Content.Build(), IsTTS, embeds, AllowedMentions, diff --git a/src/Discord.Net.Core/Entities/Messages/Builders/MultiLineBuilder.cs b/src/Discord.Net.Core/Entities/Messages/Builders/MultiLineBuilder.cs new file mode 100644 index 0000000000..a571a94f7b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Builders/MultiLineBuilder.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a builder for multi-line text. + /// + public class MultiLineBuilder + { + /// + /// The underlying list of lines this builder uses to construct multiline text. + /// + public List Lines { get; set; } + + /// + /// Creates a new instance of . + /// + public MultiLineBuilder() + { + Lines = new(); + } + + /// + /// Creates a new instance of with a pre-defined capacity. + /// + /// + public MultiLineBuilder(int capacity) + { + Lines = new(capacity); + } + + /// + /// Creates a new instance of with a number of lines pre-defined. + /// + /// The range of lines to add to this builder. + public MultiLineBuilder(params string[] entries) + { + Lines = new(entries); + } + + /// + /// Adds a line to the builder. + /// + /// The text to add to this line. + /// The same instance with a line appended. + public MultiLineBuilder AddLine(string text) + { + Lines.Add(text); + return this; + } + + /// + /// Adds a range of lines to the builder. + /// + /// The range of text to add. + /// The same instance with a range of lines appended. + public MultiLineBuilder AddLines(IEnumerable text) + { + if (!text.Any()) + throw new ArgumentException("The passed range does not contain any values", nameof(text)); + + Lines.AddRange(text); + return this; + } + + /// + /// Removes a (or more) line(s) from the builder. + /// + /// The predicate to remove lines with. + /// The same instance with all lines matching removed. + public MultiLineBuilder RemoveLine(Predicate predicate) + { + if (predicate is null) + throw new ArgumentNullException(nameof(predicate)); + + Lines.RemoveAll(x => predicate(x)); + return this; + } + + /// + /// Removes a line from the builder. + /// + /// The index to remove a line at. + /// + public MultiLineBuilder RemoveLine(int index) + { + Lines.RemoveAt(index); + return this; + } + + /// + /// Gets the line at a specific index. + /// + /// The index to get a line for. + /// The line at defined . + public string this[int index] + { + get + { + return Lines[index]; + } + } + + /// + /// Builds the builder into multiline text. + /// + /// A string representing the lines added in this builder. + public string Build() + => string.Join(Environment.NewLine, Lines); + + /// + /// Creates a string from the lines currently present in . + /// + /// + /// This method has the same behavior as . + /// + /// A string representing the lines added in this builder. + public override string ToString() + => string.Join(Environment.NewLine, Lines); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/Builders/TextBuilder.cs b/src/Discord.Net.Core/Entities/Messages/Builders/TextBuilder.cs new file mode 100644 index 0000000000..3cff92c6c0 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Builders/TextBuilder.cs @@ -0,0 +1,493 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a builder to build Discord messages with markdown with. + /// + public class TextBuilder + { + private readonly StringBuilder _builder; + + private bool _lineStart = false; + + /// + /// Creates a new instance of . + /// + public TextBuilder() + { + _builder = new(); + } + + /// + /// Creates a new instance of with a starting string appended. + /// + /// The string to start the builder with. + public TextBuilder(string startingString) + { + _builder = new(startingString); + } + + /// + /// Creates a new instance of with a capacity and (optionally) max capacity defined. + /// + /// The init capacity of the underlying . + /// The maximum capacity of the underlying . + public TextBuilder(int capacity, int? maxCapacity = null) + { + if (maxCapacity is not null) + _builder = new(capacity, maxCapacity.Value); + else + _builder = new(capacity); + } + + /// + /// Adds a header to the builder. + /// + /// + /// [Note] Headers are only supported in forums, which are not released publically yet. + /// + /// The text to be present in the header. + /// The header format. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a header appended. This method will append a new line below the header. + public TextBuilder AddHeader(string text, HeaderFormat format, bool skipLine = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + if (skipLine) + _builder.AppendLine(); + _builder.AppendLine(text.ToHeader(format)); + _lineStart = true; + return this; + } + + /// + /// Adds bold text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with bold text appended. + public TextBuilder AddBoldText(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(Format.Bold(text), inline); + return this; + } + + /// + /// Adds bold text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with bold text appended. + public TextBuilder AddBoldText(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddBoldText(text, inline); + } + + /// + /// Adds italic text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with italic appended. + public TextBuilder AddItalicText(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(Discord.Format.Italics(text), inline); + return this; + } + + /// + /// Adds italic text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with italic text appended. + public TextBuilder AddItalicText(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddItalicText(text, inline); + } + + /// + /// Adds plain text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with plain text appended. + public TextBuilder AddPlainText(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(text, inline); + return this; + } + + /// + /// Adds plain text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with plain text appended. + public TextBuilder AddPlainText(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddPlainText(text, inline); + } + + /// + /// Adds underlined text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with underlined text appended. + public TextBuilder AddUnderlinedText(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(text.ToUnderline(), inline); + return this; + } + + /// + /// Adds underlined text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with underlined text appended. + public TextBuilder AddUnderlinedText(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddUnderlinedText(text, inline); + } + + /// + /// Adds a timestamp to the builder. + /// + /// The time for which this timestamp should be created. + /// The style of the stamp. + /// If the stamp should be appended in the same line or if it should append to a new line. + /// The same instance with a timestamp appended. + public TextBuilder AddTimestamp(DateTime dateTime, TimestampTagStyles style, bool inline = true) + { + Construct(TimestampTag.FromDateTime(dateTime, style).ToString(), inline); + return this; + } + + /// + /// Adds strikethrough text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with striked through text appended. + public TextBuilder AddStrikeThroughText(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(text.ToStrikethrough(), inline); + return this; + } + + /// + /// Adds strikethrough text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with striked through text appended. + public TextBuilder AddStrikeThroughText(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddStrikeThroughText(text, inline); + } + + /// + /// Adds a spoiler to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with a spoiler appended. + public TextBuilder AddSpoiler(string text, bool inline = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(text.ToSpoiler(), inline); + return this; + } + + /// + /// Adds a spoiler to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with a spoiler appended. + public TextBuilder AddSpoiler(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddSpoiler(text, inline); + } + + /// + /// Adds a quote to the builder. + /// + /// The text to be present in the markdown. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a quote appended. This method will append a new line below the quote. + public TextBuilder AddQuote(string text, bool skipLine = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + if (skipLine) + _builder.AppendLine(); + _builder.AppendLine(text.ToQuote()); + _lineStart = true; + return this; + } + + /// + /// Adds a quote to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with a quote appended. + public TextBuilder AddQuote(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddQuote(text, inline); + } + + /// + /// Adds a block quote to the builder. + /// + /// The text to be present in the markdown. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a block quote appended. This method will append a new line below the quote. + public TextBuilder AddBlockQuote(string text, bool skipLine = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + if (skipLine) + _builder.AppendLine(); + _builder.AppendLine(text.ToBlockQuote()); + _lineStart = true; + return this; + } + + /// + /// Adds a block quote to the builder. + /// + /// A builder for multiline text. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a block quote appended. This method will append a new line below the quote. + public TextBuilder AddBlockQuote(MultiLineBuilder builder, bool skipLine = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddBlockQuote(text, skipLine); + } + + /// + /// Adds code marked text to the builder. + /// + /// The text to be present in the markdown. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with code marked text appended. + public TextBuilder AddCode(string text, bool inline = false) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + Construct(text.ToCode(), inline); + return this; + } + + /// + /// Adds code marked text to the builder. + /// + /// A builder for multiline text. + /// If the text should be appended in the same line or if it should append to a new line. + /// The same instance with code marked text appended. + public TextBuilder AddCode(MultiLineBuilder builder, bool inline = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddCode(text, inline); + } + + /// + /// Adds a code block to the builder. + /// + /// The text to be present in the markdown. + /// The language in which this code should be presented. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a code block appended. This method will append a new line below the block. + public TextBuilder AddCodeBlock(string text, CodeLanguage? lang = null, bool skipLine = true) + { + if (string.IsNullOrEmpty(text)) + throw new ArgumentException("Value cannot be null or empty.", nameof(text)); + + lang ??= CodeLanguage.None; + if (skipLine) + _builder.AppendLine(); + _builder.AppendLine(text.ToCodeBlock(lang)); + _lineStart = true; + return this; + } + + /// + /// Adds a code block to the builder. + /// + /// A builder for multiline text. + /// The language in which this code should be presented. + /// If the builder should skip a line when creating the next parameter. + /// The same instance with a code block appended. This method will append a new line below the quote. + public TextBuilder AddCodeBlock(MultiLineBuilder builder, CodeLanguage? lang = null, bool skipLine = true) + { + if (builder is null) + throw new ArgumentNullException(nameof(builder)); + + var text = builder.Build(); + return AddCodeBlock(text, lang, skipLine); + } + + /// + /// Adds an emote to the builder. + /// + /// The emote to add. + /// If the emote should be appended in the same line or if it should append to a new line. + /// The same instance with an emote appended. + public TextBuilder AddEmote(IEmote emote, bool inline = false) + { + if (emote is null) + throw new ArgumentNullException(nameof(emote)); + var str = emote switch + { + Emote ee => ee.ToString(), + Emoji ei => ei.ToString(), + _ => null + }; + + Construct(str, inline); + return this; + } + + /// + /// Adds a range of emotes to the builder. + /// + /// The seperator to join the emotes with. + /// If the emotes should be appended in the same line or if it should append to a new line. + /// The range of emotes to add. + /// The same instance with a range of emotes appended. + public TextBuilder AddEmotes(string seperator, bool inline = false, params IEmote[] emotes) + { + if (!emotes.Any()) + throw new ArgumentException("No values were found in the passed selection", nameof(emotes)); + + var str = string.Join(seperator, emotes.Select(x => + { + return x switch + { + Emote emote => emote.ToString(), + Emoji emoji => emoji.ToString(), + _ => throw new ArgumentNullException(nameof(emotes)), + }; + })); + Construct(str, inline); + return this; + } + + /// + /// Starts the next query to the builder on a new line. + /// + /// The same instance with an empty line appended. + public TextBuilder AddNewline() + { + _builder.AppendLine(); + _lineStart = true; + return this; + } + + /// + /// Builds a Discord message string from this instance. + /// + /// The string to send to Discord. + public string Build() + => _builder.ToString(); + + private void Construct(string text, bool inline) + { + if (_builder.Length + text.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentOutOfRangeException(nameof(text), $"Maximum message length of {DiscordConfig.MaxMessageSize} has been reached."); + + if (inline) + { + if (!_lineStart) + text = " " + text; + + else + _lineStart = false; + + _builder.Append(text); // add a space to define + } + else + { + if (_lineStart) + _lineStart = false; + _builder.AppendLine(); + _builder.Append(text); + } + } + + /// + /// Builds the underlying to a string. + /// + /// + /// This method has the same functionality as . + /// + /// The string to send to Discord. + public override string ToString() + => _builder.ToString(); + } +} diff --git a/src/Discord.Net.Core/Extensions/MarkdownExtensions.cs b/src/Discord.Net.Core/Extensions/MarkdownExtensions.cs new file mode 100644 index 0000000000..c1be9208c5 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/MarkdownExtensions.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + internal static class MarkdownExtensions + { + public static string ToBold(this string text, int index = 0, int? count = null) //=> $"**{text}**"; + { + var length = count ?? (text.Length - index); + + return text.Format($"**{text.Substring(index, (index + length))}**", index, length); + } + + public static string ToItalic(this string text, int index = 0, int? count = null) //=> $"*{text}*"; + { + var length = count ?? (text.Length - index); + + return text.Format($"*{text.Substring(index, (index + length))}*", index, length); + } + + public static string ToUnderline(this string text, int index = 0, int? count = null) //=> $"__{text}__"; + { + var length = count ?? (text.Length - index); + + return text.Format($"__{text.Substring(index, (index + length))}__", index, length); + } + + public static string ToStrikethrough(this string text, int index = 0, int? count = null) //=> $"~~{text}~~"; + { + var length = count ?? (text.Length - index); + + return text.Format($"~~{text.Substring(index, (index + length))}~~", index, length); + } + + public static string ToSpoiler(this string text, int index = 0, int? count = null) //=> $"||{text}||"; + { + var length = count ?? (text.Length - index); + + return text.Format($"||{text.Substring(index, (index + length))}||", index, length); + } + + public static string ToQuote(this string text, int index = 0, int? count = null) //=> $"> {text}"; + { + if (index is 0 && count is null) + text = text.Replace(Environment.NewLine, $"{Environment.NewLine}> "); + + var length = count ?? (text.Length - index); + + return text.Format($"{Environment.NewLine}> {text.Substring(index, (index + length))}{Environment.NewLine}", index, length); + } + + public static string ToBlockQuote(this string text, int index = 0, int? count = null) //=> $">>> {text}"; + { + var length = count ?? (text.Length - index); + + return text.Format($"{Environment.NewLine}>>> {text.Substring(index, (index + length))}{Environment.NewLine}", index, length); + } + + public static string ToCode(this string text, int index = 0, int? count = null) //=> $"`{text}`"; + { + var length = count ?? (text.Length - index); + + return text.Format($"`{text.Substring(index, (index + length))}`", index, length); + } + + public static string ToCodeBlock(this string text, CodeLanguage? lang = null, int index = 0, int? count = null) + { + lang ??= CodeLanguage.None; + + var length = count ?? (text.Length - index); + + return text.Format($"```{lang.Value}{Environment.NewLine}{text.Substring(index, (index + length))}{Environment.NewLine}```", index, length); + } + + public static string ToHyperLink(this string text, string url, int index = 0, int? count = null) + { + var length = count ?? (text.Length - index); + + return text.Format($"[{text.Substring(index, (index + length))}]({url})", index, length); + } + + public static string ToHeader(this string text, HeaderFormat format, int index = 0, int? count = null) + { + var length = count ?? (text.Length - index); + + return text.Format($"{Environment.NewLine}{format.Format} {text.Substring(index, (index + length))} {Environment.NewLine}", index, length); + } + + public static string WithTimestamp(this string text, DateTime dateTime, TimestampTagStyles style, int index = 0) + => text.Insert(index, TimestampTag.FromDateTime(dateTime, style).ToString()); + + private static string Format(this string text, string format, int index, int length) + { + return text.Insert(index, format).Remove(index + format.Length, length); + } + } +} diff --git a/src/Discord.Net.Core/Format.cs b/src/Discord.Net.Core/Format.cs index dc2a065400..66a9bdb2fc 100644 --- a/src/Discord.Net.Core/Format.cs +++ b/src/Discord.Net.Core/Format.cs @@ -10,22 +10,44 @@ public static class Format private static readonly string[] SensitiveCharacters = { "\\", "*", "_", "~", "`", ".", ":", "/", ">", "|" }; - /// Returns a markdown-formatted string with bold formatting. + /// + /// Returns a markdown-formatted string with bold formatting. + /// public static string Bold(string text) => $"**{text}**"; - /// Returns a markdown-formatted string with italics formatting. + + /// + /// Returns a markdown-formatted string with italics formatting. + /// public static string Italics(string text) => $"*{text}*"; - /// Returns a markdown-formatted string with underline formatting. + + /// + /// Returns a markdown-formatted string with underline formatting. + /// public static string Underline(string text) => $"__{text}__"; - /// Returns a markdown-formatted string with strike-through formatting. + + /// + /// Returns a markdown-formatted string with strike-through formatting. + /// public static string Strikethrough(string text) => $"~~{text}~~"; - /// Returns a string with spoiler formatting. + + /// + /// Returns a string with spoiler formatting. + /// public static string Spoiler(string text) => $"||{text}||"; - /// Returns a markdown-formatted URL. Only works in descriptions and fields. + + /// + /// Returns a markdown-formatted URL. Only works in descriptions and fields. + /// public static string Url(string text, string url) => $"[{text}]({url})"; - /// Escapes a URL so that a preview is not generated. + + /// + /// Escapes a URL so that a preview is not generated. + /// public static string EscapeUrl(string url) => $"<{url}>"; - /// Returns a markdown-formatted string with codeblock formatting. + /// + /// Returns a markdown-formatted string with codeblock formatting. + /// public static string Code(string text, string language = null) { if (language != null || text.Contains("\n")) @@ -34,7 +56,9 @@ public static string Code(string text, string language = null) return $"`{text}`"; } - /// Sanitizes the string, safely escaping any Markdown sequences. + /// + /// Sanitizes the string, safely escaping any Markdown sequences. + /// public static string Sanitize(string text) { foreach (string unsafeChar in SensitiveCharacters)