diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6dd4c0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +appsettings.*.json +.env* + +.vs +.vscode +bin +obj \ No newline at end of file diff --git a/Kasbot.csproj b/Kasbot.csproj new file mode 100644 index 0000000..18b178e --- /dev/null +++ b/Kasbot.csproj @@ -0,0 +1,17 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + + diff --git a/Kasbot.sln b/Kasbot.sln new file mode 100644 index 0000000..83cd07c --- /dev/null +++ b/Kasbot.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33213.308 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kasbot", "Kasbot.csproj", "{70A0CD18-5914-4104-A1A1-C531B96FCC20}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {70A0CD18-5914-4104-A1A1-C531B96FCC20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70A0CD18-5914-4104-A1A1-C531B96FCC20}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70A0CD18-5914-4104-A1A1-C531B96FCC20}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70A0CD18-5914-4104-A1A1-C531B96FCC20}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {65C82F99-E6D5-41DC-AA40-7B12A66657BF} + EndGlobalSection +EndGlobal diff --git a/Modules/PublicModule.cs b/Modules/PublicModule.cs new file mode 100644 index 0000000..086e554 --- /dev/null +++ b/Modules/PublicModule.cs @@ -0,0 +1,64 @@ +using Discord; +using Discord.Audio; +using Discord.Commands; +using Kasbot.Services; +using NAudio.Wave; +using TextCommandFramework.Services; +using YoutubeExplode; + +namespace TextCommandFramework.Modules +{ + public class PublicModule : ModuleBase + { + public PictureService PictureService { get; set; } + public PlayerService PlayerService { get; set; } + + [Command("ping")] + [Alias("pong", "hello")] + public Task PingAsync() + => ReplyAsync("pong!"); + + [Command("cat")] + public async Task CatAsync() + { + var stream = await PictureService.GetCatPictureAsync(); + stream.Seek(0, SeekOrigin.Begin); + await Context.Channel.SendFileAsync(stream, "cat.png"); + } + + [Command("echo")] + public Task EchoAsync([Remainder] string text) + => ReplyAsync('\u200B' + text); + + [Command("play", RunMode = RunMode.Async)] + public async Task PlayAsync([Remainder] string text) + { + var user = Context.User; + if (user.IsBot) return; + + string youtubeUrl = text; + IVoiceChannel channel = (Context.User as IVoiceState).VoiceChannel; + if (channel is null) + { + await Context.Channel.SendMessageAsync("You need to be in a voice channel to use this command."); + return; + } + + await PlayerService.Play(Context, text); + } + + [Command("stop", RunMode = RunMode.Async)] + public async Task StopAsync() + { + var user = Context.User; + if (user.IsBot) return; + + await PlayerService.Stop(Context); + } + + [Command("guild_only")] + [RequireContext(ContextType.Guild, ErrorMessage = "Sorry, this command must be ran from within a server, not a DM!")] + public Task GuildOnlyCommand() + => ReplyAsync("Nothing to see here!"); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..d4a7b57 --- /dev/null +++ b/Program.cs @@ -0,0 +1,66 @@ +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using Kasbot.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TextCommandFramework.Services; + +namespace TextCommandFramework +{ + class Program + { + static void Main(string[] args) + { + new Program().MainAsync().GetAwaiter().GetResult(); + } + + public async Task MainAsync() + { + using (var services = ConfigureServices()) + { + var token = Environment.GetEnvironmentVariable("TOKEN"); + + if (token == null) + { + throw new Exception("Discord Bot Token was not found."); + } + + var client = services.GetRequiredService(); + + client.Log += LogAsync; + services.GetRequiredService().Log += LogAsync; + + await client.LoginAsync(TokenType.Bot, token); + await client.StartAsync(); + + await services.GetRequiredService().InitializeAsync(); + + await Task.Delay(Timeout.Infinite); + } + } + + private Task LogAsync(LogMessage log) + { + Console.WriteLine(log.ToString()); + + return Task.CompletedTask; + } + + private ServiceProvider ConfigureServices() + { + return new ServiceCollection() + .AddSingleton(new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent + }) + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .BuildServiceProvider(); + } + } +} diff --git a/Services/CommandHandlingService.cs b/Services/CommandHandlingService.cs new file mode 100644 index 0000000..5709c27 --- /dev/null +++ b/Services/CommandHandlingService.cs @@ -0,0 +1,60 @@ +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Reflection; +using System.Threading.Tasks; + +namespace TextCommandFramework.Services +{ + public class CommandHandlingService + { + private readonly CommandService _commands; + private readonly DiscordSocketClient _discord; + private readonly IServiceProvider _services; + + public CommandHandlingService(IServiceProvider services) + { + _commands = services.GetRequiredService(); + _discord = services.GetRequiredService(); + _services = services; + + _commands.CommandExecuted += CommandExecutedAsync; + _discord.MessageReceived += MessageReceivedAsync; + } + + public async Task InitializeAsync() + { + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), _services); + } + + public async Task MessageReceivedAsync(SocketMessage rawMessage) + { + if (!(rawMessage is SocketUserMessage message)) + return; + if (message.Source != MessageSource.User) + return; + + var argPos = 0; + var prefix = "!"; + + //Check if the message sent has the specified prefix + if (!message.HasStringPrefix(prefix, ref argPos)) return; + + var context = new SocketCommandContext(_discord, message); + await _commands.ExecuteAsync(context, argPos, _services); + } + + public async Task CommandExecutedAsync(Optional command, ICommandContext context, IResult result) + { + if (!command.IsSpecified) + return; + + if (result.IsSuccess) + return; + + await context.Channel.SendMessageAsync($"error: {result}"); + } + } +} diff --git a/Services/PictureService.cs b/Services/PictureService.cs new file mode 100644 index 0000000..5c8e1dd --- /dev/null +++ b/Services/PictureService.cs @@ -0,0 +1,20 @@ +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace TextCommandFramework.Services +{ + public class PictureService + { + private readonly HttpClient _http; + + public PictureService(HttpClient http) + => _http = http; + + public async Task GetCatPictureAsync() + { + var resp = await _http.GetAsync("https://cataas.com/cat"); + return await resp.Content.ReadAsStreamAsync(); + } + } +} diff --git a/Services/PlayerService.cs b/Services/PlayerService.cs new file mode 100644 index 0000000..5852e6a --- /dev/null +++ b/Services/PlayerService.cs @@ -0,0 +1,87 @@ +using Discord; +using Discord.Audio; +using Discord.Commands; +using Discord.WebSocket; +using NAudio.Wave; +using YoutubeExplode; + +namespace Kasbot.Services +{ + public class PlayerService + { + public Dictionary Clients { get; set; } + + public PlayerService() + { + Clients = new Dictionary(); + } + + private async Task DownloadAudioFromYoutube(string youtubeUrl) + { + var youtube = new YoutubeClient(); + var videoId = await youtube.Search.GetVideosAsync(youtubeUrl).FirstOrDefaultAsync(); + var streamInfoSet = await youtube.Videos.Streams.GetManifestAsync(videoId.Id); + var highestAudioStreamInfo = streamInfoSet.GetAudioStreams().OrderByDescending(s => s.Bitrate).FirstOrDefault(); + var streamVideo = await youtube.Videos.Streams.GetAsync(highestAudioStreamInfo); + var memoryStream = new MemoryStream(); + await streamVideo.CopyToAsync(memoryStream); + memoryStream.Position = 0; + return memoryStream; + } + + public async Task Play(SocketCommandContext Context, string arguments) + { + IVoiceChannel channel = (Context.User as IVoiceState).VoiceChannel; + + var audioStream = await DownloadAudioFromYoutube(arguments); + if (audioStream is null) + { + await Context.Channel.SendMessageAsync("Failed to download audio from YouTube."); + return; + } + + var audioClient = await channel.ConnectAsync(); + using (var mp3Reader = new StreamMediaFoundationReader(audioStream)) + { + var audioOut = audioClient.CreatePCMStream(AudioApplication.Music); + await audioClient.SetSpeakingAsync(true); + + var media = new Media + { + AudioClient = audioClient, + Message = Context.Message, + Name = "", + AudioOutStream = audioOut, + }; + Clients.Add(Context.Guild, media); + + await mp3Reader.CopyToAsync(audioOut); + await audioClient.SetSpeakingAsync(false); + } + } + + public async Task Stop(SocketCommandContext Context) + { + if (!Clients.ContainsKey(Context.Guild)) + return; + + var media = Clients[Context.Guild]; + Clients.Remove(Context.Guild); + + await Context.Message.DeleteAsync(); + await media.Message.DeleteAsync(); + await media.AudioOutStream.DisposeAsync(); + await media.AudioOutStream.ClearAsync(new CancellationToken()); + await media.AudioClient.StopAsync(); + } + + } + + public class Media + { + public string Name { get; set; } + public IAudioClient AudioClient { get; set; } + public AudioOutStream AudioOutStream { get; set; } + public SocketUserMessage Message { get; set; } + } +}