Initial commit

Signed-off-by: mctaylors <cantsendmails@mctaylors.ru>
This commit is contained in:
Macintxsh 2023-12-17 04:10:17 +03:00
commit db55ca7fb1
Signed by: mctaylors
GPG key ID: 7181BEBE676903C1
15 changed files with 2519 additions and 0 deletions

1766
.editorconfig Normal file

File diff suppressed because it is too large Load diff

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.idea/
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/

16
Cassette.sln Normal file
View 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
View 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
View 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>

View 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 })
}));
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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
}
}

View 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();
}
}

View 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}";
}
}

View 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
View 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.