diff --git a/.gitignore b/.gitignore index dfcfd56..b111e2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +config.json + # User-specific files *.rsuser *.suo @@ -347,4 +349,4 @@ healthchecksdb MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder -.ionide/ +.ionide/ \ No newline at end of file diff --git a/Commands/BasicCommandsModule.cs b/Commands/BasicCommandsModule.cs new file mode 100644 index 0000000..182f787 --- /dev/null +++ b/Commands/BasicCommandsModule.cs @@ -0,0 +1,67 @@ +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using DSharpPlus.Interactivity; +using System; +using System.Threading.Tasks; + +namespace PoisnCopy.Commands +{ + public class BasicCommandsModule : IModule + { + /* Commands in DSharpPlus.CommandsNext are identified by supplying a Command attribute to a method in any class you've loaded into it. */ + /* The description is just a string supplied when you use the help command included in CommandsNext. */ + + [Command("connections")] + [Description("Simple command to see how many servers the bot is on.")] + public async Task Connections(CommandContext ctx) + { + await ctx.TriggerTypingAsync(); + + var connections = ctx.Client.Guilds; + await ctx.RespondAsync($"I am running on {connections} servers"); + } + + [Command("alive")] + [Description("Simple command to test if the bot is running!")] + public async Task Alive(CommandContext ctx) + { + /* Trigger the Typing... in discord */ + await ctx.TriggerTypingAsync(); + + /* Send the message "I'm Alive!" to the channel the message was recieved from */ + await ctx.RespondAsync("I'm alive!"); + } + + [Command("interact")] + [Description("Simple command to test interaction!")] + public async Task Interact(CommandContext ctx) + { + /* Trigger the Typing... in discord */ + await ctx.TriggerTypingAsync(); + + /* Send the message "I'm Alive!" to the channel the message was recieved from */ + await ctx.RespondAsync("How are you today?"); + + var intr = ctx.Client.GetInteractivityModule(); // Grab the interactivity module + var reminderContent = await intr.WaitForMessageAsync( + c => c.Author.Id == ctx.Message.Author.Id, // Make sure the response is from the same person who sent the command + TimeSpan.FromSeconds(60) // Wait 60 seconds for a response instead of the default 30 we set earlier! + ); + + // You can also check for a specific message by doing something like + // c => c.Content == "something" + + // Null if the user didn't respond before the timeout + if (reminderContent == null) + { + await ctx.RespondAsync("Sorry, I didn't get a response!"); + return; + } + + // Homework: have this change depending on if they say "good" or "bad", etc. + await ctx.RespondAsync("Sucks to suck"); + } + } +} \ No newline at end of file diff --git a/Commands/CopyChannelCommand.cs b/Commands/CopyChannelCommand.cs new file mode 100644 index 0000000..66b0cc3 --- /dev/null +++ b/Commands/CopyChannelCommand.cs @@ -0,0 +1,141 @@ +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.CommandsNext.Attributes; +using DSharpPlus.Entities; +using DSharpPlus.Interactivity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using static DSharpPlus.Entities.DiscordEmbedBuilder; + +namespace PoisnCopy.Commands +{ + [RequirePermissions(Permissions.Administrator)] + [Hidden] + public class CopyChannelCommand : IModule + { + [Command("copychannel")] + [Description("Copy a channel")] + public async Task CopyChannel(CommandContext ctx) + { + await ctx.TriggerTypingAsync(); + + await ctx.RespondAsync("Which Channel would you like to copy? (copy and paste the Id) Pleaes wait while I find all of your channels, I will give you a message when I have found them all."); + + await ctx.TriggerTypingAsync(); + var textChannels = ctx.Guild.Channels.Where(i => i.Type == ChannelType.Text).ToList(); + foreach (var txtChan in textChannels) + { + await ctx.TriggerTypingAsync(); + await ctx.RespondAsync($"`{txtChan.Id}-{txtChan.Name}`"); + await ctx.TriggerTypingAsync(); + } + await ctx.RespondAsync($"Thats all of the channels!"); + + var intr = ctx.Client.GetInteractivityModule(); // Grab the interactivity module + var response = await intr.WaitForMessageAsync( + c => c.Author.Id == ctx.Message.Author.Id, // Make sure the response is from the same person who sent the command + TimeSpan.FromSeconds(60) // Wait 60 seconds for a response instead of the default 30 we set earlier! + ); + + if (response == null) + { + await ctx.RespondAsync("Sorry, I didn't get a response!"); + return; + } + + var selectedChannel = textChannels.FirstOrDefault(i => i.Id.ToString() == response.Message.Content); + + if (selectedChannel == null) + { + await ctx.RespondAsync("Sorry, but that channel does not exist!"); + return; + } + + await ctx.RespondAsync($"Copy command: `pc.loadchannel {selectedChannel.GuildId} {selectedChannel.Id}`"); + } + + [Command("loadchannel")] + [Description("Load a copied channel")] + public async Task LoadChannel(CommandContext ctx, ulong guildId, ulong channelId) + { + var guild = await ctx.Client.GetGuildAsync(guildId); + var selectedChannel = guild.GetChannel(channelId); + + await ctx.RespondAsync("What do you want the new channel to be named?"); + + var intr = ctx.Client.GetInteractivityModule(); // Grab the interactivity module + var response = await intr.WaitForMessageAsync( + c => c.Author.Id == ctx.Message.Author.Id, // Make sure the response is from the same person who sent the command + TimeSpan.FromSeconds(60) // Wait 60 seconds for a response instead of the default 30 we set earlier! + ); + + if (response == null) + { + await ctx.RespondAsync("Sorry, I didn't get a response!"); + return; + } + + if (string.IsNullOrEmpty(response.Message.Content)) + { + await ctx.RespondAsync("Name cannot be empty!"); + return; + } + + var channelName = response.Message.Content; + + await ctx.RespondAsync("Starting copy..."); + + await ctx.RespondAsync("Collecting messages..."); + + var messag = await selectedChannel.GetMessagesAsync(); + + var messCopy = messag.ToList(); + + var more = await selectedChannel.GetMessagesAsync(100, messag.LastOrDefault().Id); + + while (more.Count > 0) + { + messCopy.AddRange(more); + more = await selectedChannel.GetMessagesAsync(100, more.LastOrDefault().Id); + } + + await ctx.RespondAsync("Organizing messages..."); + + messCopy.Reverse(); + + var newMess = new List(); + + await ctx.RespondAsync("Creating channel..."); + + var newChan = await ctx.Guild.CreateChannelAsync(channelName, selectedChannel.Type); + + await ctx.RespondAsync($"Posting {messCopy.Count} messages... (this could take awhile)"); + + foreach (var mes in messCopy) + { + if (!string.IsNullOrEmpty(mes.Content)) + { + var whAu = new EmbedAuthor { Name = mes.Author.Username, IconUrl = mes.Author.AvatarUrl }; + var what = new DiscordEmbedBuilder { Description = mes.Content, Author = whAu, Timestamp = mes.Timestamp }; + + await newChan.SendMessageAsync(null, false, what); + } + + if (mes.Attachments.Count > 0) + { + foreach (var att in mes.Attachments) + { + var whAu = new EmbedAuthor { Name = mes.Author.Username, IconUrl = mes.Author.AvatarUrl }; + var what = new DiscordEmbedBuilder { ImageUrl = att.Url, Author = whAu, Timestamp = mes.Timestamp }; + + await newChan.SendMessageAsync(null, false, what); + } + } + } + + await ctx.RespondAsync($"{newChan.Name} copy complete!"); + } + } +} \ No newline at end of file diff --git a/IModule.cs b/IModule.cs new file mode 100644 index 0000000..bf4f4f8 --- /dev/null +++ b/IModule.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace PoisnCopy +{ + public interface IModule + { + } +} diff --git a/PoisnCopy.csproj b/PoisnCopy.csproj new file mode 100644 index 0000000..30ce3c3 --- /dev/null +++ b/PoisnCopy.csproj @@ -0,0 +1,20 @@ + + + + Exe + net5.0 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PoisnCopy.sln b/PoisnCopy.sln new file mode 100644 index 0000000..07d010b --- /dev/null +++ b/PoisnCopy.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30611.23 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PoisnCopy", "PoisnCopy.csproj", "{071E04E6-CEDB-414B-BABA-E2AEAF939586}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {071E04E6-CEDB-414B-BABA-E2AEAF939586}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {071E04E6-CEDB-414B-BABA-E2AEAF939586}.Debug|Any CPU.Build.0 = Debug|Any CPU + {071E04E6-CEDB-414B-BABA-E2AEAF939586}.Release|Any CPU.ActiveCfg = Release|Any CPU + {071E04E6-CEDB-414B-BABA-E2AEAF939586}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {05007DA9-D6E7-4D2A-BB80-EBF775A7292D} + EndGlobalSection +EndGlobal diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..3efc8c6 --- /dev/null +++ b/Program.cs @@ -0,0 +1,123 @@ +using System; +using System.Linq; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using DSharpPlus; +using DSharpPlus.CommandsNext; +using DSharpPlus.Interactivity; +using Microsoft.Extensions.Configuration; + +namespace PoisnCopy +{ + internal class Program + { + /* This is the cancellation token we'll use to end the bot if needed(used for most async stuff). */ + private CancellationTokenSource _cts { get; set; } + + /* We'll load the app config into this when we create it a little later. */ + private IConfigurationRoot _config; + + /* These are the discord library's main classes */ + private DiscordClient _discord; + private CommandsNextModule _commands; + private InteractivityModule _interactivity; + + /* Use the async main to create an instance of the class and await it(async main is only available in C# 7.1 onwards). */ + + private static async Task Main(string[] args) => await new Program().InitBot(args); + + private async Task InitBot(string[] args) + { + try + { + Console.WriteLine("[info] Welcome to my bot!"); + _cts = new CancellationTokenSource(); + + // Load the config file(we'll create this shortly) + Console.WriteLine("[info] Loading config file.."); + _config = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("config.json", optional: false, reloadOnChange: true) + .Build(); + + // Create the DSharpPlus client + Console.WriteLine("[info] Creating discord client.."); + _discord = new DiscordClient(new DiscordConfiguration + { + Token = _config.GetValue("discord:token"), + TokenType = TokenType.Bot + }); + + // Create the interactivity module(I'll show you how to use this later on) + _interactivity = _discord.UseInteractivity(new InteractivityConfiguration() + { + PaginationBehaviour = TimeoutBehaviour.Delete, // What to do when a pagination request times out + PaginationTimeout = TimeSpan.FromSeconds(30), // How long to wait before timing out + Timeout = TimeSpan.FromSeconds(30) // Default time to wait for interactive commands like waiting for a message or a reaction + }); + + // Build dependancies and then create the commands module. + var deps = BuildDeps(); + _commands = _discord.UseCommandsNext(new CommandsNextConfiguration + { + StringPrefix = _config.GetValue("discord:CommandPrefix"), // Load the command prefix(what comes before the command, eg "!" or "/") from our config file + Dependencies = deps // Pass the dependancies + }); + + Console.WriteLine("[info] Loading command modules.."); + + var type = typeof(IModule); // Get the type of our interface + var types = AppDomain.CurrentDomain.GetAssemblies() // Get the assemblies associated with our project + .SelectMany(s => s.GetTypes()) // Get all the types + .Where(p => type.IsAssignableFrom(p) && !p.IsInterface); // Filter to find any type that can be assigned to an IModule + + var typeList = types as Type[] ?? types.ToArray(); // Convert to an array + foreach (var t in typeList) + _commands.RegisterCommands(t); // Loop through the list and register each command module with CommandsNext + + Console.WriteLine($"[info] Loaded {typeList.Count()} modules."); + await RunAsync(args); + } + catch (Exception ex) + { + // This will catch any exceptions that occur during the operation/setup of your bot. + + // Feel free to replace this with what ever logging solution you'd like to use. + // I may do a guide later on the basic logger I implemented in my most recent bot. + Console.Error.WriteLine(ex.ToString()); + } + } + + private async Task RunAsync(string[] args) + { + // Connect to discord's service + Console.WriteLine("Connecting.."); + await _discord.ConnectAsync(); + Console.WriteLine("Connected!"); + var connections = _discord.Presences.Count; + Console.WriteLine($"I am running on {connections} servers"); + + // Keep the bot running until the cancellation token requests we stop + while (!_cts.IsCancellationRequested) + await Task.Delay(TimeSpan.FromMinutes(1)); + } + + /* + DSharpPlus has dependancy injection for commands, this builds a list of dependancies. + We can then access these in our command modules. + */ + + private DependencyCollection BuildDeps() + { + using var deps = new DependencyCollectionBuilder(); + + deps.AddInstance(_interactivity) // Add interactivity + .AddInstance(_cts) // Add the cancellation token + .AddInstance(_config) // Add our config + .AddInstance(_discord); // Add the discord client + + return deps.Build(); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 4357e21..e3b556f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ # PoisnCopy Discord channel copy bot + +Add the bot to BOTH of your servers using this link. (NOTE: You must be the OWNER (Not just an admin) of BOTH servers) +https://discordapp.com/api/oauth2/authorize?client_id=768322637574570015&scope=bot&permissions=8 + +**DISCLAIMER: There is no guarantee that I will keep this bot running on the server at any given point, use at your own risk** + +Use: `pc.copychannel` +The bot will list the channels in the server that you are typing in. You must enter the ID of the channel that you want to copy and then I will give you a command to copy and paste in the new server. + +**Make sure your bot can see which channel you are typing in (check left hand side)** +![image](https://user-images.githubusercontent.com/60050783/107395699-49d3a300-6aba-11eb-8b1c-d4e4b41cd6f3.png) + +*Note: The `pc.copychannel` command is just to list the IDs, once you have them, you can just use the `pc.loadchannel`command. + +i.e. + +-> `pc.copychannel` + +-> "Which Channel would you like to copy? (copy and paste the Id) Pleaes wait while I find all of your channels, I will give you a message when I have found them all." + +-> `123456752146761758` + +-> "Copy command: " + +-> `pc.loadchannel 123456737220528146 123456752146761758` **Paste this in the Server that you want to put copy the channel into** + +-> "What do you want the new channel to be named?" + +-> `Channel Name` **It will create a new channel in the server that you are in** ![image](https://user-images.githubusercontent.com/60050783/107396172-c4042780-6aba-11eb-8ec4-88cf4b750e6a.png) + +-> "Starting copy... + +-> Collecting messages... + +-> Organizing messages... + +-> Creating channel... + +-> Posting 218 messages... (this could take awhile) + +-> new-chan copy complete!" **It will tell you when it is done, copying hundreds/thousands of messages can take a LONG time, be patient** + +Contributions are welcome! Just create a PR and I will review it. + +In order to run the project on your own, you will need to rename the `test-config.json` file to `config.json` and then you need to fill in the corresponding fields from your https://discord.com/developers/applications specific application. I will not go into specifics on creating your own discord bot here. \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..24909b1 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,40 @@ +# .NET Desktop +# Build and run tests for .NET Desktop or Windows classic desktop solutions. +# Add steps that publish symbols, save build artifacts, and more: +# https://docs.microsoft.com/azure/devops/pipelines/apps/windows/dot-net + +trigger: +- master + +pool: + vmImage: 'ubuntu-latest' + +variables: + solution: '**/*.sln' + buildPlatform: 'Any CPU' + buildConfiguration: 'Release' + +steps: +- task: UseDotNet@2 + displayName: 'Use .NET Core sdk 5.x' + inputs: + packageType: 'sdk' + version: '5.x' + includePreviewVersions: true + +- script: dotnet build --configuration $(buildConfiguration) + displayName: 'dotnet build $(buildConfiguration)' + +- task: DotNetCoreCLI@2 + displayName: 'dotnet publish' + inputs: + command: 'publish' + publishWebProjects: false + projects: '**/PoisnCopy.csproj' + arguments: '-c Release' + zipAfterPublish: false +- task: PublishBuildArtifacts@1 + inputs: + PathtoPublish: 'bin/Release/net5.0/publish' + ArtifactName: 'published' + publishLocation: 'Container' \ No newline at end of file diff --git a/test-config.json b/test-config.json new file mode 100644 index 0000000..501902d --- /dev/null +++ b/test-config.json @@ -0,0 +1,8 @@ +{ + "discord": { + "token": "xxx", + "appId": "xxx", + "appSecret": "xxx", + "CommandPrefix": "pc." + } +} \ No newline at end of file