mirror of
https://github.com/Myxelium/Lunaris2.0.git
synced 2026-04-13 08:00:37 +00:00
Compare commits
34 Commits
Add-chat-f
...
0.1.20
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4d8c246d | |||
| 43f0191752 | |||
| 872b6d3138 | |||
| f292124228 | |||
| 4cbee9a625 | |||
| b79e56d3a1 | |||
| fa19f8d938 | |||
| ac869c43da | |||
| e2fdd9a2d7 | |||
| 98761fc91d | |||
| 373d482906 | |||
|
|
e044f2f91b | ||
| 4855d37d76 | |||
| 4ba01ed72b | |||
| 1e2c10a7ea | |||
| 8dcd4b334d | |||
| 9bcebea6b0 | |||
| d72676c7e0 | |||
| b30d47e351 | |||
| 3ce0df7eaf | |||
| e88e67f913 | |||
| 5053553182 | |||
| 327ccc9675 | |||
| cbc99c2773 | |||
| d56215f685 | |||
| 967bee923a | |||
| f8e6854569 | |||
| 03150a3d04 | |||
| 32b6e09336 | |||
| e3df4505fe | |||
| 3daf18e053 | |||
| 54c5c68ba6 | |||
| e16ff9cfaf | |||
| a1d20fd732 |
39
.github/workflows/dotnet.yml
vendored
39
.github/workflows/dotnet.yml
vendored
@@ -7,10 +7,25 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # required for github-action-get-previous-tag
|
||||
|
||||
- name: Get previous tag
|
||||
id: previoustag
|
||||
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get next minor version
|
||||
id: semver
|
||||
uses: 'WyriHaximus/github-action-next-semvers@v1'
|
||||
with:
|
||||
version: ${{ steps.previoustag.outputs.tag }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
@@ -21,21 +36,13 @@ jobs:
|
||||
run: dotnet restore ./Bot/Lunaris2.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ./Bot/Lunaris2.csproj --no-restore -c Release -o ./out
|
||||
run: dotnet build ./Bot/Lunaris2.csproj --no-restore -c Release /p:AssemblyVersion=${{ steps.previoustag.outputs.tag }} -o ./out
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish ./Bot/Lunaris2.csproj --configuration Release --output ./out
|
||||
|
||||
- name: Zip the build
|
||||
run: 7z a -tzip ./out/Bot.zip ./out/*
|
||||
|
||||
- name: Get the tag name
|
||||
id: get_tag
|
||||
run: echo "::set-output name=tag::${GITHUB_REF#refs/tags/}"
|
||||
|
||||
- name: Get the version
|
||||
id: get_version
|
||||
run: echo "::set-output name=version::$(date +%s).${{ github.run_id }}"
|
||||
run: 7z a -tzip ./out/Lunaris.zip ./out/*
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
@@ -43,8 +50,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
release_name: Release v${{ steps.get_version.outputs.version }}
|
||||
tag_name: ${{ steps.semver.outputs.patch }}
|
||||
release_name: Release ${{ steps.semver.outputs.patch }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
@@ -55,6 +62,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./out/Bot.zip
|
||||
asset_name: Bot.zip
|
||||
asset_path: ./out/Lunaris.zip
|
||||
asset_name: Lunaris_${{steps.semver.outputs.patch}}.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
||||
.vs
|
||||
bin
|
||||
appsettings.json
|
||||
ollama
|
||||
plugins
|
||||
|
||||
68
Bot/Handler/ChatCommand/ChatHandler.cs
Normal file
68
Bot/Handler/ChatCommand/ChatHandler.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Text;
|
||||
using Discord.WebSocket;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OllamaSharp;
|
||||
|
||||
namespace Lunaris2.Handler.ChatCommand
|
||||
{
|
||||
public record ChatCommand(SocketMessage Message, string FilteredMessage) : IRequest;
|
||||
|
||||
public class ChatHandler : IRequestHandler<ChatCommand>
|
||||
{
|
||||
private readonly OllamaApiClient _ollama;
|
||||
private readonly Dictionary<ulong, Chat?> _chatContexts = new();
|
||||
private readonly ChatSettings _chatSettings;
|
||||
|
||||
public ChatHandler(IOptions<ChatSettings> chatSettings)
|
||||
{
|
||||
_chatSettings = chatSettings.Value;
|
||||
var uri = new Uri(chatSettings.Value.Url);
|
||||
|
||||
_ollama = new OllamaApiClient(uri)
|
||||
{
|
||||
SelectedModel = chatSettings.Value.Model
|
||||
};
|
||||
}
|
||||
|
||||
public async Task Handle(ChatCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var channelId = command.Message.Channel.Id;
|
||||
_chatContexts.TryAdd(channelId, null);
|
||||
|
||||
var userMessage = command.FilteredMessage;
|
||||
|
||||
var randomPersonality = _chatSettings.Personalities[new Random().Next(_chatSettings.Personalities.Count)];
|
||||
|
||||
userMessage = $"{randomPersonality.Instruction} {userMessage}";
|
||||
|
||||
using var setTyping = command.Message.Channel.EnterTypingState();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userMessage))
|
||||
{
|
||||
await command.Message.Channel.SendMessageAsync("Am I expected to read your mind?");
|
||||
setTyping.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
var response = await GenerateResponse(userMessage, channelId, cancellationToken);
|
||||
await command.Message.Channel.SendMessageAsync(response);
|
||||
|
||||
setTyping.Dispose();
|
||||
}
|
||||
|
||||
private async Task<string> GenerateResponse(string userMessage, ulong channelId, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = new StringBuilder();
|
||||
|
||||
if (_chatContexts[channelId] == null)
|
||||
{
|
||||
_chatContexts[channelId] = _ollama.Chat(stream => response.Append(stream.Message?.Content ?? ""));
|
||||
}
|
||||
|
||||
await _chatContexts[channelId].Send(userMessage, cancellationToken);
|
||||
|
||||
return response.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Bot/Handler/ChatCommand/ChatSettings.cs
Normal file
14
Bot/Handler/ChatCommand/ChatSettings.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace Lunaris2.Handler.ChatCommand;
|
||||
|
||||
public class ChatSettings
|
||||
{
|
||||
public string Url { get; set; }
|
||||
public string Model { get; set; }
|
||||
public List<Personality> Personalities { get; set; }
|
||||
}
|
||||
|
||||
public class Personality
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Instruction { get; set; }
|
||||
}
|
||||
8
Bot/Handler/ChatCommand/readme.md
Normal file
8
Bot/Handler/ChatCommand/readme.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## Ollama - Large Language Model Chat - Handler
|
||||
|
||||
This handler "owns" the logic for accessing the ollama api, which runs the transformer model.
|
||||
|
||||
> How to get started with a local chat bot see: [Run LLMs Locally using Ollama](https://marccodess.medium.com/run-llms-locally-using-ollama-8f04dd9b14f9)
|
||||
|
||||
Assuming you are on the same network as the Ollama server you should configure it to be accessible to other machines on the network, however this is only required if you aren't running it from localhost relative to the bot.
|
||||
See: [How do I configure Ollama server?](https://github.com/ollama/ollama/blob/main/docs/faq.md#how-do-i-configure-ollama-server)
|
||||
@@ -1,14 +0,0 @@
|
||||
using Discord.WebSocket;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler.GoodByeCommand;
|
||||
|
||||
public record GoodbyeCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class GoodbyeHandler : IRequestHandler<GoodbyeCommand>
|
||||
{
|
||||
public async Task Handle(GoodbyeCommand message, CancellationToken cancellationToken)
|
||||
{
|
||||
await message.Message.RespondAsync($"Goodbye, {message.Message.User.Username}! :c");
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using Discord.WebSocket;
|
||||
using Lunaris2.SlashCommand;
|
||||
using MediatR;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Lunaris2.Handler.HelloCommand;
|
||||
|
||||
public record HelloCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class HelloHandler : IRequestHandler<HelloCommand>
|
||||
{
|
||||
public async Task Handle(HelloCommand message, CancellationToken cancellationToken)
|
||||
{
|
||||
Console.WriteLine(JsonConvert.SerializeObject(Command.GetAllCommands()));
|
||||
|
||||
await message.Message.RespondAsync($"Hello, {message.Message.User.Username}!");
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,69 @@
|
||||
using Lunaris2.Handler.GoodByeCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.JoinCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.SkipCommand;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Lunaris2.Notification;
|
||||
using Lunaris2.SlashCommand;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler;
|
||||
|
||||
public class MessageReceivedHandler(ISender mediator) : INotificationHandler<MessageReceivedNotification>
|
||||
public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotification>
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ISender _mediatir;
|
||||
|
||||
public MessageReceivedHandler(DiscordSocketClient client, ISender mediatir)
|
||||
{
|
||||
_client = client;
|
||||
_mediatir = mediatir;
|
||||
}
|
||||
|
||||
public async Task Handle(MessageReceivedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (notification.Message.CommandName)
|
||||
await BotMentioned(notification, cancellationToken);
|
||||
await Statistics(notification, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task Statistics(MessageReceivedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.Message.Content.Contains("!LunarisStats"))
|
||||
{
|
||||
case Command.Hello.Name:
|
||||
await mediator.Send(new HelloCommand.HelloCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Goodbye.Name:
|
||||
await mediator.Send(new GoodbyeCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Join.Name:
|
||||
await mediator.Send(new JoinCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Play.Name:
|
||||
await mediator.Send(new PlayCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Skip.Name:
|
||||
await mediator.Send(new SkipCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
var servers = _client.Guilds.Select(guild => guild.Name);
|
||||
var channels = _client.Guilds
|
||||
.SelectMany(guild => guild.VoiceChannels)
|
||||
.Where(channel => channel.ConnectedUsers.Any(guildUser => guildUser.Id == _client.CurrentUser.Id) &&
|
||||
channel.Users.Count != 1);
|
||||
|
||||
var statsList = new StringBuilder();
|
||||
statsList.AppendLine("➡️ Servers");
|
||||
|
||||
foreach (var server in servers)
|
||||
statsList.AppendLine($"* {server}");
|
||||
|
||||
statsList.AppendLine("➡️ Now playing channels: ");
|
||||
|
||||
foreach (var channel in channels)
|
||||
statsList.AppendLine($"* {channel.Name} in {channel.Guild.Name}");
|
||||
|
||||
var embed = new EmbedBuilder()
|
||||
.WithTitle("Lunaris Statistics")
|
||||
.WithDescription(statsList.ToString())
|
||||
.Build();
|
||||
|
||||
await notification.Message.Channel.SendMessageAsync(embed: embed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BotMentioned(MessageReceivedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (notification.Message.MentionedUsers.Any(user => user.Id == _client.CurrentUser.Id))
|
||||
{
|
||||
const string pattern = "<.*?>";
|
||||
const string replacement = "";
|
||||
var regex = new Regex(pattern);
|
||||
var messageContent = regex.Replace(notification.Message.Content, replacement);
|
||||
|
||||
await _mediatir.Send(new ChatCommand.ChatCommand(notification.Message, messageContent), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Discord.WebSocket;
|
||||
using Lavalink4NET;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.ClearQueueCommand;
|
||||
|
||||
public record ClearQueueCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class DisconnectHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<ClearQueueCommand>
|
||||
{
|
||||
public async Task Handle(ClearQueueCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = command.Message;
|
||||
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
await player.Queue.ClearAsync(cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync("Cleared queue. No songs are queued.", client).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Discord.WebSocket;
|
||||
using Lavalink4NET;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.DisconnectCommand;
|
||||
|
||||
public record DisconnectCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class DisconnectHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<DisconnectCommand>
|
||||
{
|
||||
public async Task Handle(DisconnectCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = command.Message;
|
||||
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
await player.DisconnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
await context.SendMessageAsync("Disconnected.", client).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,46 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Victoria.Node;
|
||||
using Lavalink4NET;
|
||||
using Lavalink4NET.Players;
|
||||
using Lavalink4NET.Players.Queued;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static async ValueTask<QueuedLavalinkPlayer?> GetPlayerAsync(
|
||||
this IAudioService audioService,
|
||||
DiscordSocketClient client,
|
||||
SocketSlashCommand context,
|
||||
bool connectToVoiceChannel = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var retrieveOptions = new PlayerRetrieveOptions(
|
||||
ChannelBehavior: connectToVoiceChannel ? PlayerChannelBehavior.Join : PlayerChannelBehavior.None);
|
||||
|
||||
var playerOptions = new QueuedLavalinkPlayerOptions { HistoryCapacity = 10000 };
|
||||
|
||||
var result = await audioService.Players
|
||||
.RetrieveAsync(context.GetGuild(client).Id, context.GetGuild(client).GetUser(context.User.Id).VoiceChannel.Id, playerFactory: PlayerFactory.Queued, Options.Create(playerOptions), retrieveOptions)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
var errorMessage = result.Status switch
|
||||
{
|
||||
PlayerRetrieveStatus.UserNotInVoiceChannel => "You are not connected to a voice channel.",
|
||||
PlayerRetrieveStatus.BotNotConnected => "The bot is currently not connected.",
|
||||
_ => "Unknown error.",
|
||||
};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.Player;
|
||||
}
|
||||
|
||||
public static SocketGuild GetGuild(this SocketSlashCommand message, DiscordSocketClient client)
|
||||
{
|
||||
if (message.GuildId == null)
|
||||
@@ -33,24 +68,6 @@ public static class Extensions
|
||||
await message.RespondAsync(content, ephemeral: true);
|
||||
}
|
||||
|
||||
public static async Task EnsureConnected(this LavaNode lavaNode)
|
||||
{
|
||||
if(!lavaNode.IsConnected)
|
||||
await lavaNode.ConnectAsync();
|
||||
}
|
||||
|
||||
public static async Task JoinVoiceChannel(this SocketSlashCommand context, LavaNode lavaNode)
|
||||
{
|
||||
try
|
||||
{
|
||||
var textChannel = context.Channel as ITextChannel;
|
||||
await lavaNode.JoinAsync(context.GetVoiceState().VoiceChannel, textChannel);
|
||||
await context.RespondAsync($"Joined {context.GetVoiceState().VoiceChannel.Name}!");
|
||||
}
|
||||
catch (Exception exception) {
|
||||
Console.WriteLine(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetOptionValueByName(this SocketSlashCommand command, string optionName)
|
||||
{
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using Discord.WebSocket;
|
||||
using MediatR;
|
||||
using Victoria.Node;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.JoinCommand;
|
||||
|
||||
public record JoinCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class JoinHandler : IRequestHandler<JoinCommand>
|
||||
{
|
||||
private readonly LavaNode _lavaNode;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public JoinHandler(LavaNode lavaNode, DiscordSocketClient client)
|
||||
{
|
||||
_lavaNode = lavaNode;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task Handle(JoinCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = command.Message;
|
||||
|
||||
await _lavaNode.EnsureConnected();
|
||||
|
||||
if (_lavaNode.HasPlayer(context.GetGuild(_client))) {
|
||||
await context.RespondAsync("I'm already connected to a voice channel!");
|
||||
return;
|
||||
}
|
||||
|
||||
await context.JoinVoiceChannel(_lavaNode);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,102 @@
|
||||
using System.Net;
|
||||
using Discord;
|
||||
using Discord.Net;
|
||||
using Discord.WebSocket;
|
||||
using Lunaris2.Handler.MusicPlayer;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer;
|
||||
|
||||
public static class MessageModule
|
||||
{
|
||||
private static Dictionary<ulong, List<ulong>> guildMessageIds = new Dictionary<ulong, List<ulong>>();
|
||||
private static readonly Dictionary<ulong, List<ulong>> GuildMessageIds = new();
|
||||
|
||||
public static async Task SendMessageAsync(this SocketSlashCommand context, string message, DiscordSocketClient client)
|
||||
{
|
||||
var guildId = await StoreForRemoval(context, client);
|
||||
try
|
||||
{
|
||||
var guildId = await StoreForRemoval(context, client);
|
||||
|
||||
await context.RespondAsync(message);
|
||||
var sentMessage = await context.GetOriginalResponseAsync();
|
||||
|
||||
guildMessageIds[guildId].Add(sentMessage.Id);
|
||||
var sentMessage = await context.FollowupAsync(message);
|
||||
GuildMessageIds[guildId].Add(sentMessage.Id);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SendMessageAsync(this SocketSlashCommand context, Embed message, DiscordSocketClient client)
|
||||
{
|
||||
var guildId = await StoreForRemoval(context, client);
|
||||
try
|
||||
{
|
||||
var guildId = await StoreForRemoval(context, client);
|
||||
|
||||
await context.RespondAsync(embed: message);
|
||||
|
||||
var sentMessage = await context.GetOriginalResponseAsync();
|
||||
|
||||
guildMessageIds[guildId].Add(sentMessage.Id);
|
||||
var sentMessage = await context.FollowupAsync(embed: message);
|
||||
GuildMessageIds[guildId].Add(sentMessage.Id);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ulong> StoreForRemoval(SocketSlashCommand context, DiscordSocketClient client)
|
||||
public static async Task RemoveMessages(this SocketSlashCommand context, DiscordSocketClient client)
|
||||
{
|
||||
var guildId = context.GetGuild(client).Id;
|
||||
|
||||
if (guildMessageIds.ContainsKey(guildId))
|
||||
if (GuildMessageIds.TryGetValue(guildId, out var value))
|
||||
{
|
||||
foreach (var messageId in guildMessageIds[guildId])
|
||||
if (value.Count <= 0)
|
||||
return;
|
||||
|
||||
foreach (var messageId in value)
|
||||
{
|
||||
var messageToDelete = await context.Channel.GetMessageAsync(messageId);
|
||||
if (messageToDelete != null)
|
||||
await messageToDelete.DeleteAsync();
|
||||
}
|
||||
|
||||
guildMessageIds[guildId].Clear();
|
||||
value.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ulong> StoreForRemoval(SocketSlashCommand context, DiscordSocketClient client)
|
||||
{
|
||||
var guildId = context.GetGuild(client).Id;
|
||||
|
||||
if (GuildMessageIds.TryGetValue(guildId, out var value))
|
||||
{
|
||||
if (value.Count <= 0)
|
||||
return guildId;
|
||||
|
||||
// Create a copy of the list to avoid modifying it during iteration
|
||||
var messagesToDelete = new List<ulong>(value);
|
||||
|
||||
foreach (var messageId in messagesToDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messageToDelete = await context.Channel.GetMessageAsync(messageId);
|
||||
if (messageToDelete != null)
|
||||
{
|
||||
await messageToDelete.DeleteAsync();
|
||||
}
|
||||
}
|
||||
catch (HttpException ex)
|
||||
{
|
||||
if (ex.HttpCode != HttpStatusCode.NotFound)
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the list after we're done with the iteration
|
||||
value.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
guildMessageIds.Add(guildId, []);
|
||||
// If the guildId does not exist, add it to the dictionary
|
||||
GuildMessageIds.Add(guildId, new List<ulong>());
|
||||
}
|
||||
|
||||
return guildId;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
using Victoria;
|
||||
using Victoria.Player;
|
||||
using Lavalink4NET.Players.Queued;
|
||||
using Lavalink4NET.Tracks;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer;
|
||||
|
||||
@@ -13,31 +13,36 @@ public class MusicEmbed
|
||||
string length,
|
||||
string artist,
|
||||
string queuedBy,
|
||||
string? nextInQueue)
|
||||
string? nextSong = null)
|
||||
{
|
||||
var getNextSong = nextSong is not null ? $"\nNext Song: {nextSong}" : string.Empty;
|
||||
|
||||
return new EmbedBuilder()
|
||||
.WithAuthor("Lunaris", "https://media.tenor.com/GqAwMt01UXgAAAAi/cd.gif")
|
||||
.WithTitle(title)
|
||||
.WithDescription($"Length: {length}\nArtist: {artist}\nQueued by: {queuedBy}\nNext in queue: {nextInQueue}")
|
||||
.WithDescription($"Length: {length}\nArtist: {artist}\nQueued by: {queuedBy}{getNextSong}")
|
||||
.WithColor(Color.Magenta)
|
||||
.WithThumbnailUrl(imageUrl)
|
||||
.Build();
|
||||
}
|
||||
|
||||
public async Task NowPlayingEmbed(
|
||||
LavaPlayer<LavaTrack> player,
|
||||
LavalinkTrack track,
|
||||
SocketSlashCommand context,
|
||||
DiscordSocketClient client)
|
||||
DiscordSocketClient client,
|
||||
ITrackQueue? queue = null)
|
||||
{
|
||||
var artwork = await player.Track.FetchArtworkAsync();
|
||||
var getNextTrack = player.Vueue.Count > 1 ? player.Vueue.ToArray()[1].Title : "No songs in queue.";
|
||||
var duration = TimeSpan.Parse(track.Duration.ToString());
|
||||
|
||||
var artwork = track.ArtworkUri;
|
||||
var nextSong = queue?.Count > 1 ? queue[1].Track?.Title : null;
|
||||
var embed = SendMusicEmbed(
|
||||
artwork,
|
||||
player.Track.Title,
|
||||
player.Track.Duration.ToString(),
|
||||
player.Track.Author,
|
||||
artwork.ToString(),
|
||||
track.Title,
|
||||
duration.ToString(@"hh\:mm\:ss"),
|
||||
track.Author,
|
||||
context.User.Username,
|
||||
getNextTrack);
|
||||
nextSong);
|
||||
|
||||
await context.SendMessageAsync(embed, client);
|
||||
}
|
||||
|
||||
31
Bot/Handler/MusicPlayer/PauseCommand/PauseHandler.cs
Normal file
31
Bot/Handler/MusicPlayer/PauseCommand/PauseHandler.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Discord.WebSocket;
|
||||
using Lavalink4NET;
|
||||
using Lavalink4NET.Players;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.PauseCommand;
|
||||
|
||||
public record PauseCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class PauseHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<PauseCommand>
|
||||
{
|
||||
public async Task Handle(PauseCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = command.Message;
|
||||
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.State is PlayerState.Paused)
|
||||
{
|
||||
await context.SendMessageAsync("Player is already paused.", client);
|
||||
return;
|
||||
}
|
||||
|
||||
await player.PauseAsync(cancellationToken);
|
||||
await context.SendMessageAsync("Paused.", client);
|
||||
}
|
||||
}
|
||||
30
Bot/Handler/MusicPlayer/PlayCommand/NormalizationFilter.cs
Normal file
30
Bot/Handler/MusicPlayer/PlayCommand/NormalizationFilter.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json;
|
||||
using Lavalink4NET.Filters;
|
||||
using Lavalink4NET.Protocol.Models.Filters;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||
|
||||
public class NormalizationFilter : IFilterOptions
|
||||
{
|
||||
private double MaxAmplitude { get; set; }
|
||||
private bool Adaptive { get; set; }
|
||||
|
||||
public NormalizationFilter(double maxAmplitude, bool adaptive)
|
||||
{
|
||||
MaxAmplitude = maxAmplitude;
|
||||
Adaptive = adaptive;
|
||||
}
|
||||
|
||||
public bool IsDefault => MaxAmplitude == 1.0 && !Adaptive;
|
||||
|
||||
public void Apply(ref PlayerFilterMapModel filterMap)
|
||||
{
|
||||
filterMap.AdditionalFilters ??= new Dictionary<string, JsonElement>();
|
||||
var normalizationFilter = new
|
||||
{
|
||||
maxAmplitude = MaxAmplitude,
|
||||
adaptive = Adaptive
|
||||
};
|
||||
filterMap.AdditionalFilters["normalization"] = JsonSerializer.SerializeToElement(normalizationFilter);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
using System.Collections.Immutable;
|
||||
using Discord.WebSocket;
|
||||
using Lunaris2.SlashCommand;
|
||||
using MediatR;
|
||||
using Victoria.Node;
|
||||
using Victoria.Node.EventArgs;
|
||||
using Victoria.Player;
|
||||
using Victoria.Responses.Search;
|
||||
using Lavalink4NET;
|
||||
using Lavalink4NET.Events.Players;
|
||||
using Lavalink4NET.Integrations.SponsorBlock;
|
||||
using Lavalink4NET.Integrations.SponsorBlock.Extensions;
|
||||
using Lavalink4NET.Players.Queued;
|
||||
using Lavalink4NET.Rest.Entities.Tracks;
|
||||
using Lavalink4NET.Tracks;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||
|
||||
@@ -15,105 +17,180 @@ public record PlayCommand(SocketSlashCommand Message) : IRequest;
|
||||
public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
{
|
||||
private readonly MusicEmbed _musicEmbed;
|
||||
private readonly LavaNode _lavaNode;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IAudioService _audioService;
|
||||
private SocketSlashCommand _context;
|
||||
private const int MaxTrackDuration = 30;
|
||||
private LavalinkTrack? _previousTrack;
|
||||
private static readonly HashSet<ulong> SubscribedGuilds = new();
|
||||
|
||||
public PlayHandler(
|
||||
LavaNode lavaNode,
|
||||
DiscordSocketClient client,
|
||||
MusicEmbed musicEmbed)
|
||||
MusicEmbed musicEmbed,
|
||||
IAudioService audioService)
|
||||
{
|
||||
_lavaNode = lavaNode;
|
||||
_client = client;
|
||||
_musicEmbed = musicEmbed;
|
||||
_audioService = audioService;
|
||||
}
|
||||
|
||||
[Command(RunMode = RunMode.Async)]
|
||||
public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||
private async Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
|
||||
{
|
||||
_context = command.Message;
|
||||
// Reset the previous track when the track ends.
|
||||
_previousTrack = null;
|
||||
}
|
||||
|
||||
await _lavaNode.EnsureConnected();
|
||||
private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
|
||||
{
|
||||
var player = sender as QueuedLavalinkPlayer;
|
||||
|
||||
var songName = _context.GetOptionValueByName(Option.Input);
|
||||
if (player?.CurrentTrack is null)
|
||||
{
|
||||
return; // No track is currently playing.
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(songName)) {
|
||||
await _context.RespondAsync("Please provide search terms.");
|
||||
var currentTrack = player.CurrentTrack;
|
||||
|
||||
// Check if the current track is the same as the previous one
|
||||
if (_previousTrack?.Identifier == currentTrack.Identifier)
|
||||
{
|
||||
// The same track is playing, so we don't need to create a new embed.
|
||||
return;
|
||||
}
|
||||
|
||||
var player = await GetPlayer();
|
||||
|
||||
if (player == null)
|
||||
return;
|
||||
|
||||
var searchResponse = await _lavaNode.SearchAsync(
|
||||
Uri.IsWellFormedUriString(songName, UriKind.Absolute)
|
||||
? SearchType.Direct
|
||||
: SearchType.YouTube, songName);
|
||||
|
||||
if (!await SearchResponse(searchResponse, player, songName))
|
||||
return;
|
||||
|
||||
await PlayTrack(player);
|
||||
|
||||
await _musicEmbed.NowPlayingEmbed(player, _context, _client);
|
||||
|
||||
_lavaNode.OnTrackEnd += OnTrackEnd;
|
||||
// Track has changed, update the previous track and send the embed
|
||||
_previousTrack = currentTrack;
|
||||
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
|
||||
}
|
||||
|
||||
private async Task OnTrackEnd(TrackEndEventArg<LavaPlayer<LavaTrack>, LavaTrack> arg)
|
||||
public Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var player = arg.Player;
|
||||
if (!player.Vueue.TryDequeue(out var nextTrack))
|
||||
new Thread(PlayMusic).Start();
|
||||
return Task.CompletedTask;
|
||||
|
||||
async void PlayMusic()
|
||||
{
|
||||
try
|
||||
{
|
||||
RegisterTrackStartedEventListerner(command);
|
||||
|
||||
await _audioService.StartAsync(cancellationToken);
|
||||
|
||||
var context = command.Message;
|
||||
_context = context;
|
||||
|
||||
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
|
||||
{
|
||||
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
var searchQuery = context.GetOptionValueByName(Option.Input);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
await context.SendMessageAsync("Please provide search terms.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
await context.SendMessageAsync("📻 Searching...", _client);
|
||||
|
||||
var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
await ApplyFilters(cancellationToken, player);
|
||||
await ConfigureSponsorBlock(cancellationToken, player);
|
||||
|
||||
var trackLoadOptions = new TrackLoadOptions
|
||||
{
|
||||
SearchMode = TrackSearchMode.YouTubeMusic,
|
||||
};
|
||||
|
||||
var trackCollection = await _audioService.Tracks.LoadTracksAsync(searchQuery, trackLoadOptions, cancellationToken: cancellationToken);
|
||||
|
||||
// Check if the result is a playlist or just a single track from the search result
|
||||
if (trackCollection.IsPlaylist)
|
||||
{
|
||||
// If it's a playlist, check if it's a free-text input.
|
||||
if (!Uri.IsWellFormedUriString(searchQuery, UriKind.Absolute))
|
||||
{
|
||||
// Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
|
||||
// Queue only the first track of the search result
|
||||
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
|
||||
if (trackCollection.Track != null)
|
||||
{
|
||||
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _musicEmbed.NowPlayingEmbed(trackCollection.Track, _context, _client);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync("No tracks found.", _client);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's a playlist and a URL was used, so we can queue all tracks as usual
|
||||
var queueTracks = trackCollection.Tracks
|
||||
.Skip(1)
|
||||
.Select(t => new TrackQueueItem(t))
|
||||
.ToList();
|
||||
|
||||
await player.PlayAsync(trackCollection.Tracks.First(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await player.Queue.AddRangeAsync(queueTracks, cancellationToken);
|
||||
await context.SendMessageAsync($"Queued playlist {trackCollection.Playlist?.Name} with {queueTracks.Count} tracks.", _client);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's just a single track or a search result.
|
||||
var track = trackCollection.Track;
|
||||
|
||||
if (track != null)
|
||||
{
|
||||
await player.PlayAsync(track, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _musicEmbed.NowPlayingEmbed(track, _context, _client);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync("No tracks found.", _client);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
throw new Exception("Error occured in the Play handler!", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterTrackStartedEventListerner(PlayCommand command)
|
||||
{
|
||||
if (SubscribedGuilds.Contains((ulong)command.Message.GuildId!))
|
||||
return;
|
||||
|
||||
await player.PlayAsync(nextTrack);
|
||||
|
||||
await _musicEmbed.NowPlayingEmbed(player, _context, _client);
|
||||
_audioService.TrackStarted += OnTrackStarted;
|
||||
_audioService.TrackEnded += OnTrackEnded;
|
||||
SubscribedGuilds.Add((ulong)command.Message.GuildId!);
|
||||
}
|
||||
|
||||
private static async Task PlayTrack(LavaPlayer<LavaTrack> player)
|
||||
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
|
||||
{
|
||||
if (player.PlayerState is PlayerState.Playing or PlayerState.Paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
player.Vueue.TryDequeue(out var lavaTrack);
|
||||
await player.PlayAsync(lavaTrack);
|
||||
var normalizationFilter = new NormalizationFilter(0.5, true);
|
||||
player.Filters.SetFilter(normalizationFilter);
|
||||
await player.Filters.CommitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<LavaPlayer<LavaTrack>?> GetPlayer()
|
||||
private static async Task ConfigureSponsorBlock(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
|
||||
{
|
||||
var voiceState = _context.User as IVoiceState;
|
||||
var categories = ImmutableArray.Create(
|
||||
SegmentCategory.Intro,
|
||||
SegmentCategory.Sponsor,
|
||||
SegmentCategory.SelfPromotion,
|
||||
SegmentCategory.Outro,
|
||||
SegmentCategory.Filler);
|
||||
|
||||
if (voiceState?.VoiceChannel != null)
|
||||
return await _lavaNode.JoinAsync(voiceState.VoiceChannel, _context.Channel as ITextChannel);
|
||||
|
||||
await _context.RespondAsync("You must be connected to a voice channel!");
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> SearchResponse(
|
||||
SearchResponse searchResponse, LavaPlayer<LavaTrack> player,
|
||||
string songName)
|
||||
{
|
||||
if (searchResponse.Status is SearchStatus.LoadFailed or SearchStatus.NoMatches) {
|
||||
await _context.RespondAsync($"I wasn't able to find anything for `{songName}`.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchResponse.Playlist.Name)) {
|
||||
player.Vueue.Enqueue(searchResponse.Tracks);
|
||||
|
||||
await _context.RespondAsync($"Enqueued {searchResponse.Tracks.Count} songs.");
|
||||
}
|
||||
else {
|
||||
var track = searchResponse.Tracks.FirstOrDefault()!;
|
||||
player.Vueue.Enqueue(track);
|
||||
}
|
||||
|
||||
return true;
|
||||
await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,75 @@ flowchart TD
|
||||
PlayTrack --> NowPlayingEmbed
|
||||
```
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class PlayHandler {
|
||||
-MusicEmbed _musicEmbed
|
||||
-DiscordSocketClient _client
|
||||
-IAudioService _audioService
|
||||
-SocketSlashCommand _context
|
||||
-const int MaxTrackDuration
|
||||
-LavalinkTrack? _previousTrack
|
||||
-static HashSet~ulong~ SubscribedGuilds
|
||||
+PlayHandler(DiscordSocketClient client, MusicEmbed musicEmbed, IAudioService audioService)
|
||||
+Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||
-void PlayMusic()
|
||||
-Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
|
||||
-Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
|
||||
-void RegisterTrackStartedEventListerner(PlayCommand command)
|
||||
-static Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
|
||||
-static Task ConfigureSponsorBlock(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
|
||||
}
|
||||
|
||||
class PlayCommand {
|
||||
+SocketSlashCommand Message
|
||||
}
|
||||
|
||||
class TrackEndedEventArgs {
|
||||
}
|
||||
|
||||
class TrackStartedEventArgs {
|
||||
}
|
||||
|
||||
class QueuedLavalinkPlayer {
|
||||
+LavalinkTrack? CurrentTrack
|
||||
+Task PlayAsync(LavalinkTrack track, CancellationToken cancellationToken)
|
||||
+Task Queue.AddRangeAsync(List~TrackQueueItem~ queueTracks, CancellationToken cancellationToken)
|
||||
+Task Filters.SetFilter(NormalizationFilter normalizationFilter)
|
||||
+Task Filters.CommitAsync(CancellationToken cancellationToken)
|
||||
+Task UpdateSponsorBlockCategoriesAsync(ImmutableArray~SegmentCategory~ categories, CancellationToken cancellationToken)
|
||||
}
|
||||
|
||||
class LavalinkTrack {
|
||||
+string Identifier
|
||||
}
|
||||
|
||||
class NormalizationFilter {
|
||||
+NormalizationFilter(double gain, bool enabled)
|
||||
}
|
||||
|
||||
class SegmentCategory {
|
||||
+static SegmentCategory Intro
|
||||
+static SegmentCategory Sponsor
|
||||
+static SegmentCategory SelfPromotion
|
||||
+static SegmentCategory Outro
|
||||
+static SegmentCategory Filler
|
||||
}
|
||||
|
||||
class TrackQueueItem {
|
||||
+TrackQueueItem(LavalinkTrack track)
|
||||
}
|
||||
|
||||
PlayHandler --> PlayCommand
|
||||
PlayHandler --> TrackEndedEventArgs
|
||||
PlayHandler --> TrackStartedEventArgs
|
||||
PlayHandler --> QueuedLavalinkPlayer
|
||||
PlayHandler --> LavalinkTrack
|
||||
PlayHandler --> NormalizationFilter
|
||||
PlayHandler --> SegmentCategory
|
||||
PlayHandler --> TrackQueueItem
|
||||
```
|
||||
|
||||
## Steps in the code
|
||||
|
||||
| Name | Description |
|
||||
|
||||
139
Bot/Handler/MusicPlayer/README.md
Normal file
139
Bot/Handler/MusicPlayer/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
### README.md
|
||||
|
||||
# Handlers
|
||||
|
||||
Handlers for the Lunaris2 bot, which is built using C#, Discord.Net, and Lavalink4NET. Below is a detailed description of each handler and their responsibilities.
|
||||
|
||||
## Handlers
|
||||
|
||||
### ClearQueueHandler
|
||||
|
||||
Handles the command to clear the music queue.
|
||||
|
||||
```csharp
|
||||
public class ClearQueueHandler : IRequestHandler<ClearQueueCommand>
|
||||
```
|
||||
|
||||
### DisconnectHandler
|
||||
|
||||
Handles the command to disconnect the bot from the voice channel.
|
||||
|
||||
```csharp
|
||||
public class DisconnectHandler : IRequestHandler<DisconnectCommand>
|
||||
```
|
||||
|
||||
### PauseHandler
|
||||
|
||||
Handles the command to pause the currently playing track.
|
||||
|
||||
```csharp
|
||||
public class PauseHandler : IRequestHandler<PauseCommand>
|
||||
```
|
||||
|
||||
### PlayHandler
|
||||
|
||||
Handles the command to play a track or playlist.
|
||||
|
||||
```csharp
|
||||
public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
```
|
||||
|
||||
### ResumeHandler
|
||||
|
||||
Handles the command to resume the currently paused track.
|
||||
|
||||
```csharp
|
||||
public class ResumeHandler : IRequestHandler<ResumeCommand>
|
||||
```
|
||||
|
||||
### SkipHandler
|
||||
|
||||
Handles the command to skip the currently playing track.
|
||||
|
||||
```csharp
|
||||
public class SkipHandler : IRequestHandler<SkipCommand>
|
||||
```
|
||||
|
||||
### MessageReceivedHandler
|
||||
|
||||
Handles incoming messages and processes commands or statistics requests.
|
||||
|
||||
```csharp
|
||||
public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotification>
|
||||
```
|
||||
|
||||
## Mermaid Diagrams
|
||||
|
||||
### Class Diagram
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as User
|
||||
participant DiscordSocketClient as DiscordSocketClient
|
||||
participant MessageReceivedHandler as MessageReceivedHandler
|
||||
participant MessageReceivedNotification as MessageReceivedNotification
|
||||
participant EmbedBuilder as EmbedBuilder
|
||||
participant Channel as Channel
|
||||
|
||||
User->>DiscordSocketClient: Send message "!LunarisStats"
|
||||
DiscordSocketClient->>MessageReceivedHandler: MessageReceivedNotification
|
||||
MessageReceivedHandler->>MessageReceivedNotification: Handle(notification, cancellationToken)
|
||||
MessageReceivedNotification->>MessageReceivedHandler: BotMentioned(notification, cancellationToken)
|
||||
MessageReceivedHandler->>DiscordSocketClient: Get guilds and voice channels
|
||||
DiscordSocketClient-->>MessageReceivedHandler: List of guilds and voice channels
|
||||
MessageReceivedHandler->>EmbedBuilder: Create embed with statistics
|
||||
EmbedBuilder-->>MessageReceivedHandler: Embed
|
||||
MessageReceivedHandler->>Channel: Send embed message
|
||||
```
|
||||
|
||||
### Sequence Diagram for PlayHandler
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Bot
|
||||
participant DiscordSocketClient
|
||||
participant IAudioService
|
||||
participant SocketSlashCommand
|
||||
participant LavalinkPlayer
|
||||
|
||||
User->>Bot: /play [song]
|
||||
Bot->>DiscordSocketClient: Get user voice channel
|
||||
DiscordSocketClient-->>Bot: Voice channel info
|
||||
Bot->>IAudioService: Get or create player
|
||||
IAudioService-->>Bot: Player instance
|
||||
Bot->>SocketSlashCommand: Get search query
|
||||
SocketSlashCommand-->>Bot: Search query
|
||||
Bot->>IAudioService: Load tracks
|
||||
IAudioService-->>Bot: Track collection
|
||||
Bot->>LavalinkPlayer: Play track
|
||||
LavalinkPlayer-->>Bot: Track started
|
||||
Bot->>User: Now playing embed
|
||||
```
|
||||
|
||||
### Sequence Diagram for MessageReceivedHandler
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Bot
|
||||
participant DiscordSocketClient
|
||||
participant ISender
|
||||
participant MessageReceivedNotification
|
||||
|
||||
User->>Bot: Send message
|
||||
Bot->>MessageReceivedNotification: Create notification
|
||||
Bot->>DiscordSocketClient: Check if bot is mentioned
|
||||
DiscordSocketClient-->>Bot: Mention info
|
||||
alt Bot is mentioned
|
||||
Bot->>ISender: Send ChatCommand
|
||||
end
|
||||
Bot->>DiscordSocketClient: Check for statistics command
|
||||
alt Statistics command found
|
||||
Bot->>DiscordSocketClient: Get server and channel info
|
||||
DiscordSocketClient-->>Bot: Server and channel info
|
||||
Bot->>User: Send statistics embed
|
||||
end
|
||||
```
|
||||
|
||||
This README provides an overview of the handlers and their responsibilities, along with class and sequence diagrams to illustrate the interactions and relationships between the components.
|
||||
29
Bot/Handler/MusicPlayer/ResumeCommand/ResumeHandler.cs
Normal file
29
Bot/Handler/MusicPlayer/ResumeCommand/ResumeHandler.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Discord.WebSocket;
|
||||
using Lavalink4NET;
|
||||
using Lavalink4NET.Players;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.ResumeCommand;
|
||||
|
||||
public record ResumeCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class ResumeHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<ResumeCommand>
|
||||
{
|
||||
public async Task Handle(ResumeCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = command.Message;
|
||||
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
if (player.State is not PlayerState.Paused)
|
||||
{
|
||||
await context.SendMessageAsync("Player is not paused.", client);
|
||||
return;
|
||||
}
|
||||
|
||||
await player.ResumeAsync(cancellationToken);
|
||||
await context.SendMessageAsync("Resumed.", client);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,34 @@
|
||||
using Discord.WebSocket;
|
||||
using Lavalink4NET;
|
||||
using MediatR;
|
||||
using Victoria.Node;
|
||||
using Victoria.Player;
|
||||
|
||||
namespace Lunaris2.Handler.MusicPlayer.SkipCommand;
|
||||
|
||||
public record SkipCommand(SocketSlashCommand Message) : IRequest;
|
||||
|
||||
public class SkipHandler : IRequestHandler<SkipCommand>
|
||||
public class SkipHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<SkipCommand>
|
||||
{
|
||||
private readonly LavaNode _lavaNode;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly MusicEmbed _musicEmbed;
|
||||
|
||||
public SkipHandler(LavaNode lavaNode, DiscordSocketClient client, MusicEmbed musicEmbed)
|
||||
public async Task Handle(SkipCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
_lavaNode = lavaNode;
|
||||
_client = client;
|
||||
_musicEmbed = musicEmbed;
|
||||
}
|
||||
var context = command.Message;
|
||||
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
|
||||
|
||||
public async Task Handle(SkipCommand message, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = message.Message;
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
await _lavaNode.EnsureConnected();
|
||||
|
||||
if (!_lavaNode.TryGetPlayer(context.GetGuild(_client), out var player)) {
|
||||
await context.RespondAsync("I'm not connected to a voice channel.");
|
||||
if (player.CurrentItem is null)
|
||||
{
|
||||
await context.SendMessageAsync("Nothing playing!", client).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.PlayerState != PlayerState.Playing) {
|
||||
await context.RespondAsync("Woaaah there, I can't skip when nothing is playing.");
|
||||
return;
|
||||
}
|
||||
await player.SkipAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try {
|
||||
await player.SkipAsync();
|
||||
await _musicEmbed.NowPlayingEmbed(player, context, _client);
|
||||
}
|
||||
catch (Exception exception) {
|
||||
await context.RespondAsync("There is not more tracks to skip.");
|
||||
Console.WriteLine(exception);
|
||||
}
|
||||
var track = player.CurrentItem;
|
||||
|
||||
if (track is not null)
|
||||
await context.SendMessageAsync($"Skipped. Now playing: {track.Track!.Title}", client).ConfigureAwait(false);
|
||||
else
|
||||
await context.SendMessageAsync("Skipped. Stopped playing because the queue is now empty.", client).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
41
Bot/Handler/SlashCommandReceivedHandler.cs
Normal file
41
Bot/Handler/SlashCommandReceivedHandler.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Lunaris2.Handler.MusicPlayer.ClearQueueCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.DisconnectCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.PauseCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.ResumeCommand;
|
||||
using Lunaris2.Handler.MusicPlayer.SkipCommand;
|
||||
using Lunaris2.Notification;
|
||||
using Lunaris2.SlashCommand;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Handler;
|
||||
|
||||
public class SlashCommandReceivedHandler(ISender mediator) : INotificationHandler<SlashCommandReceivedNotification>
|
||||
{
|
||||
public async Task Handle(SlashCommandReceivedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
await notification.Message.DeferAsync();
|
||||
|
||||
switch (notification.Message.CommandName)
|
||||
{
|
||||
case Command.Resume.Name:
|
||||
await mediator.Send(new ResumeCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Pause.Name:
|
||||
await mediator.Send(new PauseCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Disconnect.Name:
|
||||
await mediator.Send(new DisconnectCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Play.Name:
|
||||
await mediator.Send(new PlayCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Skip.Name:
|
||||
await mediator.Send(new SkipCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
case Command.Clear.Name:
|
||||
await mediator.Send(new ClearQueueCommand(notification.Message), cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Lunaris2.Helper;
|
||||
|
||||
public static class Async
|
||||
{
|
||||
public static void Run(Func<Task> task)
|
||||
{
|
||||
_ = Task.Run(task);
|
||||
}
|
||||
}
|
||||
@@ -6,23 +6,34 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>ec2f340f-a44c-4869-ab79-a12ba9459d80</UserSecretsId>
|
||||
<AssemblyVersion>0.0.1337</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Lavalink4net 4.0.25 seems to break the Message Module-->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net" Version="3.13.1" />
|
||||
<PackageReference Include="Discord.Net.Commands" Version="3.13.1" />
|
||||
<PackageReference Include="Discord.Net.Core" Version="3.13.1" />
|
||||
<PackageReference Include="Discord.Net.Interactions" Version="3.13.1" />
|
||||
<PackageReference Include="Discord.Net.Rest" Version="3.13.1" />
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
<PackageReference Include="Discord.Net" Version="3.16.0" />
|
||||
<PackageReference Include="Discord.Net.Commands" Version="3.16.0" />
|
||||
<PackageReference Include="Discord.Net.Core" Version="3.16.0" />
|
||||
<PackageReference Include="Discord.Net.Interactions" Version="3.16.0" />
|
||||
<PackageReference Include="Discord.Net.Rest" Version="3.16.0" />
|
||||
<PackageReference Include="Lavalink4NET" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Artwork" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Discord.NET" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Integrations.Lavasrc" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Victoria" Version="6.0.23.324" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
<PackageReference Include="OllamaSharp" Version="1.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="../docker-compose.yml" pack="true" PackagePath="." />
|
||||
<None Include="../application.yml" pack="true" PackagePath="." />
|
||||
<None Include="../start-services.sh" pack="true" PackagePath="." />
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
||||
@@ -19,13 +19,20 @@ public class DiscordEventListener(DiscordSocketClient client, IServiceScopeFacto
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
client.SlashCommandExecuted += OnMessageReceivedAsync;
|
||||
client.SlashCommandExecuted += OnSlashCommandRecievedAsync;
|
||||
client.MessageReceived += OnMessageReceivedAsync;
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task OnMessageReceivedAsync(SocketSlashCommand arg)
|
||||
private Task OnMessageReceivedAsync(SocketMessage arg)
|
||||
{
|
||||
await Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken);
|
||||
_ = Task.Run(() => Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken), _cancellationToken);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task OnSlashCommandRecievedAsync(SocketSlashCommand arg)
|
||||
{
|
||||
await Mediator.Publish(new SlashCommandReceivedNotification(arg), _cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using MediatR;
|
||||
|
||||
namespace Lunaris2.Notification;
|
||||
|
||||
public class MessageReceivedNotification(SocketSlashCommand message) : INotification
|
||||
public class MessageReceivedNotification(SocketMessage message) : INotification
|
||||
{
|
||||
public SocketSlashCommand Message { get; } = message ?? throw new ArgumentNullException(nameof(message));
|
||||
public SocketMessage Message { get; } = message ?? throw new ArgumentNullException(nameof(message));
|
||||
}
|
||||
9
Bot/Notification/SlashCommandReceivedNotification.cs
Normal file
9
Bot/Notification/SlashCommandReceivedNotification.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Discord.WebSocket;
|
||||
using MediatR;
|
||||
|
||||
namespace Lunaris2.Notification;
|
||||
|
||||
public class SlashCommandReceivedNotification(SocketSlashCommand message) : INotification
|
||||
{
|
||||
public SocketSlashCommand Message { get; } = message ?? throw new ArgumentNullException(nameof(message));
|
||||
}
|
||||
@@ -1,26 +1,32 @@
|
||||
using System.Reflection;
|
||||
using Discord;
|
||||
using Discord.Commands;
|
||||
using Discord.Interactions;
|
||||
using Discord.WebSocket;
|
||||
using Lunaris2.Handler.ChatCommand;
|
||||
using Lavalink4NET.Extensions;
|
||||
using Lavalink4NET.Integrations.SponsorBlock.Extensions;
|
||||
using Lunaris2.Handler.MusicPlayer;
|
||||
using Lunaris2.Notification;
|
||||
using Lunaris2.Service;
|
||||
using Lunaris2.SlashCommand;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Victoria;
|
||||
using Victoria.Node;
|
||||
using RunMode = Discord.Commands.RunMode;
|
||||
|
||||
namespace Lunaris2;
|
||||
|
||||
public class Program
|
||||
{
|
||||
private static LavaNode? _lavaNode;
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
CreateHostBuilder(args).Build().Run();
|
||||
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
|
||||
{
|
||||
Console.WriteLine(eventArgs.ExceptionObject);
|
||||
};
|
||||
var app = CreateHostBuilder(args).Build();
|
||||
|
||||
app.UseSponsorBlock();
|
||||
app.Run();
|
||||
}
|
||||
|
||||
private static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||
@@ -32,29 +38,31 @@ public class Program
|
||||
GatewayIntents = GatewayIntents.All
|
||||
};
|
||||
|
||||
var commandServiceConfig = new CommandServiceConfig{ DefaultRunMode = RunMode.Async };
|
||||
|
||||
var client = new DiscordSocketClient(config);
|
||||
var commands = new CommandService(commandServiceConfig);
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppContext.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json")
|
||||
.Build();
|
||||
|
||||
services.AddSingleton(client)
|
||||
.AddSingleton(commands)
|
||||
.AddMediatR(configuration => configuration.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
|
||||
.AddSingleton<DiscordEventListener>()
|
||||
.AddSingleton(service => new InteractionService(service.GetRequiredService<DiscordSocketClient>()))
|
||||
.AddLavaNode(nodeConfiguration =>
|
||||
services
|
||||
.AddMediatR(mediatRServiceConfiguration => mediatRServiceConfiguration.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
|
||||
.AddLavalink()
|
||||
.ConfigureLavalink(options =>
|
||||
{
|
||||
nodeConfiguration.SelfDeaf = false;
|
||||
nodeConfiguration.Hostname = configuration["LavaLinkHostname"];
|
||||
nodeConfiguration.Port = Convert.ToUInt16(configuration["LavaLinkPort"]);
|
||||
nodeConfiguration.Authorization = configuration["LavaLinkPassword"];
|
||||
options.BaseAddress = new Uri(
|
||||
$"http://{configuration["LavaLinkHostname"]}:{configuration["LavaLinkPort"]}"
|
||||
);
|
||||
options.WebSocketUri = new Uri($"ws://{configuration["LavaLinkHostname"]}:{configuration["LavaLinkPort"]}/v4/websocket");
|
||||
options.Passphrase = configuration["LavaLinkPassword"] ?? "youshallnotpass";
|
||||
options.Label = "Node";
|
||||
})
|
||||
.AddSingleton<LavaNode>()
|
||||
.AddSingleton<MusicEmbed>();
|
||||
.AddSingleton<MusicEmbed>()
|
||||
.AddSingleton<ChatSettings>()
|
||||
.AddSingleton(client)
|
||||
.AddSingleton<DiscordEventListener>()
|
||||
.AddSingleton<VoiceChannelMonitorService>()
|
||||
.AddSingleton(service => new InteractionService(service.GetRequiredService<DiscordSocketClient>()))
|
||||
.Configure<ChatSettings>(configuration.GetSection("LLM"));
|
||||
|
||||
client.Ready += () => Client_Ready(client);
|
||||
client.Log += Log;
|
||||
@@ -69,8 +77,6 @@ public class Program
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
|
||||
_lavaNode = services.BuildServiceProvider().GetRequiredService<LavaNode>();
|
||||
|
||||
var listener = services
|
||||
.BuildServiceProvider()
|
||||
.GetRequiredService<DiscordEventListener>();
|
||||
@@ -81,10 +87,12 @@ public class Program
|
||||
.GetResult();
|
||||
});
|
||||
|
||||
private static async Task Client_Ready(DiscordSocketClient client)
|
||||
private static Task Client_Ready(DiscordSocketClient client)
|
||||
{
|
||||
await _lavaNode.ConnectAsync();
|
||||
client.RegisterCommands();
|
||||
|
||||
new VoiceChannelMonitorService(client).StartMonitoring();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static Task Log(LogMessage arg)
|
||||
|
||||
@@ -2,19 +2,68 @@
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Program[Program] -->|Register| EventListener
|
||||
EventListener[DiscordEventListener] --> A
|
||||
Program --> Intervals[VoiceChannelMonitorService]
|
||||
Intervals --> SetStatus[SetStatus, Updates status with amount of playing bots]
|
||||
Intervals --> LeaveChannel[LeaveOnAlone, Leaves channel when alone for a time]
|
||||
EventListener[DiscordEventListener] --> A[MessageReceivedHandler]
|
||||
|
||||
A[MessageReceivedHandler] -->|Message| C{Send to correct command by
|
||||
EventListener[DiscordEventListener] --> A2[SlashCommandReceivedHandler]
|
||||
|
||||
A --> |Message| f{If bot is mentioned}
|
||||
A --> |Message '!LunarisStats'| p[Responds with Server and Channel Statistics.]
|
||||
f --> |ChatCommand| v[ChatHandler]
|
||||
|
||||
A2[SlashCommandReceivedHandler] -->|Message| C{Send to correct command by
|
||||
looking at commandName}
|
||||
|
||||
C -->|JoinCommand| D[JoinHandler]
|
||||
C -->|PlayCommand| E[PlayHandler]
|
||||
C -->|HelloCommand| F[HelloHandler]
|
||||
C -->|GoodbyeCommand| G[GoodbyeHandler]
|
||||
C -->|PauseCommand| F[PauseHandler]
|
||||
C -->|DisconnectCommand| H[DisconnectHandler]
|
||||
C -->|ResumeCommand| J[ResumeHandler]
|
||||
C -->|SkipCommand| K[SkipHandler]
|
||||
C -->|ClearQueueCommand| L[ClearQueueHandler]
|
||||
```
|
||||
|
||||
Program registers an event listener ```DiscordEventListener``` which publish a message :
|
||||
|
||||
```c#
|
||||
await Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken);
|
||||
```
|
||||
|
||||
|Name| Description |
|
||||
|--|--|
|
||||
| SlashCommandReceivedHandler | Handles commands using ``/`` from any Discord Guild/Server. |
|
||||
| MessageReceivedHandler| Listens to **all** messages. |
|
||||
|
||||
## Handler integrations
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D[JoinHandler] --> Disc[Discord Api]
|
||||
E[PlayHandler] --> Disc[Discord Api]
|
||||
F[SkipHandler] --> Disc[Discord Api]
|
||||
G[PauseHandler] --> Disc[Discord Api]
|
||||
v[ChatHandler] --> Disc[Discord Api]
|
||||
ClearQueueHandler --> Disc
|
||||
ClearQueuehandler --> Lava
|
||||
DisconnectHandler --> Disc
|
||||
Resumehandler --> Disc
|
||||
v --> o[Ollama Server]
|
||||
o --> v
|
||||
E --> Lava[Lavalink]
|
||||
F --> Lava
|
||||
G --> Lava
|
||||
```
|
||||
|Name| Description |
|
||||
|--|--|
|
||||
| JoinHandler| Handles the logic for **just** joining a voice channel. |
|
||||
| PlayHandler| Handles the logic for joining and playing music in a voice channel. |
|
||||
| PauseHandler | Handles the logic for pausing currently playing track. |
|
||||
| DisconnectHandler | Handles the logic for disconnecting from voicechannels. |
|
||||
| ClearQueueHandler | Handles the logic for clearing the queued songs, except the currently playing one. |
|
||||
| SkipHandler | Handles the logic for skipping tracks that are queued. If 0 trackS is in queue, it stops the current one.|
|
||||
| Resumehandler | Resumes paused tracks. |
|
||||
| ChatHandler| Handles the logic for LLM chat with user. |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
96
Bot/Service/VoiceChannelMonitorService.cs
Normal file
96
Bot/Service/VoiceChannelMonitorService.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace Lunaris2.Service
|
||||
{
|
||||
public class VoiceChannelMonitorService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly Dictionary<ulong, Timer> _timers = new();
|
||||
|
||||
public VoiceChannelMonitorService(DiscordSocketClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public void StartMonitoring()
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await CheckVoiceChannels();
|
||||
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CheckVoiceChannels()
|
||||
{
|
||||
SetStatus();
|
||||
await LeaveOnAlone();
|
||||
}
|
||||
|
||||
private void SetStatus()
|
||||
{
|
||||
var channels = _client.Guilds
|
||||
.SelectMany(guild => guild.VoiceChannels)
|
||||
.Count(channel =>
|
||||
channel.ConnectedUsers
|
||||
.Any(guildUser => guildUser.Id == _client.CurrentUser.Id) &&
|
||||
channel.Users.Count > 1
|
||||
);
|
||||
|
||||
if (channels == 0)
|
||||
_client.SetGameAsync(System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), type: ActivityType.CustomStatus);
|
||||
else if(channels == 1)
|
||||
_client.SetGameAsync("in 1 server", type: ActivityType.Playing);
|
||||
else if(channels > 1)
|
||||
_client.SetGameAsync($" in {channels} servers", type: ActivityType.Playing);
|
||||
}
|
||||
|
||||
private async Task LeaveOnAlone()
|
||||
{
|
||||
foreach (var guild in _client.Guilds)
|
||||
{
|
||||
// Find voice channels where only the bot is left
|
||||
var voiceChannel = guild.VoiceChannels.FirstOrDefault(vc =>
|
||||
vc.ConnectedUsers.Count == 1 &&
|
||||
vc.Users.Any(u => u.Id == _client.CurrentUser.Id));
|
||||
|
||||
if (voiceChannel != null)
|
||||
{
|
||||
// If timer not set for this channel, start one
|
||||
if (!_timers.ContainsKey(voiceChannel.Id))
|
||||
{
|
||||
Console.WriteLine($"Bot is alone in channel {voiceChannel.Name}, starting timer...");
|
||||
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null,
|
||||
TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Set delay before leaving
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clean up timer if channel is no longer active
|
||||
var timersToDispose = _timers.Where(t => guild.VoiceChannels.All(vc => vc.Id != t.Key)).ToList();
|
||||
foreach (var timer in timersToDispose)
|
||||
{
|
||||
await timer.Value.DisposeAsync();
|
||||
_timers.Remove(timer.Key);
|
||||
Console.WriteLine($"Disposed timer for inactive voice channel ID: {timer.Key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LeaveChannel(SocketVoiceChannel voiceChannel)
|
||||
{
|
||||
if (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id))
|
||||
{
|
||||
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity...");
|
||||
await voiceChannel.DisconnectAsync();
|
||||
await _timers[voiceChannel.Id].DisposeAsync();
|
||||
_timers.Remove(voiceChannel.Id); // Clean up after leaving
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,22 +9,16 @@ public static class Option
|
||||
|
||||
public static class Command
|
||||
{
|
||||
public static class Hello
|
||||
public static class Disconnect
|
||||
{
|
||||
public const string Name = "hello";
|
||||
public const string Description = "Say hello to the bot!";
|
||||
public const string Name = "disconnect";
|
||||
public const string Description = "Disconnect from the voice channel!";
|
||||
}
|
||||
|
||||
public static class Goodbye
|
||||
public static class Clear
|
||||
{
|
||||
public const string Name = "goodbye";
|
||||
public const string Description = "Say goodbye to the bot!";
|
||||
}
|
||||
|
||||
public static class Join
|
||||
{
|
||||
public const string Name = "join";
|
||||
public const string Description = "Join the voice channel!";
|
||||
public const string Name = "clear";
|
||||
public const string Description = "Clear the music queue!";
|
||||
}
|
||||
|
||||
public static class Skip
|
||||
@@ -33,10 +27,16 @@ public static class Command
|
||||
public const string Description = "Skip the current song!";
|
||||
}
|
||||
|
||||
public static class Stop
|
||||
public static class Resume
|
||||
{
|
||||
public const string Name = "stop";
|
||||
public const string Description = "Stop the music!";
|
||||
public const string Name = "resume";
|
||||
public const string Description = "Resume the music!";
|
||||
}
|
||||
|
||||
public static class Pause
|
||||
{
|
||||
public const string Name = "pause";
|
||||
public const string Description = "Pause the music!";
|
||||
}
|
||||
|
||||
public static class Play
|
||||
|
||||
@@ -42,7 +42,6 @@ public class SlashCommandBuilder(
|
||||
|
||||
private static async Task RemoveUnusedCommands(string[] commands, IEnumerable<SocketApplicationCommand> registeredCommands)
|
||||
{
|
||||
// Remove commands from Discord(registeredCommands) that are not in the list of commands
|
||||
foreach(var command in registeredCommands)
|
||||
{
|
||||
if (commands.Contains(command.Name))
|
||||
|
||||
@@ -7,12 +7,13 @@ public static class SlashCommandRegistration
|
||||
{
|
||||
public static void RegisterCommands(this DiscordSocketClient client)
|
||||
{
|
||||
RegisterCommand(client, Command.Hello.Name, Command.Hello.Description);
|
||||
RegisterCommand(client, Command.Goodbye.Name, Command.Goodbye.Description);
|
||||
RegisterCommand(client, Command.Join.Name, Command.Join.Description);
|
||||
RegisterCommand(client, Command.Resume.Name, Command.Resume.Description);
|
||||
RegisterCommand(client, Command.Pause.Name, Command.Pause.Description);
|
||||
RegisterCommand(client, Command.Disconnect.Name, Command.Disconnect.Description);
|
||||
RegisterCommand(client, Command.Skip.Name, Command.Skip.Description);
|
||||
RegisterCommand(client, Command.Play.Name, Command.Play.Description, Command.Play.Options);
|
||||
RegisterCommand(client, Command.Stop.Name, Command.Stop.Description);
|
||||
RegisterCommand(client, Command.Resume.Name, Command.Resume.Description);
|
||||
RegisterCommand(client, Command.Clear.Name, Command.Clear.Description);
|
||||
}
|
||||
|
||||
private static void RegisterCommand(
|
||||
|
||||
@@ -6,8 +6,18 @@
|
||||
"Microsoft": "Information"
|
||||
}
|
||||
},
|
||||
"Token": "discordToken",
|
||||
"Token": "TOKEN",
|
||||
"LavaLinkPassword": "youshallnotpass",
|
||||
"LavaLinkHostname": "127.0.0.1",
|
||||
"LavaLinkPort": 2333
|
||||
"LavaLinkPort": 2333,
|
||||
"LLM": {
|
||||
"Url": "http://localhost:7869",
|
||||
"Model": "gemma",
|
||||
"personalities": [
|
||||
{
|
||||
"name": "Lunaris",
|
||||
"instruction": "Let's role play. Your name is Lunaris and can be quite rude. You are supposed to answer on following question with a maximum of 2 sentences:"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
2
Bot/generate-trusted-session.sh
Normal file
2
Bot/generate-trusted-session.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
docker run quay.io/invidious/youtube-trusted-session-generator
|
||||
read -p "Copy the codes and press enter to close the terminal."
|
||||
28
README.md
28
README.md
@@ -7,6 +7,7 @@ Lunaris2 is a Discord bot designed to play music in your server's voice channels
|
||||
- Play music from YouTube directly in your Discord server.
|
||||
- Skip tracks, pause, and resume playback.
|
||||
- Queue system to line up your favorite tracks.
|
||||
- Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -17,6 +18,29 @@ Lunaris2 is a Discord bot designed to play music in your server's voice channels
|
||||
5. Make sure you got docker installed. And run the file ``start-services.sh``, make sure you got git-bash installed.
|
||||
6. Now you can start the project and run the application.
|
||||
|
||||
## LLM
|
||||
Lunaris supports AI chat using a large language model, this is done by hosting the LLM locally, in this case Docker will set it up for you when you run the start-services script.
|
||||
|
||||
The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). Running LLM locally requires much resources from your system, minimum requirements is at least 8GB of ram. If your don't have enought ram, select a LLM model in the [appsettings file](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/appsettings.json#L15) that requires less of your system.
|
||||
|
||||
*NOTE: you need to download the model from the Ollama ui, the model name which is preselected in the code is called ``gemma``.*
|
||||
|
||||
## PM2 Setup
|
||||
- Install PM2 and configure it following their setup guide
|
||||
#### Lavalink
|
||||
* Download Lavalink 4.X.X (.jar)
|
||||
* Install Java 17
|
||||
|
||||
If using Linux run following command to start Lavalink with PM2:
|
||||
``pm2 start "sudo java -Xmx1G -jar Lavalink.jar" --name Lavalink4.0.7``
|
||||
|
||||
For me I have Lavalink.jar downloaded in ``/opt`` folder from Linux root. By running Lavalink using PM2, you can monitor it and manage it from a page in your browser instead of having to access the server terminal.
|
||||
#### Lunaris
|
||||
* Install dotnet
|
||||
|
||||
Register the Lunaris bot with PM2:
|
||||
``pm2 start "dotnet Lunaris2.dll"``
|
||||
|
||||
## Usage
|
||||
|
||||
- `/play <song>`: Plays the specified song in the voice channel you're currently in.
|
||||
@@ -25,7 +49,3 @@ Lunaris2 is a Discord bot designed to play music in your server's voice channels
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
|
||||
@@ -2,17 +2,73 @@ server: # REST and WS server
|
||||
port: 2333
|
||||
address: 0.0.0.0
|
||||
plugins:
|
||||
# name: # Name of the plugin
|
||||
# some_key: some_value # Some key-value pair for the plugin
|
||||
# another_key: another_value
|
||||
lavasrc:
|
||||
providers: # Custom providers for track loading. This is the default
|
||||
# - "dzisrc:%ISRC%" # Deezer ISRC provider
|
||||
# - "dzsearch:%QUERY%" # Deezer search provider
|
||||
- "ytsearch:\"%ISRC%\"" # Will be ignored if track does not have an ISRC. See https://en.wikipedia.org/wiki/International_Standard_Recording_Code
|
||||
- "ytsearch:%QUERY%" # Will be used if track has no ISRC or no track could be found for the ISRC
|
||||
# you can add multiple other fallback sources here
|
||||
sources:
|
||||
spotify: true # Enable Spotify source
|
||||
applemusic: false # Enable Apple Music source
|
||||
deezer: false # Enable Deezer source
|
||||
yandexmusic: false # Enable Yandex Music source
|
||||
flowerytts: false # Enable Flowery TTS source
|
||||
youtube: false # Enable YouTube search source (https://github.com/topi314/LavaSearch)
|
||||
vkmusic: false # Enable Vk Music source
|
||||
spotify:
|
||||
clientId: "ID"
|
||||
clientSecret: "SECRET"
|
||||
# spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api
|
||||
countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
|
||||
playlistLoadLimit: 6 # The number of pages at 100 tracks each
|
||||
albumLoadLimit: 6 # The number of pages at 50 tracks each
|
||||
resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow)
|
||||
localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"`
|
||||
youtube:
|
||||
oauth:
|
||||
# setting "enabled: true" is the bare minimum to get OAuth working.
|
||||
enabled: true
|
||||
|
||||
# if you have a refresh token, you may set it below (make sure to uncomment the line to apply it).
|
||||
# setting a valid refresh token will skip the OAuth flow entirely. See above note on how to retrieve
|
||||
# your refreshToken.
|
||||
# Set this if you don't want the OAuth flow to be triggered, if you intend to supply a refresh token later.
|
||||
# Initialization is skipped automatically if a valid refresh token is supplied. Leave this commented if you're
|
||||
# completing the OAuth flow for the first time/do not have a refresh token.
|
||||
# skipInitialization: true
|
||||
# pot: // run generate-trusted-session.sh
|
||||
# token: ""
|
||||
# visitorData: ""
|
||||
enabled: true # Whether this source can be used.
|
||||
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
|
||||
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
|
||||
allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded.
|
||||
# The clients to use for track loading. See below for a list of valid clients.
|
||||
# Clients are queried in the order they are given (so the first client is queried first and so on...)
|
||||
clients:
|
||||
- MUSIC
|
||||
- WEB
|
||||
- TVHTML5EMBEDDED
|
||||
- ANDROID_TESTSUITE
|
||||
lavalink:
|
||||
plugins:
|
||||
# - dependency: "group:artifact:version"
|
||||
# repository: "repository"
|
||||
- dependency: com.github.devoxin:lavadspx-plugin:0.0.5 # replace {VERSION} with the latest version from the "Releases" tab.
|
||||
repository: https://jitpack.io
|
||||
- dependency: "dev.lavalink.youtube:youtube-plugin:1.8.3"
|
||||
snapshot: false # Set to true if you want to use a snapshot version.
|
||||
- dependency: "com.github.topi314.lavasearch:lavasearch-plugin:1.0.0"
|
||||
repository: "https://maven.lavalink.dev/releases" # this is optional for lavalink v4.0.0-beta.5 or greater
|
||||
snapshot: false # set to true if you want to use snapshot builds (see below)
|
||||
- dependency: "com.github.topi314.sponsorblock:sponsorblock-plugin:3.0.1"
|
||||
- dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.2.0"
|
||||
repository: "https://maven.lavalink.dev/releases" # this is optional for lavalink v4.0.0-beta.5 or greater
|
||||
snapshot: false # set to true if you want to use snapshot builds (see below)
|
||||
server:
|
||||
password: "youshallnotpass"
|
||||
sources:
|
||||
youtube: true
|
||||
youtube: false
|
||||
bandcamp: true
|
||||
soundcloud: true
|
||||
twitch: true
|
||||
@@ -30,9 +86,9 @@ lavalink:
|
||||
rotation: true
|
||||
channelMix: true
|
||||
lowPass: true
|
||||
bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses.
|
||||
frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered
|
||||
opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
|
||||
bufferDurationMs: 1000 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses.
|
||||
frameBufferDurationMs: 10000 # How many milliseconds of audio to keep buffered
|
||||
opusEncodingQuality: 5 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
|
||||
resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU.
|
||||
trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data.
|
||||
useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready.
|
||||
@@ -41,20 +97,6 @@ lavalink:
|
||||
youtubeSearchEnabled: true
|
||||
soundcloudSearchEnabled: true
|
||||
gc-warnings: true
|
||||
#ratelimit:
|
||||
#ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks
|
||||
#excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink
|
||||
#strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch
|
||||
#searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing
|
||||
#retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times
|
||||
#youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without.
|
||||
#email: "" # Email of Google account
|
||||
#password: "" # Password of Google account
|
||||
#httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked
|
||||
#proxyHost: "localhost" # Hostname of the proxy, (ip or domain)
|
||||
#proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy
|
||||
#proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth
|
||||
#proxyPassword: "" # Password for basic authentication
|
||||
|
||||
metrics:
|
||||
prometheus:
|
||||
@@ -64,9 +106,6 @@ metrics:
|
||||
sentry:
|
||||
dsn: ""
|
||||
environment: ""
|
||||
# tags:
|
||||
# some_key: some_value
|
||||
# another_key: another_value
|
||||
|
||||
logging:
|
||||
file:
|
||||
@@ -84,7 +123,6 @@ logging:
|
||||
includePayload: true
|
||||
maxPayloadLength: 10000
|
||||
|
||||
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 1GB
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
lavalink:
|
||||
# pin the image version to Lavalink v4
|
||||
image: ghcr.io/lavalink-devs/lavalink:3.7.11
|
||||
image: ghcr.io/lavalink-devs/lavalink:4.0.8
|
||||
container_name: lavalink
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -24,7 +24,52 @@ services:
|
||||
ports:
|
||||
# you only need this if you want to make your lavalink accessible from outside of containers
|
||||
- "2333:2333"
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
ports:
|
||||
- 7869:11434
|
||||
volumes:
|
||||
- .:/code
|
||||
- ./ollama/ollama:/root/.ollama
|
||||
container_name: ollama
|
||||
pull_policy: always
|
||||
tty: true
|
||||
restart: always
|
||||
environment:
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
- OLLAMA_HOST=0.0.0.0
|
||||
networks:
|
||||
- ollama-docker
|
||||
|
||||
ollama-webui:
|
||||
image: ghcr.io/open-webui/open-webui:main
|
||||
container_name: ollama-webui
|
||||
volumes:
|
||||
- ./ollama/ollama-webui:/app/backend/data
|
||||
depends_on:
|
||||
- ollama
|
||||
ports:
|
||||
- 8080:8080
|
||||
environment: # https://docs.openwebui.com/getting-started/env-configuration#default_models
|
||||
- OLLAMA_BASE_URLS=http://host.docker.internal:7869 #comma separated ollama hosts
|
||||
- ENV=dev
|
||||
- WEBUI_AUTH=False
|
||||
- WEBUI_NAME=valiantlynx AI
|
||||
- WEBUI_URL=http://localhost:8080
|
||||
- WEBUI_SECRET_KEY=t0p-s3cr3t
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ollama-docker
|
||||
|
||||
volumes:
|
||||
ollama: {}
|
||||
|
||||
networks:
|
||||
# create a lavalink network you can add other containers to, to give them access to Lavalink
|
||||
lavalink:
|
||||
name: lavalink
|
||||
ollama-docker:
|
||||
external: false
|
||||
Binary file not shown.
Reference in New Issue
Block a user