diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c2bd2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,352 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ced12cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/runtime:7.0-alpine AS base +WORKDIR /app +RUN apk add --no-cache icu-libs +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false + +FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build +WORKDIR /src +COPY ["Rhea/Rhea.csproj", "Rhea/"] +RUN dotnet restore "Rhea/Rhea.csproj" +COPY . . +WORKDIR "/src/Rhea" +RUN dotnet build "Rhea.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "Rhea.csproj" -c Release -o /app/publish + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Rhea.dll"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c80b769 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Derek Alsop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9b6a29 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Rhea \ No newline at end of file diff --git a/Rhea.sln b/Rhea.sln new file mode 100644 index 0000000..22195ac --- /dev/null +++ b/Rhea.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rhea", "Rhea\Rhea.csproj", "{B5B2CA84-DD0A-4B7C-8657-29E6793CD3C6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B5B2CA84-DD0A-4B7C-8657-29E6793CD3C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5B2CA84-DD0A-4B7C-8657-29E6793CD3C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5B2CA84-DD0A-4B7C-8657-29E6793CD3C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5B2CA84-DD0A-4B7C-8657-29E6793CD3C6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Rhea/Models/TrackContext.cs b/Rhea/Models/TrackContext.cs new file mode 100644 index 0000000..c23ff70 --- /dev/null +++ b/Rhea/Models/TrackContext.cs @@ -0,0 +1,11 @@ +namespace Rhea.Models; + +public class TrackContext +{ + public TrackContext(string requester) + { + Requester = requester; + } + + public string Requester { get; set; } +} \ No newline at end of file diff --git a/Rhea/Modules/BaseModule.cs b/Rhea/Modules/BaseModule.cs new file mode 100644 index 0000000..3b56c23 --- /dev/null +++ b/Rhea/Modules/BaseModule.cs @@ -0,0 +1,27 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Lavalink4NET; +using Lavalink4NET.Player; + +namespace Rhea.Modules; + +public class BaseModule : InteractionModuleBase +{ + private readonly IAudioService lavalink; + + protected BaseModule(IAudioService lavalink) + { + this.lavalink = lavalink; + } + + protected async Task GetPlayer(ulong GuildID, ulong ChannelID) + => lavalink.GetPlayer(GuildID) ?? await lavalink.JoinAsync(GuildID, ChannelID); + + protected string FormatTime(TimeSpan time) + => time.ToString(@"hh\:mm\:ss").TrimStart('0', ':'); + + protected bool IsPrivileged(SocketGuildUser Member) + => Member.GetPermissions(Member.VoiceChannel).MoveMembers || Member.Roles.FirstOrDefault(role => role.Name.ToLower() == "dj") != null || + !Member.VoiceChannel.ConnectedUsers.Any(user => !user.IsBot && user.Id != Member.Id); +} \ No newline at end of file diff --git a/Rhea/Modules/ControlsModule.cs b/Rhea/Modules/ControlsModule.cs new file mode 100644 index 0000000..9d6be35 --- /dev/null +++ b/Rhea/Modules/ControlsModule.cs @@ -0,0 +1,249 @@ +using Discord.Interactions; +using Lavalink4NET; +using Lavalink4NET.Player; + +namespace Rhea.Modules; + +public class ControlsModule : BaseModule +{ + public ControlsModule(IAudioService lavalink) : base(lavalink) { } + + [SlashCommand("resume", "Resume playing")] + public async Task ResumeCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (player.State is not PlayerState.Paused) + { + await RespondAsync("I'm not paused"); + return; + } + + if (!IsPrivileged(member)) + { + await RespondAsync("You must either be alone in the channel, have a role named `DJ` or have the permission `Move Members` to resume the bot."); + return; + } + + await player.ResumeAsync(); + await RespondAsync("▶ **Resumed**"); + } + + [SlashCommand("pause", "Pause playing")] + public async Task PauseCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (player.State is not PlayerState.Playing) + { + await RespondAsync("I'm not playing anything"); + return; + } + + if (!IsPrivileged(member)) + { + await RespondAsync("You must either be alone in the channel, have a role named `DJ` or have the permission `Move Members` to pause the bot."); + return; + } + + await player.PauseAsync(); + await RespondAsync("⏸ **Paused**"); + } + + [SlashCommand("stop", "Stop playing")] + public async Task StopCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (!IsPrivileged(member)) + { + await RespondAsync("You must either be alone in the channel, have a role named `DJ` or have the permission `Move Members` to stop the bot."); + return; + } + + await player.StopAsync(true); + await RespondAsync("🛑 **Stopped and cleared queue**"); + } + + [SlashCommand("skip", "Skip to the next song")] + public async Task SkipCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (!IsPrivileged(member)) + { + var result = await player.VoteAsync(member.Id, 0.66f); + if (result.WasSkipped) + { + await RespondAsync(":fast_forward: **Skipped** :thumbsup:"); + } + else + { + var threshold = result.TotalUsers * result.Percentage; + await RespondAsync($"**Need {Math.Ceiling(threshold)} more votes to skip**"); + } + } + else + { + await player.SkipAsync(); + await RespondAsync(":fast_forward: **Skipped** :thumbsup:"); + } + } + + [SlashCommand("shuffle", "Shuffle the queue")] + public async Task ShuffleCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + player.Queue.Shuffle(); + await RespondAsync("🔀 **Queue shuffled**"); + } + + [SlashCommand("seek", "Seek to a timestamp in the current track")] + public async Task SeekCommand([Summary(description: "The timestamp to seek to")] string timestamp) + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (player.State is not PlayerState.Playing) + { + await RespondAsync("I'm not playing anything."); + return; + } + + if (!player.CurrentTrack!.IsSeekable) + { + await RespondAsync("You can't seek on the current track."); + return; + } + + var parts = timestamp.Split(':'); + var parsedTimestamp = parts.Select(part => part.Length == 1 + ? $"0{part}" + : part) + .ToList(); + + for (var i = parsedTimestamp.Count; i < 3; i++) + { + parsedTimestamp.Insert(0, "00"); + } + + var ts = TimeSpan.Parse(string.Join(':', parsedTimestamp)); + + await player.SeekPositionAsync(ts); + await RespondAsync($"**Seeked to** `{FormatTime(ts)}`"); + } + + [SlashCommand("loop", "Set whether or not the queue should loop")] + public async Task LoopCommand( + [Summary(description: "Loop mode"), Choice("Disable", (int)PlayerLoopMode.None), Choice("Track", (int)PlayerLoopMode.Track), + Choice("Queue", (int)PlayerLoopMode.Queue)] + int mode) + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + player.LoopMode = (PlayerLoopMode)mode; + switch ((PlayerLoopMode)mode) + { + case PlayerLoopMode.None: + await RespondAsync("🔂 **Loop disabled**"); + break; + case PlayerLoopMode.Track: + await RespondAsync("🔂 **Loop track**"); + break; + case PlayerLoopMode.Queue: + await RespondAsync("🔂 **Loop queue**"); + break; + } + } +} \ No newline at end of file diff --git a/Rhea/Modules/MediaModule.cs b/Rhea/Modules/MediaModule.cs new file mode 100644 index 0000000..76aeca6 --- /dev/null +++ b/Rhea/Modules/MediaModule.cs @@ -0,0 +1,224 @@ +using Discord; +using Discord.Interactions; +using Lavalink4NET; +using Lavalink4NET.Artwork; +using Lavalink4NET.Player; +using Lavalink4NET.Rest; +using Rhea.Models; + +namespace Rhea.Modules; + +public class MediaModule : BaseModule +{ + private readonly IAudioService lavalink; + private readonly IArtworkService artwork; + + public MediaModule(IAudioService lavalink, IArtworkService artwork) : base(lavalink) + { + this.lavalink = lavalink; + this.artwork = artwork; + } + + [SlashCommand("play", "Play some music")] + public async Task Play([Summary(description: "A search term or url")] string search) + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + await DeferAsync(); + + var searchResponse = await lavalink.LoadTracksAsync(search, Uri.IsWellFormedUriString(search, UriKind.Absolute) + ? SearchMode.None + : SearchMode.YouTube); + + if (searchResponse.LoadType is TrackLoadType.LoadFailed or TrackLoadType.NoMatches) + { + await ModifyOriginalResponseAsync(properties => properties.Content = $"Unable to find anything for `{Format.Sanitize(search)}`"); + return; + } + + if (!string.IsNullOrWhiteSpace(searchResponse.PlaylistInfo?.Name)) + { + player.Queue.AddRange(searchResponse.Tracks!.Select(lavalinkTrack => + { + lavalinkTrack.Context = new TrackContext($"{Context.User.Username}#{Context.User.Discriminator}"); + return lavalinkTrack; + })); + var embed = new EmbedBuilder() + .WithAuthor("Queued Playlist") + .WithTitle(searchResponse.PlaylistInfo.Name) + .WithUrl(search) + .AddField("Tracks", searchResponse.Tracks!.Count(), true) + .AddField("Playlist length", FormatTime(new TimeSpan(searchResponse.Tracks!.Sum(t => t.Duration.Ticks))), true) + .WithColor(Color.Blue) + .WithFooter($"{Context.User.Username}#{Context.User.Discriminator}", Context.User.GetAvatarUrl()).Build(); + + if (player.State is not PlayerState.Playing or PlayerState.Paused && player.Queue.TryDequeue(out var track)) await player.PlayAsync(track!, enqueue: false); + + await ModifyOriginalResponseAsync(properties => properties.Embed = embed); + } + else + { + var track = searchResponse.Tracks!.First(); + track.Context = new TrackContext($"{Context.User.Username}#{Context.User.Discriminator}"); + + var art = await artwork.ResolveAsync(track); + + if (player.State is PlayerState.Playing or PlayerState.Paused) + { + var embed = new EmbedBuilder() + .WithAuthor("Queued Track") + .WithTitle(track.Title) + .WithUrl(track.Uri!.AbsoluteUri) + .AddField("Channel", track.Author, true) + .AddField("Duration", track.IsLiveStream + ? "Live stream" + : FormatTime(track.Duration), true) + .AddField("Time until playing", + FormatTime(new TimeSpan(player.Queue.Sum(t => t.Duration.Ticks) + player.CurrentTrack!.Duration.Ticks - player.Position.Position.Ticks)), + true) + .AddField("Queue position", player.Queue.Count + 1) + .WithColor(Color.Blue) + .WithFooter($"{Context.User.Username}#{Context.User.Discriminator}", Context.User.GetAvatarUrl()); + + if (art != null) embed.WithThumbnailUrl(art.AbsoluteUri); + + player.Queue.Add(track); + + await ModifyOriginalResponseAsync(m => m.Embed = embed.Build()); + } + else + { + var embed = new EmbedBuilder() + .WithAuthor("Now Playing") + .WithTitle(track.Title) + .WithUrl(track.Uri!.AbsoluteUri) + .AddField("Channel", track.Author, true) + .AddField("Duration", track.IsLiveStream + ? "Live stream" + : FormatTime(track.Duration), true) + .WithColor(Color.Green) + .WithFooter($"{Context.User.Username}#{Context.User.Discriminator}", Context.User.GetAvatarUrl()); + + if (art != null) embed.WithThumbnailUrl(art.AbsoluteUri); + + await player.PlayAsync(track); + + await ModifyOriginalResponseAsync(properties => properties.Embed = embed.Build()); + } + } + } + + + [SlashCommand("np", "Show what is currently playing")] + public async Task NowPlayingCommand() + { + var member = Context.Guild.GetUser(Context.User.Id); + if (member.VoiceState == null) + { + await RespondAsync("You must be in a voice channel to run this command.", ephemeral: true); + return; + } + + var player = await GetPlayer(Context.Guild.Id, member.VoiceChannel.Id); + + if (player.VoiceChannelId != member.VoiceChannel.Id) + { + await RespondAsync("You must be in the same voice channel as me to run this command.", ephemeral: true); + return; + } + + if (player.State is not PlayerState.Playing or PlayerState.Paused) + { + await RespondAsync("I'm not playing anything"); + return; + } + + var bar = ""; + + if (player.CurrentTrack!.IsLiveStream) + { + bar = "Live stream"; + } + else + { + var progress = (int)Math.Floor((decimal)player.Position.Position.Ticks / player.CurrentTrack!.Duration.Ticks * 100 / 4); + if (progress - 1 > 0) bar += new string('▬', progress - 1); + bar += "🔘"; + bar += new string('▬', 25 - progress); + bar += $"\n\n{FormatTime(player.Position.Position)} / {FormatTime(player.CurrentTrack.Duration)}"; + } + + var art = await artwork.ResolveAsync(player.CurrentTrack); + + var embed = new EmbedBuilder() + .WithTitle("Currently playing") + .WithDescription( + $"[{player.CurrentTrack.Title}]({player.CurrentTrack.Uri})\n\n{bar}") + .WithColor(Color.Blue); + + if (art != null) embed.WithThumbnailUrl(art.AbsoluteUri); + + await RespondAsync(embed: embed.Build()); + } + + private Embed QueueEmbed(VoteLavalinkPlayer player, int page = 0) + { + string loop; + switch (player.LoopMode) + { + case PlayerLoopMode.Track: + loop = "Looping Track"; + break; + case PlayerLoopMode.Queue: + loop = "Looping Queue"; + break; + case PlayerLoopMode.None: + default: + loop = "Not Looping"; + break; + } + + var pages = Math.Ceiling((decimal)player.Queue.Count / 10); + if (page * 10 > player.Queue.Count) page = (int)pages - 1; + + var tracks = player.Queue.Skip(page * 10).Take(10).ToList(); + var embed = new EmbedBuilder() + .AddField("Currently Playing", + player.CurrentTrack != null + ? $"{player.CurrentTrack!.Title} | {FormatTime(player.Position.Position)}/{FormatTime(player.CurrentTrack.Duration)} | {((TrackContext)player.CurrentTrack.Context!).Requester}" + : "Nothing playing") + .AddField("Up Next", string.Join("\n", tracks.Select(track => $"{track.Title} | {FormatTime(track.Duration)} | {((TrackContext)track.Context!).Requester}"))) + .WithFooter($"{player.Queue.Count:N0} Tracks | {loop}") + .WithColor(Color.Blue) + .Build(); + + return embed; + } + + [SlashCommand("queue", "Show what's in queue")] + public async Task QueueCommand() + { + var player = lavalink.GetPlayer(Context.Guild.Id); + if (player == null || player.Queue.IsEmpty) + { + await RespondAsync("Nothing in queue"); + return; + } + + var embed = QueueEmbed(player); + await RespondAsync(embed: embed); + } +} \ No newline at end of file diff --git a/Rhea/Modules/MiscModule.cs b/Rhea/Modules/MiscModule.cs new file mode 100644 index 0000000..009e23e --- /dev/null +++ b/Rhea/Modules/MiscModule.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Discord; +using Discord.Interactions; + +namespace Rhea.Modules; + +public class MiscModule : InteractionModuleBase +{ + [SlashCommand("about", "Information about the bot")] + public async Task AboutCommand() + { + var library = Assembly.GetAssembly(typeof(InteractionModuleBase))!.GetName(); + var korrdyn = Context.Client.GetUser(160168328520794112); + var embed = new EmbedBuilder().WithAuthor(Context.Client.CurrentUser.Username, Context.Client.CurrentUser.GetAvatarUrl()) + .AddField("Guilds", Context.Client.Guilds.Count.ToString("N0"), true) + .AddField("Users", Context.Client.Guilds.Select(guild => guild.MemberCount).Sum().ToString("N0"), true) + .AddField("Library", $"Discord.Net {library.Version!.ToString()}", true) + .AddField("Developer", $"{korrdyn.Username}#{korrdyn.Discriminator}", true) + .AddField("Links", $"[GitHub](https://github.com/Korrdyn/Rhea)\n[Support](https://discord.gg/{Environment.GetEnvironmentVariable("DISCORD_INVITE")})\n[Patreon](https://patreon.com/Korrdyn)", true) + .WithColor(Color.Blue) + .WithCurrentTimestamp() + .Build(); + + await RespondAsync(embed: embed); + } + + [SlashCommand("invite", "Invite the bot")] + public async Task InviteCommand() + => await RespondAsync( + $"https://discord.com/api/oauth2/authorize?client_id={Context.Client.CurrentUser.Id}&scope=bot%20applications.commands"); +} \ No newline at end of file diff --git a/Rhea/Program.cs b/Rhea/Program.cs new file mode 100644 index 0000000..77cc356 --- /dev/null +++ b/Rhea/Program.cs @@ -0,0 +1,220 @@ +using System.Reflection; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Lavalink4NET; +using Lavalink4NET.Artwork; +using Lavalink4NET.DiscordNet; +using Lavalink4NET.Tracking; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; + +namespace Rhea; + +public class Program +{ + private readonly IServiceProvider serviceProvider; + + private Program() + { + serviceProvider = CreateProvider(); + } + + static void Main() + { + try + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + new Program().RunAsync().GetAwaiter().GetResult(); + } + catch (Exception error) + { + Log.Error(error, "Error from main"); + } + finally + { + Log.CloseAndFlush(); + } + } + + static IServiceProvider CreateProvider() + { + var collection = new ServiceCollection() + .AddSingleton(new LoggerFactory()) + .AddSingleton(new DiscordSocketConfig + { + GatewayIntents = GatewayIntents.GuildVoiceStates | GatewayIntents.GuildMembers | GatewayIntents.Guilds + }) + .AddSingleton() + .AddSingleton(new InteractionServiceConfig()) + .AddSingleton(x => new InteractionService(x.GetRequiredService())) + .AddSingleton() + .AddSingleton() + .AddSingleton(new LavalinkNodeOptions + { + RestUri = $"http://{Environment.GetEnvironmentVariable("LAVALINK_HOST")}/", + WebSocketUri = $"ws://{Environment.GetEnvironmentVariable("LAVALINK_HOST")}/", + Password = Environment.GetEnvironmentVariable("LAVALINK_AUTH")!, + ResumeKey = "Rhea" + }) + .AddSingleton() + .AddSingleton(new InactivityTrackingOptions { + DisconnectDelay = TimeSpan.FromSeconds(10), + PollInterval = TimeSpan.FromSeconds(4), + TrackInactivity = true + }) + .AddSingleton(); + + return collection.BuildServiceProvider(); + } + + async Task RunAsync() + { + var client = serviceProvider.GetRequiredService(); + var handler = serviceProvider.GetRequiredService(); + var lavalink = serviceProvider.GetRequiredService(); + + await handler.AddModulesAsync(Assembly.GetEntryAssembly(), serviceProvider); + + client.Log += LogAsync; + handler.Log += LogAsync; + + client.Ready += async () => + { + await lavalink.InitializeAsync(); + Log.Information("[Lavalink] Connected"); + if (IsDebug()) + await handler.RegisterCommandsToGuildAsync(ulong.Parse(Environment.GetEnvironmentVariable("DEV_GUILD")!)); + else + await handler.RegisterCommandsGloballyAsync(); + }; + + client.InteractionCreated += async interaction => + { + try + { + var context = new SocketInteractionContext(client, interaction); + + await handler.ExecuteCommandAsync(context, serviceProvider); + } + catch + { + if (interaction.Type is InteractionType.ApplicationCommand) + await interaction.GetOriginalResponseAsync().ContinueWith(async msg => await msg.Result.DeleteAsync()); + } + }; + + handler.SlashCommandExecuted += SlashCommandExecuted; + + + await client.LoginAsync(TokenType.Bot, Environment.GetEnvironmentVariable("TOKEN")); + await client.StartAsync(); + + await Task.Delay(Timeout.Infinite); + } + + private static async Task LogAsync(LogMessage message) + { + var severity = message.Severity switch + { + LogSeverity.Critical => LogEventLevel.Fatal, + LogSeverity.Error => LogEventLevel.Error, + LogSeverity.Warning => LogEventLevel.Warning, + LogSeverity.Info => LogEventLevel.Information, + LogSeverity.Verbose => LogEventLevel.Verbose, + LogSeverity.Debug => LogEventLevel.Debug, + _ => LogEventLevel.Information + }; + Log.Write(severity, message.Exception, "[{Source}] {Message}", message.Source, message.Message); + await Task.CompletedTask; + } + + private static async Task SlashCommandExecuted(SlashCommandInfo command, IInteractionContext context, IResult result) + { + if (!result.IsSuccess) + { + Log.Warning("[Command] {ContextUser} tried to run {CommandName} but ran into {S}", context.User, command.Name, result.Error.ToString()); + var embed = new EmbedBuilder + { + Color = new Color(0x2F3136) + }; + switch (result.Error) + { + case InteractionCommandError.BadArgs: + embed.Title = "Invalid Arguments"; + embed.Description = + "Please make sure the arguments you're providing are correct.\nIf you keep running into this message, please join the support server"; + break; + case InteractionCommandError.ConvertFailed: + case InteractionCommandError.Exception: + embed.Title = "Error Occurred"; + embed.Description = "I ran into a problem running your command.\nIf it continues to happen join the support server"; + break; + case InteractionCommandError.UnmetPrecondition: + embed.Title = "Missing Permissions"; + embed.Description = result.ErrorReason; + break; + default: + embed.Title = "Something Happened"; + embed.Description = "I was unable to run your command.\nIf it continues to happen join the support server"; + break; + } + + if (context.Interaction.HasResponded) + await context.Interaction.ModifyOriginalResponseAsync(m => m.Embed = embed.Build()); + else + await context.Interaction.RespondAsync(embed: embed.Build(), ephemeral: true); + } + else + { + var guild = context.Interaction.GuildId == null + ? "DM" + : $"{context.Guild.Name} ({context.Guild.Id}) #{context.Channel.Name} ({context.Channel.Id})"; + Log.Information( + $"[Command] {guild} {context.User.Username}#{context.User.Discriminator} ({context.User.Id}) ran /{(string.IsNullOrEmpty(command.Module.Parent?.SlashGroupName) ? string.Empty : command.Module.Parent.SlashGroupName + ' ')}{(string.IsNullOrEmpty(command.Module.SlashGroupName) ? string.Empty : command.Module.SlashGroupName + ' ')}{command.Name} {ParseArgs(((SocketSlashCommandData)context.Interaction.Data).Options)}"); + + } + } + + private static string ParseArgs(IEnumerable data) + { + List args = new(); + + foreach (var option in data) + { + switch (option.Type) + { + case ApplicationCommandOptionType.SubCommand: + case ApplicationCommandOptionType.SubCommandGroup: + args.Add(ParseArgs(option.Options)); + break; + case ApplicationCommandOptionType.Channel: + case ApplicationCommandOptionType.Role: + case ApplicationCommandOptionType.User: + args.Add($"{option.Name}:{((ISnowflakeEntity)option.Value).Id.ToString()}"); + break; + default: + args.Add($"{option.Name}:{option.Value}"); + break; + } + + } + + return string.Join(' ', args); + } + + private static bool IsDebug() + { +#if DEBUG + return true; +#else + return false; +#endif + } +} \ No newline at end of file diff --git a/Rhea/Rhea.csproj b/Rhea/Rhea.csproj new file mode 100644 index 0000000..0a977b1 --- /dev/null +++ b/Rhea/Rhea.csproj @@ -0,0 +1,26 @@ + + + + Exe + net7.0 + enable + enable + Linux + + + + + .dockerignore + + + + + + + + + + + + +