Initial commit
Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
This commit is contained in:
commit
db55ca7fb1
15 changed files with 2519 additions and 0 deletions
1766
.editorconfig
Normal file
1766
.editorconfig
Normal file
File diff suppressed because it is too large
Load diff
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
.idea/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
16
Cassette.sln
Normal file
16
Cassette.sln
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cassette", "Cassette\Cassette.csproj", "{74933F3C-F5CA-4592-8C4B-76329F1ED311}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{74933F3C-F5CA-4592-8C4B-76329F1ED311}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{74933F3C-F5CA-4592-8C4B-76329F1ED311}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{74933F3C-F5CA-4592-8C4B-76329F1ED311}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{74933F3C-F5CA-4592-8C4B-76329F1ED311}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
83
Cassette/Cassette.cs
Normal file
83
Cassette/Cassette.cs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
using Lavalink4NET.Remora.Discord;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Cassette.Commands;
|
||||||
|
using Remora.Commands.Extensions;
|
||||||
|
using Remora.Discord.API.Abstractions.Gateway.Commands;
|
||||||
|
using Remora.Discord.Commands.Extensions;
|
||||||
|
using Remora.Discord.Commands.Services;
|
||||||
|
using Remora.Discord.Gateway;
|
||||||
|
using Remora.Discord.Hosting.Extensions;
|
||||||
|
|
||||||
|
namespace Cassette;
|
||||||
|
|
||||||
|
file class Cassette
|
||||||
|
{
|
||||||
|
public static async Task Main(string[] args)
|
||||||
|
{
|
||||||
|
var host = CreateHostBuilder(args)
|
||||||
|
.UseConsoleLifetime()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = host.Services;
|
||||||
|
var log = services.GetRequiredService<ILogger<Cassette>>();
|
||||||
|
|
||||||
|
var slashService = services.GetRequiredService<SlashService>();
|
||||||
|
var updateSlash = await slashService.UpdateSlashCommandsAsync();
|
||||||
|
if (!updateSlash.IsSuccess)
|
||||||
|
{
|
||||||
|
log.LogWarning("Failed to update slash commands: {Reason}", updateSlash.Error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await host.RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IHostBuilder CreateHostBuilder(string[] args)
|
||||||
|
{
|
||||||
|
return Host.CreateDefaultBuilder(args)
|
||||||
|
.AddDiscordService
|
||||||
|
(
|
||||||
|
services =>
|
||||||
|
{
|
||||||
|
var configuration = services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
|
return configuration.GetValue<string?>("BOT_TOKEN") ??
|
||||||
|
throw new InvalidOperationException
|
||||||
|
(
|
||||||
|
"No bot token has been provided. " +
|
||||||
|
"Set the BOT_TOKEN environment variable to a valid token."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.ConfigureServices
|
||||||
|
(
|
||||||
|
(_, services) =>
|
||||||
|
{
|
||||||
|
services.Configure<DiscordGatewayClientOptions>(g =>
|
||||||
|
{
|
||||||
|
g.Intents
|
||||||
|
|= GatewayIntents.MessageContents
|
||||||
|
| GatewayIntents.GuildVoiceStates
|
||||||
|
| GatewayIntents.Guilds;
|
||||||
|
});
|
||||||
|
services
|
||||||
|
.AddDiscordCommands(true, false)
|
||||||
|
.AddLavalink()
|
||||||
|
.AddCommandTree()
|
||||||
|
.WithCommandGroup<BotCommandGroup>()
|
||||||
|
.WithCommandGroup<ControlsCommandGroup>()
|
||||||
|
.WithCommandGroup<InfoCommandGroup>()
|
||||||
|
.WithCommandGroup<TrustedCommandGroup>();
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.ConfigureLogging
|
||||||
|
(
|
||||||
|
c => c
|
||||||
|
.AddConsole()
|
||||||
|
.AddFilter("System.Net.Http.HttpClient.*.LogicalHandler", LogLevel.Warning)
|
||||||
|
.AddFilter("System.Net.Http.HttpClient.*.ClientHandler", LogLevel.Warning)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
22
Cassette/Cassette.csproj
Normal file
22
Cassette/Cassette.csproj
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>Cassette</AssemblyName>
|
||||||
|
<RootNamespace>Cassette</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="JetBrains.Annotations" Version="2023.3.0" />
|
||||||
|
<PackageReference Include="Lavalink4NET.Remora.Discord" Version="4.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Remora.Commands" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Remora.Discord.Extensions" Version="5.3.2" />
|
||||||
|
<PackageReference Include="Remora.Discord.Hosting" Version="6.0.7" />
|
||||||
|
<PackageReference Include="Remora.Discord.Interactivity" Version="4.5.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
61
Cassette/Commands/BotCommandGroup.cs
Normal file
61
Cassette/Commands/BotCommandGroup.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Text;
|
||||||
|
using Cassette.Extensions;
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
using Remora.Commands.Attributes;
|
||||||
|
using Remora.Commands.Groups;
|
||||||
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
|
using Remora.Discord.API.Abstractions.Rest;
|
||||||
|
using Remora.Discord.API.Objects;
|
||||||
|
using Remora.Discord.Commands.Feedback.Messages;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
using Remora.Discord.Extensions.Embeds;
|
||||||
|
using Remora.Results;
|
||||||
|
|
||||||
|
namespace Cassette.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="CommandGroup"/> that shows information about the bot.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BotCommandGroup(
|
||||||
|
IFeedbackService feedbackService,
|
||||||
|
IDiscordRestUserAPI userApi) : CommandGroup
|
||||||
|
{
|
||||||
|
private const string RepositoryUrl = "https://git.mctaylors.ru/mctaylors/Cassette";
|
||||||
|
|
||||||
|
[Command("about")]
|
||||||
|
[Description("Who developed this bot?")]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> AboutCommandAsync()
|
||||||
|
{
|
||||||
|
var botResult = await userApi.GetCurrentUserAsync();
|
||||||
|
if (!botResult.IsDefined(out var bot))
|
||||||
|
{
|
||||||
|
return Result.FromError(botResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
var description = new StringBuilder()
|
||||||
|
.AppendLine("A Discord bot written by [@mctaylors](https://git.mctaylors.ru/mctaylors) " +
|
||||||
|
"that plays your music in your voice channels.")
|
||||||
|
.AppendLine("Powered by Remora.Discord & Lavalink4NET.");
|
||||||
|
|
||||||
|
var embed = new EmbedBuilder()
|
||||||
|
.WithUserAuthor($"About {bot.Username}", bot)
|
||||||
|
.WithDescription(description.ToString())
|
||||||
|
.WithColour(Color.Teal)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var button = new ButtonComponent(
|
||||||
|
ButtonComponentStyle.Link,
|
||||||
|
"Open Repository",
|
||||||
|
URL: RepositoryUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualEmbedResult(embed,
|
||||||
|
new FeedbackMessageOptions(MessageComponents: new[]
|
||||||
|
{
|
||||||
|
new ActionRowComponent(new[] { button })
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
194
Cassette/Commands/ControlsCommandGroup.cs
Normal file
194
Cassette/Commands/ControlsCommandGroup.cs
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using Cassette.Extensions;
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
using Lavalink4NET;
|
||||||
|
using Lavalink4NET.Rest.Entities.Tracks;
|
||||||
|
using Remora.Commands.Attributes;
|
||||||
|
using Remora.Commands.Groups;
|
||||||
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
|
using Remora.Discord.Commands.Attributes;
|
||||||
|
using Remora.Discord.Commands.Conditions;
|
||||||
|
using Remora.Discord.Commands.Contexts;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
using Remora.Results;
|
||||||
|
|
||||||
|
namespace Cassette.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="CommandGroup"/> that controls the music player.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ControlsCommandGroup(
|
||||||
|
ICommandContext commandContext,
|
||||||
|
IAudioService audioService,
|
||||||
|
FeedbackService feedbackService) : CommandGroup
|
||||||
|
{
|
||||||
|
[Command("play")]
|
||||||
|
[Description("Plays or adds the requested track to the queue")]
|
||||||
|
[RequireBotDiscordPermissions(DiscordPermission.Connect)]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> PlayCommandAsync(
|
||||||
|
[Description("URL or YouTube query")] string query)
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(commandContext, audioService, feedbackService, true);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = await audioService.Tracks.LoadTrackAsync(query, TrackSearchMode.YouTube);
|
||||||
|
if (track is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"Not found.", feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
await player.PlayAsync(track);
|
||||||
|
|
||||||
|
var message = new StringBuilder().Append($"Added {track.Display()}");
|
||||||
|
|
||||||
|
if (player.Queue.IsEmpty)
|
||||||
|
{
|
||||||
|
message.Append(" to begin playing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.Queue.IsEmpty)
|
||||||
|
{
|
||||||
|
message.Append(" to the queue at position ").Append(player.Queue.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
message.ToString(), feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("pause")]
|
||||||
|
[Description("Pauses the current track")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> PauseCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.IsPaused)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"Player is currently paused",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
await player.PauseAsync();
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Paused {player.CurrentTrack.Display()}", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("resume")]
|
||||||
|
[Description("Resumes the current track")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> ResumeCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!player.IsPaused)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"Player is currently not paused.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
await player.ResumeAsync();
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Resumed {player.CurrentTrack.Display()}", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("seek")]
|
||||||
|
[Description("Rewinds the current track to a specific position")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> SeekCommandAsync(
|
||||||
|
[Description("Position to rewind")] TimeSpan position)
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = player.CurrentTrack;
|
||||||
|
|
||||||
|
await player.SeekAsync(position);
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"{track.Display()} rewound to {position.ReadableDuration()}", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("skip")]
|
||||||
|
[Description("Skips the current track")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> SkipCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = player.CurrentTrack;
|
||||||
|
|
||||||
|
await player.SkipAsync();
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Skipped {track.Display()}", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
}
|
114
Cassette/Commands/InfoCommandGroup.cs
Normal file
114
Cassette/Commands/InfoCommandGroup.cs
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using Cassette.Extensions;
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
using Lavalink4NET;
|
||||||
|
using Lavalink4NET.Players.Queued;
|
||||||
|
using Remora.Discord.Commands.Contexts;
|
||||||
|
using Remora.Commands.Attributes;
|
||||||
|
using Remora.Commands.Groups;
|
||||||
|
using Remora.Discord.Commands.Attributes;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
using Remora.Discord.Extensions.Formatting;
|
||||||
|
using Remora.Results;
|
||||||
|
|
||||||
|
namespace Cassette.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="CommandGroup"/> that shows information about the music player.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InfoCommandGroup(
|
||||||
|
ICommandContext commandContext,
|
||||||
|
IAudioService audioService,
|
||||||
|
FeedbackService feedbackService) : CommandGroup
|
||||||
|
{
|
||||||
|
[Command("nowplaying")]
|
||||||
|
[Description("Shows the currently playing track")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> NowPlayingCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTrack = player.CurrentTrack;
|
||||||
|
if (currentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"You're listening to {currentTrack.Display(true)}", feedbackService.Theme.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("queue")]
|
||||||
|
[Description("Shows the current track queue")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> QueueCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
|
||||||
|
var getResult = await GetQueueAsync(player, builder);
|
||||||
|
if (!getResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return Result.FromError(getResult.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(builder.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Result> GetQueueAsync(IQueuedLavalinkPlayer player, StringBuilder builder)
|
||||||
|
{
|
||||||
|
var queue = player.Queue;
|
||||||
|
if (queue.IsEmpty)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing in queue right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < queue.Count; i++)
|
||||||
|
{
|
||||||
|
var track = queue[i].Track;
|
||||||
|
if (track is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess(); // dunno
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append($"{i + 1}. ").Append(track.Display(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("volume")]
|
||||||
|
[Description("Shows the current volume of the music player")]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> VolumeCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Current volume is {Markdown.Bold($"{player.Volume * 100}%")}", feedbackService.Theme.Text);
|
||||||
|
}
|
||||||
|
}
|
98
Cassette/Commands/TrustedCommandGroup.cs
Normal file
98
Cassette/Commands/TrustedCommandGroup.cs
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using Cassette.Extensions;
|
||||||
|
using JetBrains.Annotations;
|
||||||
|
using Lavalink4NET;
|
||||||
|
using Remora.Discord.Commands.Contexts;
|
||||||
|
using Remora.Commands.Attributes;
|
||||||
|
using Remora.Commands.Groups;
|
||||||
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
|
using Remora.Discord.Commands.Attributes;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
using Remora.Discord.Extensions.Formatting;
|
||||||
|
using Remora.Results;
|
||||||
|
|
||||||
|
namespace Cassette.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <see cref="CommandGroup"/> that controls the music player and requires higher permissions.
|
||||||
|
/// <remarks>Trusted role support is not implemented yet.</remarks>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TrustedCommandGroup(
|
||||||
|
ICommandContext commandContext,
|
||||||
|
IAudioService audioService,
|
||||||
|
FeedbackService feedbackService) : CommandGroup
|
||||||
|
{
|
||||||
|
private const DiscordPermission RequiredPermission = DiscordPermission.MuteMembers;
|
||||||
|
|
||||||
|
[Command("setvolume")]
|
||||||
|
[Description("Adjusts the volume of the music player")]
|
||||||
|
[DiscordDefaultMemberPermissions(RequiredPermission)]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> SetVolumeCommandAsync(
|
||||||
|
[MinValue(1)] [MaxValue(200)] float percentage)
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
var previousVolume = player.Volume / 100;
|
||||||
|
await player.SetVolumeAsync(percentage / 100);
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Volume changed {Markdown.Bold($"{previousVolume}%")} to {Markdown.Bold($"{percentage}%")}",
|
||||||
|
feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("stop")]
|
||||||
|
[Description("Stops the current track and clears the queue")]
|
||||||
|
[DiscordDefaultMemberPermissions(RequiredPermission)]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> StopCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.CurrentTrack is null)
|
||||||
|
{
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"There's nothing playing right now.",
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
}
|
||||||
|
|
||||||
|
var track = player.CurrentTrack;
|
||||||
|
|
||||||
|
await player.StopAsync();
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
$"Stopped {track.Display()} and cleared the queue.", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command("disconnect")]
|
||||||
|
[Description("Disconnects the bot and clears the queue")]
|
||||||
|
[DiscordDefaultMemberPermissions(RequiredPermission)]
|
||||||
|
[DiscordDefaultDMPermission(false)]
|
||||||
|
[UsedImplicitly]
|
||||||
|
public async Task<Result> DisconnectCommandAsync()
|
||||||
|
{
|
||||||
|
var player = await LavalinkPlayer.GetPlayerAsync(
|
||||||
|
commandContext, audioService, feedbackService);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
return Result.FromSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
await player.DisconnectAsync();
|
||||||
|
|
||||||
|
return await feedbackService.SendContextualMessageResult(
|
||||||
|
"Disconnected.", feedbackService.Theme.Success);
|
||||||
|
}
|
||||||
|
}
|
22
Cassette/Extensions/EmbedBuilderExtensions.cs
Normal file
22
Cassette/Extensions/EmbedBuilderExtensions.cs
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
using Remora.Discord.API;
|
||||||
|
using Remora.Discord.API.Abstractions.Objects;
|
||||||
|
using Remora.Discord.Extensions.Embeds;
|
||||||
|
|
||||||
|
namespace Cassette.Extensions;
|
||||||
|
|
||||||
|
public static class EmbedBuilderExtensions
|
||||||
|
{
|
||||||
|
public static EmbedBuilder WithUserAuthor(
|
||||||
|
this EmbedBuilder builder, string name, IUser? user, string? url = null)
|
||||||
|
{
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return builder.WithAuthor(name, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconResult = CDN.GetUserAvatarUrl(user);
|
||||||
|
return builder.WithAuthor(name, url, !iconResult.IsDefined(out var iconUri)
|
||||||
|
? CDN.GetDefaultUserAvatarUrl(user).Entity.AbsoluteUri
|
||||||
|
: iconUri.AbsoluteUri);
|
||||||
|
}
|
||||||
|
}
|
30
Cassette/Extensions/FeedbackServiceExtensions.cs
Normal file
30
Cassette/Extensions/FeedbackServiceExtensions.cs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using Remora.Discord.API.Objects;
|
||||||
|
using Remora.Discord.Commands.Feedback.Messages;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
using Remora.Results;
|
||||||
|
|
||||||
|
namespace Cassette.Extensions;
|
||||||
|
|
||||||
|
public static class FeedbackServiceExtensions
|
||||||
|
{
|
||||||
|
public static async Task<Result> SendContextualMessageResult(
|
||||||
|
this IFeedbackService feedbackService, string message, Color? color = null)
|
||||||
|
{
|
||||||
|
return (Result)await feedbackService.SendContextualMessageAsync(
|
||||||
|
new FeedbackMessage(message, color ?? feedbackService.Theme.Secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Result> SendContextualEmbedResult(
|
||||||
|
this IFeedbackService feedbackService, Result<Embed> embedResult,
|
||||||
|
FeedbackMessageOptions? options = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!embedResult.IsDefined(out var embed))
|
||||||
|
{
|
||||||
|
return Result.FromError(embedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Result)await feedbackService.SendContextualEmbedAsync(embed, options, ct);
|
||||||
|
// @Octol1ttle i didn't stole that i swear
|
||||||
|
}
|
||||||
|
}
|
37
Cassette/Extensions/LavalinkTrackExtensions.cs
Normal file
37
Cassette/Extensions/LavalinkTrackExtensions.cs
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Lavalink4NET.Tracks;
|
||||||
|
using Remora.Discord.Extensions.Formatting;
|
||||||
|
|
||||||
|
namespace Cassette.Extensions;
|
||||||
|
|
||||||
|
public static class LavalinkTrackExtensions
|
||||||
|
{
|
||||||
|
public static string Display(this LavalinkTrack track, bool detailed = false)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
|
||||||
|
var titleAppend = track.Title;
|
||||||
|
if (track is { Title: "Unknown title", Uri: not null, Provider: StreamProvider.Http })
|
||||||
|
{
|
||||||
|
titleAppend = WebUtility.UrlDecode(track.Uri.Segments.Last());
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(Markdown.Bold(Markdown.Sanitize(titleAppend)));
|
||||||
|
|
||||||
|
if (!detailed)
|
||||||
|
{
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.Author is not "Unknown artist")
|
||||||
|
{
|
||||||
|
builder.Append(" by ").Append(Markdown.Sanitize(track.Author));
|
||||||
|
}
|
||||||
|
|
||||||
|
var duration = track.Duration;
|
||||||
|
builder.Append($" ({duration.ReadableDuration()})");
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
9
Cassette/Extensions/TimeSpanExtensions.cs
Normal file
9
Cassette/Extensions/TimeSpanExtensions.cs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
namespace Cassette.Extensions;
|
||||||
|
|
||||||
|
public static class TimeSpanExtensions
|
||||||
|
{
|
||||||
|
public static string ReadableDuration(this TimeSpan duration)
|
||||||
|
{
|
||||||
|
return $"{duration.TotalMinutes:N0}:{duration.Seconds:00}";
|
||||||
|
}
|
||||||
|
}
|
40
Cassette/LavalinkPlayer.cs
Normal file
40
Cassette/LavalinkPlayer.cs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
using Cassette.Extensions;
|
||||||
|
using Lavalink4NET;
|
||||||
|
using Lavalink4NET.Players;
|
||||||
|
using Lavalink4NET.Players.Queued;
|
||||||
|
using Lavalink4NET.Remora.Discord;
|
||||||
|
using Remora.Discord.Commands.Contexts;
|
||||||
|
using Remora.Discord.Commands.Feedback.Services;
|
||||||
|
|
||||||
|
namespace Cassette;
|
||||||
|
|
||||||
|
public abstract class LavalinkPlayer
|
||||||
|
{
|
||||||
|
public static async ValueTask<QueuedLavalinkPlayer?> GetPlayerAsync(
|
||||||
|
ICommandContext commandContext, IAudioService audioService,
|
||||||
|
FeedbackService feedbackService, bool connectToVoiceChannel = false)
|
||||||
|
{
|
||||||
|
var retrieveOptions = new PlayerRetrieveOptions(
|
||||||
|
ChannelBehavior: connectToVoiceChannel ? PlayerChannelBehavior.Join : PlayerChannelBehavior.None);
|
||||||
|
|
||||||
|
var result = await audioService.Players
|
||||||
|
.RetrieveAsync(commandContext, PlayerFactory.Queued, retrieveOptions);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
return result.Player;
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorMessage = result.Status switch
|
||||||
|
{
|
||||||
|
PlayerRetrieveStatus.UserNotInVoiceChannel => "You are not connected to a voice channel.",
|
||||||
|
PlayerRetrieveStatus.BotNotConnected => "The bot is currently not connected to a voice channel.",
|
||||||
|
_ => "Unknown error."
|
||||||
|
};
|
||||||
|
|
||||||
|
await feedbackService.SendContextualMessageResult(errorMessage,
|
||||||
|
feedbackService.Theme.FaultOrDanger);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 mctaylors
|
||||||
|
|
||||||
|
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.
|
Loading…
Reference in a new issue