mirror of
https://github.com/Myxelium/Lunaris2.0.git
synced 2026-08-22 13:51:22 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f08d89f322 | ||
|
|
b30d47e351 | ||
|
|
3ce0df7eaf | ||
|
|
e88e67f913 | ||
|
|
5053553182 | ||
|
|
327ccc9675 | ||
|
|
cbc99c2773 | ||
|
|
d56215f685 | ||
|
|
967bee923a | ||
|
|
f8e6854569 | ||
|
|
03150a3d04 | ||
|
|
32b6e09336 | ||
|
|
e3df4505fe | ||
|
|
3daf18e053 | ||
|
|
54c5c68ba6 | ||
|
|
e16ff9cfaf | ||
|
|
a1d20fd732 | ||
|
|
3d7655a902 | ||
|
|
8ddcf31da7 | ||
|
|
80a7c19b20 | ||
|
|
713715901b | ||
|
|
1a3a00f4ed | ||
|
|
0673653491 | ||
|
|
a650fd431e | ||
|
|
f6a763589e | ||
|
|
b7198bd01e | ||
|
|
f0678a4244 | ||
|
|
984d6b0af8 | ||
|
|
175f1f0d01 | ||
|
|
c8a1936f64 | ||
|
|
3f091ed7cc | ||
|
|
49872791b3 | ||
|
|
5a7b326dc2 | ||
|
|
0bc2a9be56 | ||
|
|
2d7ec0829e | ||
|
|
9b48536cd7 | ||
|
|
31c39e8893 | ||
|
|
521f808209 | ||
|
|
97667df53b | ||
|
|
95ab1281a4 | ||
|
|
f7b54ca80c | ||
|
|
3699a13cd9 | ||
|
|
13efaa7552 | ||
|
|
169b71c724 | ||
|
|
5b996ecd1d |
@@ -0,0 +1,67 @@
|
|||||||
|
name: .NET Core CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # required for github-action-get-previous-tag
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v1
|
||||||
|
with:
|
||||||
|
dotnet-version: '8.0.x'
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore ./Bot/Lunaris2.csproj
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: dotnet build ./Bot/Lunaris2.csproj --no-restore -c Release -o ./out
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
run: dotnet publish ./Bot/Lunaris2.csproj --configuration Release --output ./out
|
||||||
|
|
||||||
|
- name: Zip the build
|
||||||
|
run: 7z a -tzip ./out/Lunaris.zip ./out/*
|
||||||
|
|
||||||
|
- 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: Create Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
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.semver.outputs.patch }}
|
||||||
|
release_name: Release ${{ steps.semver.outputs.patch }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload Release Asset
|
||||||
|
id: upload-release-asset
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: ./out/Lunaris.zip
|
||||||
|
asset_name: Lunaris.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
obj
|
||||||
|
.idea
|
||||||
|
.vs
|
||||||
|
bin
|
||||||
|
appsettings.json
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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}!");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Lunaris2.Notification;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Lunaris2.Handler;
|
||||||
|
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
await BotMentioned(notification, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BotMentioned(MessageReceivedNotification notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (notification.Message.MentionedUsers.Any(user => user.Id == _client.CurrentUser.Id))
|
||||||
|
{
|
||||||
|
// The bot was mentioned
|
||||||
|
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,59 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Victoria.Node;
|
||||||
|
|
||||||
|
namespace Lunaris2.Handler.MusicPlayer;
|
||||||
|
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
public static SocketGuild GetGuild(this SocketSlashCommand message, DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
if (message.GuildId == null)
|
||||||
|
{
|
||||||
|
throw new Exception("Guild ID is null!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.GetGuild(message.GuildId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IVoiceState GetVoiceState(this SocketSlashCommand message)
|
||||||
|
{
|
||||||
|
var voiceState = message.User as IVoiceState;
|
||||||
|
|
||||||
|
if (voiceState?.VoiceChannel == null)
|
||||||
|
{
|
||||||
|
throw new Exception("You must be connected to a voice channel!");
|
||||||
|
}
|
||||||
|
|
||||||
|
return voiceState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task RespondAsync(this SocketSlashCommand message, string content)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return command.Data.Options.FirstOrDefault(option => option.Name == optionName)?.Value.ToString() ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Lunaris2.Handler.MusicPlayer;
|
||||||
|
|
||||||
|
public static class MessageModule
|
||||||
|
{
|
||||||
|
private static Dictionary<ulong, List<ulong>> guildMessageIds = new Dictionary<ulong, List<ulong>>();
|
||||||
|
|
||||||
|
public static async Task SendMessageAsync(this SocketSlashCommand context, string message, DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
var guildId = await StoreForRemoval(context, client);
|
||||||
|
|
||||||
|
await context.RespondAsync(message);
|
||||||
|
var sentMessage = await context.GetOriginalResponseAsync();
|
||||||
|
|
||||||
|
guildMessageIds[guildId].Add(sentMessage.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SendMessageAsync(this SocketSlashCommand context, Embed message, DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
var guildId = await StoreForRemoval(context, client);
|
||||||
|
|
||||||
|
await context.RespondAsync(embed: message);
|
||||||
|
|
||||||
|
var sentMessage = await context.GetOriginalResponseAsync();
|
||||||
|
|
||||||
|
guildMessageIds[guildId].Add(sentMessage.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<ulong> StoreForRemoval(SocketSlashCommand context, DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
var guildId = context.GetGuild(client).Id;
|
||||||
|
|
||||||
|
if (guildMessageIds.ContainsKey(guildId))
|
||||||
|
{
|
||||||
|
foreach (var messageId in guildMessageIds[guildId])
|
||||||
|
{
|
||||||
|
var messageToDelete = await context.Channel.GetMessageAsync(messageId);
|
||||||
|
if (messageToDelete != null)
|
||||||
|
await messageToDelete.DeleteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
guildMessageIds[guildId].Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
guildMessageIds.Add(guildId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return guildId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Victoria;
|
||||||
|
using Victoria.Player;
|
||||||
|
|
||||||
|
namespace Lunaris2.Handler.MusicPlayer;
|
||||||
|
|
||||||
|
public class MusicEmbed
|
||||||
|
{
|
||||||
|
private Embed SendMusicEmbed(
|
||||||
|
string imageUrl,
|
||||||
|
string title,
|
||||||
|
string length,
|
||||||
|
string artist,
|
||||||
|
string queuedBy,
|
||||||
|
string? nextInQueue)
|
||||||
|
{
|
||||||
|
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}")
|
||||||
|
.WithColor(Color.Magenta)
|
||||||
|
.WithThumbnailUrl(imageUrl)
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NowPlayingEmbed(
|
||||||
|
LavaPlayer<LavaTrack> player,
|
||||||
|
SocketSlashCommand context,
|
||||||
|
DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
var artwork = await player.Track.FetchArtworkAsync();
|
||||||
|
var getNextTrack = player.Vueue.Count > 1 ? player.Vueue.ToArray()[1].Title : "No songs in queue.";
|
||||||
|
var embed = SendMusicEmbed(
|
||||||
|
artwork,
|
||||||
|
player.Track.Title,
|
||||||
|
player.Track.Duration.ToString(),
|
||||||
|
player.Track.Author,
|
||||||
|
context.User.Username,
|
||||||
|
getNextTrack);
|
||||||
|
|
||||||
|
await context.SendMessageAsync(embed, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.Commands;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Lunaris2.SlashCommand;
|
||||||
|
using MediatR;
|
||||||
|
using Victoria.Node;
|
||||||
|
using Victoria.Node.EventArgs;
|
||||||
|
using Victoria.Player;
|
||||||
|
using Victoria.Responses.Search;
|
||||||
|
|
||||||
|
namespace Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||||
|
|
||||||
|
public record PlayCommand(SocketSlashCommand Message) : IRequest;
|
||||||
|
|
||||||
|
public class PlayHandler : IRequestHandler<PlayCommand>
|
||||||
|
{
|
||||||
|
private readonly MusicEmbed _musicEmbed;
|
||||||
|
private readonly LavaNode _lavaNode;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private SocketSlashCommand _context;
|
||||||
|
|
||||||
|
public PlayHandler(
|
||||||
|
LavaNode lavaNode,
|
||||||
|
DiscordSocketClient client,
|
||||||
|
MusicEmbed musicEmbed)
|
||||||
|
{
|
||||||
|
_lavaNode = lavaNode;
|
||||||
|
_client = client;
|
||||||
|
_musicEmbed = musicEmbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Command(RunMode = RunMode.Async)]
|
||||||
|
public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_context = command.Message;
|
||||||
|
|
||||||
|
await _lavaNode.EnsureConnected();
|
||||||
|
|
||||||
|
var songName = _context.GetOptionValueByName(Option.Input);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(songName)) {
|
||||||
|
await _context.RespondAsync("Please provide search terms.");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnTrackEnd(TrackEndEventArg<LavaPlayer<LavaTrack>, LavaTrack> arg)
|
||||||
|
{
|
||||||
|
var player = arg.Player;
|
||||||
|
if (!player.Vueue.TryDequeue(out var nextTrack))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await player.PlayAsync(nextTrack);
|
||||||
|
|
||||||
|
await _musicEmbed.NowPlayingEmbed(player, _context, _client);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task PlayTrack(LavaPlayer<LavaTrack> player)
|
||||||
|
{
|
||||||
|
if (player.PlayerState is PlayerState.Playing or PlayerState.Paused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
player.Vueue.TryDequeue(out var lavaTrack);
|
||||||
|
await player.PlayAsync(lavaTrack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LavaPlayer<LavaTrack>?> GetPlayer()
|
||||||
|
{
|
||||||
|
var voiceState = _context.User as IVoiceState;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
PlayHandler --> EnsureConnected
|
||||||
|
EnsureConnected --> GetPlayer
|
||||||
|
GetPlayer --> SearchAsync
|
||||||
|
SearchAsync --> SearchResponse
|
||||||
|
SearchResponse --> PlayTrack
|
||||||
|
PlayTrack --> NowPlayingEmbed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps in the code
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|--|--|
|
||||||
|
| PlayHandler | Holds the logic for playing songs |
|
||||||
|
| GetPlayer | Joins voice channel, produces chat resposne |
|
||||||
|
| EnsureConnected | Makes sure the client is connected |
|
||||||
|
| SearchAsync | Searches for songs information |
|
||||||
|
| SearchResponse | Handling possible errors from the response of SearchAsync |
|
||||||
|
| PlayTrack | Plays the song |
|
||||||
|
|
||||||
|
There is also OnTrackEnd, when it get called an attempt is made to play the next song in queue.
|
||||||
|
|
||||||
|
## Short explaination for some of the variables used:
|
||||||
|
|
||||||
|
| Variable | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `_lavaNode` | `LavaNode` | An instance of the `LavaNode` class, used to interact with the LavaLink server for playing music in Discord voice channels. |
|
||||||
|
| `_client` | `DiscordSocketClient` | An instance of the `DiscordSocketClient` class, used to interact with the Discord API for sending messages, joining voice channels, etc. |
|
||||||
|
| `_musicEmbed` | `MusicEmbed` | An instance of a custom `MusicEmbed` class, used to create and send embed messages related to the music player's current status. |
|
||||||
|
| `context` | `SocketSlashCommand` | An instance of the `SocketSlashCommand` class, representing a slash command received from Discord. Used to get information about the command and to respond to it. |
|
||||||
|
| `player` | `LavaPlayer` | An instance of the `LavaPlayer` class, representing a music player connected to a specific voice channel. Used to play, pause, skip, and queue tracks. |
|
||||||
|
| `guildMessageIds` | `Dictionary<ulong, List<ulong>>` | A dictionary that maps guild IDs to lists of message IDs. Used to keep track of messages sent by the bot in each guild, allowing the bot to delete its old messages when it sends new ones. |
|
||||||
|
| `songName` | `string` | A string that represents the name or URL of a song to play. Used to search for and queue tracks. |
|
||||||
|
| `searchResponse` | `SearchResponse` | An instance of the `SearchResponse` class, representing the result of a search for tracks. Used to get the tracks that were found and queue them in the player. |
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Discord.WebSocket;
|
||||||
|
using MediatR;
|
||||||
|
using Victoria.Node;
|
||||||
|
using Victoria.Player;
|
||||||
|
|
||||||
|
namespace Lunaris2.Handler.MusicPlayer.SkipCommand;
|
||||||
|
|
||||||
|
public record SkipCommand(SocketSlashCommand Message) : IRequest;
|
||||||
|
|
||||||
|
public class SkipHandler : IRequestHandler<SkipCommand>
|
||||||
|
{
|
||||||
|
private readonly LavaNode _lavaNode;
|
||||||
|
private readonly DiscordSocketClient _client;
|
||||||
|
private readonly MusicEmbed _musicEmbed;
|
||||||
|
|
||||||
|
public SkipHandler(LavaNode lavaNode, DiscordSocketClient client, MusicEmbed musicEmbed)
|
||||||
|
{
|
||||||
|
_lavaNode = lavaNode;
|
||||||
|
_client = client;
|
||||||
|
_musicEmbed = musicEmbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(SkipCommand message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = message.Message;
|
||||||
|
|
||||||
|
await _lavaNode.EnsureConnected();
|
||||||
|
|
||||||
|
if (!_lavaNode.TryGetPlayer(context.GetGuild(_client), out var player)) {
|
||||||
|
await context.RespondAsync("I'm not connected to a voice channel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.PlayerState != PlayerState.Playing) {
|
||||||
|
await context.RespondAsync("Woaaah there, I can't skip when nothing is playing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Lunaris2.Handler.GoodByeCommand;
|
||||||
|
using Lunaris2.Handler.MusicPlayer.JoinCommand;
|
||||||
|
using Lunaris2.Handler.MusicPlayer.PlayCommand;
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
switch (notification.Message.CommandName)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Lunaris2.Helper;
|
||||||
|
|
||||||
|
public static class Async
|
||||||
|
{
|
||||||
|
public static void Run(Func<Task> task)
|
||||||
|
{
|
||||||
|
_ = Task.Run(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<UserSecretsId>ec2f340f-a44c-4869-ab79-a12ba9459d80</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<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="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
|
||||||
|
<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="OllamaSharp" Version="1.1.10" />
|
||||||
|
<PackageReference Include="Victoria" Version="6.0.23.324" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Discord.WebSocket;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace Lunaris2.Notification;
|
||||||
|
|
||||||
|
public class DiscordEventListener(DiscordSocketClient client, IServiceScopeFactory serviceScope)
|
||||||
|
{
|
||||||
|
private readonly CancellationToken _cancellationToken = new CancellationTokenSource().Token;
|
||||||
|
|
||||||
|
private IMediator Mediator
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var scope = serviceScope.CreateScope();
|
||||||
|
return scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync()
|
||||||
|
{
|
||||||
|
client.SlashCommandExecuted += OnSlashCommandRecievedAsync;
|
||||||
|
client.MessageReceived += OnMessageReceivedAsync;
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnMessageReceivedAsync(SocketMessage arg)
|
||||||
|
{
|
||||||
|
await Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnSlashCommandRecievedAsync(SocketSlashCommand arg)
|
||||||
|
{
|
||||||
|
await Mediator.Publish(new SlashCommandReceivedNotification(arg), _cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Discord.WebSocket;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Lunaris2.Notification;
|
||||||
|
|
||||||
|
public class MessageReceivedNotification(SocketMessage message) : INotification
|
||||||
|
{
|
||||||
|
public SocketMessage Message { get; } = message ?? throw new ArgumentNullException(nameof(message));
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Discord;
|
||||||
|
using Discord.Commands;
|
||||||
|
using Discord.Interactions;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Lunaris2.Handler.ChatCommand;
|
||||||
|
using Lunaris2.Handler.MusicPlayer;
|
||||||
|
using Lunaris2.Notification;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IHostBuilder CreateHostBuilder(string[] args) =>
|
||||||
|
Host.CreateDefaultBuilder(args)
|
||||||
|
.ConfigureServices((_, services) =>
|
||||||
|
{
|
||||||
|
var config = new DiscordSocketConfig
|
||||||
|
{
|
||||||
|
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 =>
|
||||||
|
{
|
||||||
|
nodeConfiguration.SelfDeaf = false;
|
||||||
|
nodeConfiguration.Hostname = configuration["LavaLinkHostname"];
|
||||||
|
nodeConfiguration.Port = Convert.ToUInt16(configuration["LavaLinkPort"]);
|
||||||
|
nodeConfiguration.Authorization = configuration["LavaLinkPassword"];
|
||||||
|
})
|
||||||
|
.AddSingleton<LavaNode>()
|
||||||
|
.AddSingleton<MusicEmbed>()
|
||||||
|
.AddSingleton<ChatSettings>()
|
||||||
|
.Configure<ChatSettings>(configuration.GetSection("LLM"));
|
||||||
|
|
||||||
|
client.Ready += () => Client_Ready(client);
|
||||||
|
client.Log += Log;
|
||||||
|
|
||||||
|
client
|
||||||
|
.LoginAsync(TokenType.Bot, configuration["Token"])
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
|
||||||
|
client
|
||||||
|
.StartAsync()
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
|
||||||
|
_lavaNode = services.BuildServiceProvider().GetRequiredService<LavaNode>();
|
||||||
|
|
||||||
|
var listener = services
|
||||||
|
.BuildServiceProvider()
|
||||||
|
.GetRequiredService<DiscordEventListener>();
|
||||||
|
|
||||||
|
listener
|
||||||
|
.StartAsync()
|
||||||
|
.GetAwaiter()
|
||||||
|
.GetResult();
|
||||||
|
});
|
||||||
|
|
||||||
|
private static async Task Client_Ready(DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
await _lavaNode.ConnectAsync();
|
||||||
|
client.RegisterCommands();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task Log(LogMessage arg)
|
||||||
|
{
|
||||||
|
Console.WriteLine(arg);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
## How commands from Discord gets executed
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Program[Program] -->|Register| EventListener
|
||||||
|
EventListener[DiscordEventListener] --> A[MessageReceivedHandler]
|
||||||
|
|
||||||
|
EventListener[DiscordEventListener] --> A2[SlashCommandReceivedHandler]
|
||||||
|
|
||||||
|
A --> |Message| f{If bot is mentioned}
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
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 TD
|
||||||
|
D[JoinHandler] --> Disc[Discord Api]
|
||||||
|
E[PlayHandler] --> Disc[Discord Api]
|
||||||
|
F[HelloHandler] --> Disc[Discord Api]
|
||||||
|
G[GoodbyeHandler] --> Disc[Discord Api]
|
||||||
|
v[ChatHandler] --> Disc[Discord Api]
|
||||||
|
v --> o[Ollama Server]
|
||||||
|
o --> v
|
||||||
|
E --> Lava[Lavalink]
|
||||||
|
```
|
||||||
|
|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. |
|
||||||
|
| HelloHandler| Responds with Hello. (Dummy handler, will be removed)|
|
||||||
|
| GoodbyeHandler| Responds with Goodbye. (Dummy handler, will be removed)|
|
||||||
|
| ChatHandler| Handles the logic for LLM chat with user. |
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using Discord;
|
||||||
|
|
||||||
|
namespace Lunaris2.SlashCommand;
|
||||||
|
|
||||||
|
public static class Option
|
||||||
|
{
|
||||||
|
public const string Input = "input";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Command
|
||||||
|
{
|
||||||
|
public static class Hello
|
||||||
|
{
|
||||||
|
public const string Name = "hello";
|
||||||
|
public const string Description = "Say hello to the bot!";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Goodbye
|
||||||
|
{
|
||||||
|
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 static class Skip
|
||||||
|
{
|
||||||
|
public const string Name = "skip";
|
||||||
|
public const string Description = "Skip the current song!";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Stop
|
||||||
|
{
|
||||||
|
public const string Name = "stop";
|
||||||
|
public const string Description = "Stop the music!";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Play
|
||||||
|
{
|
||||||
|
public const string Name = "play";
|
||||||
|
public const string Description = "Play a song!";
|
||||||
|
|
||||||
|
public static readonly List<SlashCommandOptionBuilder>? Options = new()
|
||||||
|
{
|
||||||
|
new SlashCommandOptionBuilder
|
||||||
|
{
|
||||||
|
Name = "input",
|
||||||
|
Description = "The song you want to play",
|
||||||
|
Type = ApplicationCommandOptionType.String,
|
||||||
|
IsRequired = true
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string[] GetAllCommands()
|
||||||
|
{
|
||||||
|
return typeof(Command)
|
||||||
|
.GetNestedTypes()
|
||||||
|
.Select(command => command.GetField("Name")?.GetValue(null)?.ToString())
|
||||||
|
.ToArray()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.Net;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Lunaris2.SlashCommand;
|
||||||
|
|
||||||
|
public class SlashCommandBuilder(
|
||||||
|
string commandName,
|
||||||
|
string commandDescription,
|
||||||
|
List<SlashCommandOptionBuilder>? commandOptions = null)
|
||||||
|
{
|
||||||
|
private string CommandName { get; set; } = commandName;
|
||||||
|
private string CommandDescription { get; set; } = commandDescription;
|
||||||
|
private List<SlashCommandOptionBuilder>? CommandOptions { get; set; } = commandOptions;
|
||||||
|
|
||||||
|
public async Task CreateSlashCommand(DiscordSocketClient client)
|
||||||
|
{
|
||||||
|
var registeredCommands = await client.GetGlobalApplicationCommandsAsync();
|
||||||
|
await RemoveUnusedCommands(Command.GetAllCommands(), registeredCommands);
|
||||||
|
|
||||||
|
if (await CommandExists(registeredCommands))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var globalCommand = new Discord.SlashCommandBuilder();
|
||||||
|
globalCommand.WithName(CommandName);
|
||||||
|
globalCommand.WithDescription(CommandDescription);
|
||||||
|
|
||||||
|
CommandOptions?.ForEach(option => globalCommand.AddOption(option));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.CreateGlobalApplicationCommandAsync(globalCommand.Build());
|
||||||
|
Console.WriteLine($"Command {CommandName} has been registered.");
|
||||||
|
}
|
||||||
|
catch(HttpException exception)
|
||||||
|
{
|
||||||
|
var json = JsonConvert.SerializeObject(exception.Errors, Formatting.Indented);
|
||||||
|
Console.WriteLine(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await command.DeleteAsync();
|
||||||
|
Console.WriteLine($"Command {command.Name} has been removed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task<bool> CommandExists(IEnumerable<SocketApplicationCommand> registeredCommands)
|
||||||
|
{
|
||||||
|
if (!registeredCommands.Any(command => command.Name == CommandName && command.Description == CommandDescription))
|
||||||
|
return Task.FromResult(false);
|
||||||
|
|
||||||
|
Console.WriteLine($"Command {CommandName} already exists.");
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Discord;
|
||||||
|
using Discord.WebSocket;
|
||||||
|
|
||||||
|
namespace Lunaris2.SlashCommand;
|
||||||
|
|
||||||
|
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.Skip.Name, Command.Skip.Description);
|
||||||
|
RegisterCommand(client, Command.Play.Name, Command.Play.Description, Command.Play.Options);
|
||||||
|
RegisterCommand(client, Command.Stop.Name, Command.Stop.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RegisterCommand(
|
||||||
|
DiscordSocketClient client,
|
||||||
|
string commandName,
|
||||||
|
string commandDescription,
|
||||||
|
List<SlashCommandOptionBuilder>? commandOptions = null)
|
||||||
|
{
|
||||||
|
var command = new SlashCommandBuilder(commandName, commandDescription, commandOptions);
|
||||||
|
_ = command.CreateSlashCommand(client);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"System": "Information",
|
||||||
|
"Microsoft": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Token": "discordToken",
|
||||||
|
"LavaLinkPassword": "youshallnotpass",
|
||||||
|
"LavaLinkHostname": "127.0.0.1",
|
||||||
|
"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:"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.9.34701.34
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lunaris2", "Bot/Lunaris2.csproj", "{3A16FD4C-EE50-4B05-9A75-0DDCD93AA615}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{3A16FD4C-EE50-4B05-9A75-0DDCD93AA615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3A16FD4C-EE50-4B05-9A75-0DDCD93AA615}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3A16FD4C-EE50-4B05-9A75-0DDCD93AA615}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3A16FD4C-EE50-4B05-9A75-0DDCD93AA615}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {4E738D71-9DB0-456E-9BF2-0CE3FED1DE44}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -1,2 +1,33 @@
|
|||||||
# Lunaris2.0
|
# Lunaris2 - Discord Music Bot
|
||||||
WIP
|
|
||||||
|
Lunaris2 is a Discord bot designed to play music in your server's voice channels. It's built using C# and the Discord.Net library, and it uses the LavaLink music client for audio streaming.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
1. Clone the repo.
|
||||||
|
2. Extract.
|
||||||
|
3. If there isn't already a appsettings.json file in there, create one.
|
||||||
|
4. Set the discord bot token. How the file should look (without token): [appsettings.json](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/appsettings.json)]
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
- `/play <song>`: Plays the specified song in the voice channel you're currently in.
|
||||||
|
- `/skip`: Skips the currently playing song.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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
|
||||||
|
lavalink:
|
||||||
|
plugins:
|
||||||
|
# - dependency: "group:artifact:version"
|
||||||
|
# repository: "repository"
|
||||||
|
server:
|
||||||
|
password: "youshallnotpass"
|
||||||
|
sources:
|
||||||
|
youtube: true
|
||||||
|
bandcamp: true
|
||||||
|
soundcloud: true
|
||||||
|
twitch: true
|
||||||
|
vimeo: true
|
||||||
|
http: true
|
||||||
|
local: false
|
||||||
|
filters: # All filters are enabled by default
|
||||||
|
volume: true
|
||||||
|
equalizer: true
|
||||||
|
karaoke: true
|
||||||
|
timescale: true
|
||||||
|
tremolo: true
|
||||||
|
vibrato: true
|
||||||
|
distortion: true
|
||||||
|
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.
|
||||||
|
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.
|
||||||
|
youtubePlaylistLoadLimit: 6 # Number of pages at 100 each
|
||||||
|
playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
|
||||||
|
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:
|
||||||
|
enabled: false
|
||||||
|
endpoint: /metrics
|
||||||
|
|
||||||
|
sentry:
|
||||||
|
dsn: ""
|
||||||
|
environment: ""
|
||||||
|
# tags:
|
||||||
|
# some_key: some_value
|
||||||
|
# another_key: another_value
|
||||||
|
|
||||||
|
logging:
|
||||||
|
file:
|
||||||
|
path: ./logs/
|
||||||
|
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
lavalink: INFO
|
||||||
|
|
||||||
|
request:
|
||||||
|
enabled: true
|
||||||
|
includeClientInfo: true
|
||||||
|
includeHeaders: false
|
||||||
|
includeQueryString: true
|
||||||
|
includePayload: true
|
||||||
|
maxPayloadLength: 10000
|
||||||
|
|
||||||
|
|
||||||
|
logback:
|
||||||
|
rollingpolicy:
|
||||||
|
max-file-size: 1GB
|
||||||
|
max-history: 30
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
services:
|
||||||
|
lavalink:
|
||||||
|
# pin the image version to Lavalink v4
|
||||||
|
image: ghcr.io/lavalink-devs/lavalink:3.7.11
|
||||||
|
container_name: lavalink
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# set Java options here
|
||||||
|
- _JAVA_OPTIONS=-Xmx6G
|
||||||
|
# set lavalink server port
|
||||||
|
- SERVER_PORT=2333
|
||||||
|
# set password for lavalink
|
||||||
|
- LAVALINK_SERVER_PASSWORD=youshallnotpass
|
||||||
|
volumes:
|
||||||
|
# mount application.yml from the same directory or use environment variables
|
||||||
|
- ./application.yml:/opt/Lavalink/application.yml
|
||||||
|
# persist plugins between restarts, make sure to set the correct permissions (user: 322, group: 322)
|
||||||
|
- ./plugins/:/opt/Lavalink/plugins/
|
||||||
|
networks:
|
||||||
|
- lavalink
|
||||||
|
expose:
|
||||||
|
# lavalink exposes port 2333 to connect to for other containers (this is for documentation purposes only)
|
||||||
|
- 2333
|
||||||
|
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.
@@ -0,0 +1,3 @@
|
|||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
read -p "Press enter to continue"
|
||||||
Reference in New Issue
Block a user