diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79098dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +db.dat +config.json + +# Common IntelliJ Platform excludes + +# User specific +**/.idea/**/workspace.xml +**/.idea/**/tasks.xml +**/.idea/shelf/* +**/.idea/dictionaries +**/.idea/httpRequests/ + +# Sensitive or high-churn files +**/.idea/**/dataSources/ +**/.idea/**/dataSources.ids +**/.idea/**/dataSources.xml +**/.idea/**/dataSources.local.xml +**/.idea/**/sqlDataSources.xml +**/.idea/**/dynamic.xml + +# Rider +# Rider auto-generates .iml files, and contentModel.xml +**/.idea/**/*.iml +**/.idea/**/contentModel.xml +**/.idea/**/modules.xml + +*.suo +*.user +.vs/ +[Bb]in/ +[Oo]bj/ +_UpgradeReport_Files/ +[Pp]ackages/ +Logs/ + +Thumbs.db +Desktop.ini +.DS_Store + +bin/ +obj/ \ No newline at end of file diff --git a/.idea/.idea.QuoteBot/.idea/.gitignore b/.idea/.idea.QuoteBot/.idea/.gitignore new file mode 100644 index 0000000..229aca8 --- /dev/null +++ b/.idea/.idea.QuoteBot/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/contentModel.xml +/.idea.QuoteBot.iml +/modules.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/.idea.QuoteBot/.idea/encodings.xml b/.idea/.idea.QuoteBot/.idea/encodings.xml new file mode 100644 index 0000000..df87cf9 --- /dev/null +++ b/.idea/.idea.QuoteBot/.idea/encodings.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/.idea.QuoteBot/.idea/indexLayout.xml b/.idea/.idea.QuoteBot/.idea/indexLayout.xml new file mode 100644 index 0000000..7b08163 --- /dev/null +++ b/.idea/.idea.QuoteBot/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Commands/ConfigureQuotes.cs b/Commands/ConfigureQuotes.cs new file mode 100644 index 0000000..a92612a --- /dev/null +++ b/Commands/ConfigureQuotes.cs @@ -0,0 +1,35 @@ +using Discord; +using Discord.WebSocket; +using SimpleDiscordNet.Commands; + +namespace QuoteBot.Commands; + +public class ConfigureQuotes { + private static readonly string[] ValidForms = ["embed", "fake-msg", "bot-msg"]; + + [SlashCommand("configure-quotes", "Change quote settings", GuildPermission.ManageGuild)] + [SlashCommandArgument("channel", "The quotes channel to send quotes to", true, ApplicationCommandOptionType.Channel)] + [SlashCommandArgument("quote-form", "How to display quotes: 'embed', 'fake-msg' or 'bot-msg'", true, ApplicationCommandOptionType.String)] + public async Task Execute(SocketSlashCommand cmd, DiscordSocketClient client) { + IGuildChannel channel = cmd.GetArgument("channel")!; + string form = cmd.GetArgument("quote-form")!; + + if (!ValidForms.Contains(form)) { + await cmd.RespondWithEmbedAsync("Configure Quotes", "Invalid quote form, must be 'embed', 'fake-msg' or 'bot-msg'.", ResponseType.Error, ephemeral: false); + return; + } + + if (cmd.Channel.GetChannelType() == ChannelType.DM) { + await cmd.RespondWithEmbedAsync("Configure Quotes", "You can't do this in your DMs.", ResponseType.Error, ephemeral: false); + return; + } + + if (channel is not ITextChannel textChannel) { + await cmd.RespondWithEmbedAsync("Configure Quotes", "Channel must be a text channel.", ResponseType.Error, ephemeral: true); + return; + } + + Program.Storage.SetQuoteSettings(cmd.GuildId!.Value, textChannel.Id, form); + await cmd.RespondWithEmbedAsync("Configure Quotes", "Quotes have been configured in this server.", ResponseType.Success, ephemeral: true); + } +} \ No newline at end of file diff --git a/Commands/QuoteCommand.cs b/Commands/QuoteCommand.cs new file mode 100644 index 0000000..40c3cea --- /dev/null +++ b/Commands/QuoteCommand.cs @@ -0,0 +1,58 @@ +using Discord; +using Discord.WebSocket; +using GeneralPurposeLib; +using QuoteBot.Data; +using SimpleDiscordNet.Commands; + +namespace QuoteBot.Commands; + +public class QuoteCommand { + + [SlashCommand("quote-user", "Quote something someone said and attribute quote to user")] + [SlashCommandArgument("person", "The person you are quoting", true, ApplicationCommandOptionType.User)] + [SlashCommandArgument("message", "What they said", true, ApplicationCommandOptionType.String)] + public Task WithUser(SocketSlashCommand cmd, DiscordSocketClient client) { + IUser quotee = cmd.GetArgument("person")!; + string message = cmd.GetArgument("message")!; + return Execute(cmd, client, message, quotee: quotee); + } + + [SlashCommand("quote", "Quote something someone said")] + [SlashCommandArgument("person", "The person you are quoting", true, ApplicationCommandOptionType.String)] + [SlashCommandArgument("message", "What they said", true, ApplicationCommandOptionType.String)] + public Task WithString(SocketSlashCommand cmd, DiscordSocketClient client) { + string quotee = cmd.GetArgument("person")!; + string message = cmd.GetArgument("message")!; + return Execute(cmd, client, message, quoteeName: quotee); + } + + public async Task Execute(SocketSlashCommand cmd, DiscordSocketClient client, string msg, string? quoteeName = null, IUser? quotee = null) { + if (cmd.Channel.GetChannelType() == ChannelType.DM) { + await cmd.RespondWithEmbedAsync("Quote", "You can't do this in your DMs.", ResponseType.Error, ephemeral: false); + return; + } + + ITextChannel? quotesChannel = await cmd.GetQuotesChannel(client); + + if (quotesChannel == null) { + await cmd.RespondWithEmbedAsync("Error", + "There is no quotes channel configured, ask a staff member to configure it.", ResponseType.Error, + ephemeral: true); + return; + } + + await cmd.DeferAsync(); + + await Quoting.QuoteMessage(client, + cmd.GuildId!.Value, + quoteeName ?? quotee!.Username, + cmd.User.Username, + cmd.User.Id, + quoteeName ?? quotee!.Id.ToString(), + msg, + quotee); + + await cmd.ModifyWithEmbedAsync("Quotes", $"Quote has been created in {quotesChannel.Mention}.", + ResponseType.Success); + } +} \ No newline at end of file diff --git a/Data/Quote.cs b/Data/Quote.cs new file mode 100644 index 0000000..b063d00 --- /dev/null +++ b/Data/Quote.cs @@ -0,0 +1,3 @@ +namespace QuoteBot.Data; + +public record Quote(ulong Quoter, string Quotee, ulong Guild, ulong MessageId, string Text, ulong? QuotedMessageChannel = null, ulong? QuotedMessageId = null); \ No newline at end of file diff --git a/Data/Storage/IStorageService.cs b/Data/Storage/IStorageService.cs new file mode 100644 index 0000000..209dfec --- /dev/null +++ b/Data/Storage/IStorageService.cs @@ -0,0 +1,11 @@ +namespace QuoteBot.Data.Storage; + +public interface IStorageService { + void Init(); + void Deinit(); + + void CreateQuote(Quote quote); + void SetQuoteSettings(ulong guild, ulong channel, string form); + ulong? GetQuoteChannel(ulong guild); + string? GetQuoteForm(ulong guild); +} diff --git a/Data/Storage/SqliteStorageService.cs b/Data/Storage/SqliteStorageService.cs new file mode 100644 index 0000000..2e9f87a --- /dev/null +++ b/Data/Storage/SqliteStorageService.cs @@ -0,0 +1,80 @@ +using System.Data.SQLite; + +namespace QuoteBot.Data.Storage; + +public class SqliteStorageService : IStorageService { + private const string ConnectionString = "Data Source=db.dat"; + private SQLiteConnection _connection = null!; + + public void Init() { + _connection = new SQLiteConnection(ConnectionString); + _connection.Open(); + CreateTables(); + } + + private void CreateTables() { + SQLiteCommand cmd = new(@" +CREATE TABLE IF NOT EXISTS quotes ( + quoter VARCHAR(64), + quotee VARCHAR(64), + guild VARCHAR(64), + message_id VARCHAR(64), + quote TEXT, + quoted_message_channel VARCHAR(64), + quoted_message_id VARCHAR(64) +); + +CREATE TABLE IF NOT EXISTS guild_configs ( + guild VARCHAR(64) PRIMARY KEY, + channel_id VARCHAR(64), + form VARCHAR(16) +) +", _connection); + cmd.ExecuteNonQuery(); + } + + public void Deinit() { + _connection.Dispose(); + } + + public void CreateQuote(Quote quote) { + using SQLiteCommand cmd = new("INSERT INTO quotes (quoter, quotee, guild, message_id, quote, quoted_message_channel, quoted_message_id) " + + "VALUES (@quoter, @quotee, @guild, @message_id, @quote, @quoted_message_channel, @quoted_message_id);", _connection); + cmd.Parameters.AddWithValue("quoter", quote.Quoter.ToString()); + cmd.Parameters.AddWithValue("quotee", quote.Quotee); + cmd.Parameters.AddWithValue("guild", quote.Guild.ToString()); + cmd.Parameters.AddWithValue("message_id", quote.MessageId.ToString()); + cmd.Parameters.AddWithValue("quote", quote.Text); + cmd.Parameters.AddWithValue("quoted_message_channel", quote.QuotedMessageChannel.ToString()); + cmd.Parameters.AddWithValue("quoted_message_id", quote.QuotedMessageId.ToString()); + cmd.ExecuteNonQuery(); + } + + public void SetQuoteSettings(ulong guild, ulong channel, string form) { + using SQLiteCommand cmd = new("INSERT OR REPLACE INTO guild_configs (guild, channel_id, form) VALUES (@guild, @channel, @form);", _connection); + cmd.Parameters.AddWithValue("guild", guild.ToString()); + cmd.Parameters.AddWithValue("channel", channel.ToString()); + cmd.Parameters.AddWithValue("form", form); + cmd.ExecuteNonQuery(); + } + + public ulong? GetQuoteChannel(ulong guild) { + using SQLiteCommand cmd = new("SELECT channel_id FROM guild_configs WHERE guild = @guild;", _connection); + cmd.Parameters.AddWithValue("guild", guild.ToString()); + using SQLiteDataReader reader = cmd.ExecuteReader(); + if (!reader.Read()) { + return null; + } + return ulong.Parse(reader.GetString(0)); + } + + public string? GetQuoteForm(ulong guild) { + using SQLiteCommand cmd = new("SELECT form FROM guild_configs WHERE guild = @guild;", _connection); + cmd.Parameters.AddWithValue("guild", guild.ToString()); + using SQLiteDataReader reader = cmd.ExecuteReader(); + if (!reader.Read()) { + return null; + } + return reader.GetString(0); + } +} \ No newline at end of file diff --git a/DefaultConfig.cs b/DefaultConfig.cs new file mode 100644 index 0000000..f174ac2 --- /dev/null +++ b/DefaultConfig.cs @@ -0,0 +1,9 @@ +using GeneralPurposeLib; + +namespace QuoteBot; + +public static class DefaultConfig { + public static Dictionary Values = new() { + { "token", "xxxxxxxxxxxxxxx" } + }; +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..7cd2890 --- /dev/null +++ b/Program.cs @@ -0,0 +1,37 @@ +using GeneralPurposeLib; +using QuoteBot.Data.Storage; +using SimpleDiscordNet; + +namespace QuoteBot; + +public static class Program { + public static IStorageService Storage = null!; + + public static async Task Main(string[] args) { + Logger.Init(LogLevel.Debug); + Config config = new(DefaultConfig.Values); + + Storage = new SqliteStorageService(); + Storage.Init(); + + SimpleDiscordBot bot = new(config["token"]); + + bot.Log += message => { + Logger.Info(message.Message); + if (message.Exception != null) { + Logger.Info(message.Exception); + } + return Task.CompletedTask; + }; + + bot.Client.Ready += () => { + bot.UpdateCommands(); + bot.Client.SetCustomStatusAsync("Watching for funny quotes"); + Logger.Info("Bot ready"); + return Task.CompletedTask; + }; + await bot.StartBot(); + Logger.Info("Bot started"); + await bot.WaitAsync(); + } +} \ No newline at end of file diff --git a/QuoteBot.csproj b/QuoteBot.csproj new file mode 100644 index 0000000..e58d448 --- /dev/null +++ b/QuoteBot.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + SimpleDiscordNet.dll + + + + diff --git a/QuoteBot.sln b/QuoteBot.sln new file mode 100644 index 0000000..063dc59 --- /dev/null +++ b/QuoteBot.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuoteBot", "QuoteBot.csproj", "{132496D3-908A-4419-BB25-A08E11448FDE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {132496D3-908A-4419-BB25-A08E11448FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {132496D3-908A-4419-BB25-A08E11448FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {132496D3-908A-4419-BB25-A08E11448FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {132496D3-908A-4419-BB25-A08E11448FDE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Quoting.cs b/Quoting.cs new file mode 100644 index 0000000..ef2d532 --- /dev/null +++ b/Quoting.cs @@ -0,0 +1,65 @@ +using Discord; +using Discord.Webhook; +using Discord.WebSocket; +using QuoteBot.Data; + +namespace QuoteBot; + +public class Quoting { + + public static async Task QuoteMessage(DiscordSocketClient client, + ulong guild, + string quoteeName, + string quoterName, + ulong quoterId, + string quoteeData, + string msg, + IUser? quotee = null, + ulong? quotedMsgChannel = null, + ulong? quotedMsgId = null) { + ITextChannel? quotesChannel = await client.GetQuotesChannel(guild); + if (quotesChannel == null) { + throw new Exception("Quotes channel isn't defined"); + } + + string form = Program.Storage.GetQuoteForm(guild)!; // Should be defined if channel is + + IMessage createdMsg; + switch (form) { + case "embed": { + EmbedBuilder embedBuilder = new(); + embedBuilder.WithTitle(quoteeName); + embedBuilder.WithDescription($"\"{msg}\""); + embedBuilder.WithFooter("Quoted by " + quoterName); + embedBuilder.WithColor(Color.Green); + + createdMsg = await quotesChannel.SendMessageAsync(embed: embedBuilder.Build()); + break; + } + + case "fake-msg": { + IWebhook? webhook = (await quotesChannel.GetWebhooksAsync()).FirstOrDefault() ?? await quotesChannel.CreateWebhookAsync("Quotes"); + + DiscordWebhookClient webhookClient = new(webhook); + ulong newMsgId = await webhookClient.SendMessageAsync(text: msg, + username: quoteeName, + avatarUrl: quotee?.GetAvatarUrl()); + + createdMsg = await quotesChannel.GetMessageAsync(newMsgId); + break; + } + + case "bot-msg": { + createdMsg = await quotesChannel.SendMessageAsync($"{quoteeName}: \"{msg}\""); + break; + } + + default: + throw new Exception("Invalid quote form in db."); + } + + + Quote quote = new(quoterId, quoteeData, guild, createdMsg.Id, msg, quotedMsgChannel, quotedMsgId); + Program.Storage.CreateQuote(quote); + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..540b9e7 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Quote Bot +Quote people in Discord. \ No newline at end of file diff --git a/ReplyQuoteListener.cs b/ReplyQuoteListener.cs new file mode 100644 index 0000000..d3ec733 --- /dev/null +++ b/ReplyQuoteListener.cs @@ -0,0 +1,39 @@ +using Discord; +using Discord.WebSocket; +using SimpleDiscordNet.MessageReceived; + +namespace QuoteBot; + +public class ReplyQuoteListener { + + [MessageListener] + public async Task MessageListen(SocketMessage msg, DiscordSocketClient client) { + if (msg.Channel is not IGuildChannel channel) { + return; + } + + if (msg.MentionedUsers.All(u => u.Id != client.CurrentUser.Id)) { + return; + } + + if (msg.Reference == null || !msg.Reference.MessageId.IsSpecified) { + await msg.Channel.SendMessageAsync("Reply to a message and ping me to quote it.", + messageReference: msg.ToReference()); + return; + } + + IMessage quotedMsg = await msg.Channel.GetMessageAsync(msg.Reference.MessageId.Value); + await Quoting.QuoteMessage(client, + channel.GuildId, + quotedMsg.Author.Username, + msg.Author.Username, + msg.Author.Id, + quotedMsg.Author.Id.ToString(), + quotedMsg.Content, + quotedMsg.Author, + quotedMsg.Channel.Id, + quotedMsg.Id); + + await msg.AddReactionAsync(Emoji.Parse(":white_check_mark:")); + } +} \ No newline at end of file diff --git a/SimpleDiscordNet.dll b/SimpleDiscordNet.dll new file mode 100644 index 0000000..1872619 Binary files /dev/null and b/SimpleDiscordNet.dll differ diff --git a/Utils.cs b/Utils.cs new file mode 100644 index 0000000..1b5499d --- /dev/null +++ b/Utils.cs @@ -0,0 +1,31 @@ +using Discord; +using Discord.WebSocket; + +namespace QuoteBot; + +public static class Utils { + + public static async Task GetQuotesChannel(this SocketSlashCommand cmd, DiscordSocketClient client) { + ulong? channelId = Program.Storage.GetQuoteChannel(cmd.GuildId!.Value); + if (channelId == null) { + return null; + } + + IChannel channel = await client.GetChannelAsync(channelId.Value); + return channel as ITextChannel; + } + + public static async Task GetQuotesChannel(this DiscordSocketClient client, ulong guild) { + ulong? channelId = Program.Storage.GetQuoteChannel(guild); + if (channelId == null) { + return null; + } + + IChannel channel = await client.GetChannelAsync(channelId.Value); + return channel as ITextChannel; + } + + public static MessageReference ToReference(this SocketMessage msg) { + return new MessageReference(msg.Id); + } +} \ No newline at end of file