Compare commits

..

7 Commits

Author SHA1 Message Date
Myx
ae1a4e14d6 Add scheduler 2025-02-18 01:15:55 +01:00
Myx
5726c110a1 Refactor 2025-02-17 03:23:41 +01:00
3362c6bf8c Update README.md 2024-10-25 23:28:55 +02:00
a864944318 Update README.md 2024-10-25 22:08:25 +02:00
146455c1bd Add logotype 2024-10-25 22:07:06 +02:00
56eee11fc9 Update README.md 2024-10-25 21:56:21 +02:00
e01746a343 Update README.md 2024-10-25 21:54:44 +02:00
23 changed files with 712 additions and 242 deletions

View File

@@ -4,65 +4,64 @@ using MediatR;
using Microsoft.Extensions.Options;
using OllamaSharp;
namespace Lunaris2.Handler.ChatCommand
namespace Lunaris2.Handler.ChatCommand;
public record ChatCommand(SocketMessage Message, string FilteredMessage) : IRequest;
public class ChatHandler : IRequestHandler<ChatCommand>
{
public record ChatCommand(SocketMessage Message, string FilteredMessage) : IRequest;
private readonly OllamaApiClient _ollama;
private readonly Dictionary<ulong, Chat?> _chatContexts = new();
private readonly ChatSettings _chatSettings;
public class ChatHandler : IRequestHandler<ChatCommand>
public ChatHandler(IOptions<ChatSettings> chatSettings)
{
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)
{
_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();
}
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();
}
}

View File

@@ -68,9 +68,9 @@ public static class Extensions
await message.RespondAsync(content, ephemeral: true);
}
public static string GetOptionValueByName(this SocketSlashCommand command, string optionName)
public static T? GetOptionValueByName<T>(this SocketSlashCommand command, string optionName)
{
return command.Data.Options.FirstOrDefault(option => option.Name == optionName)?.Value.ToString() ?? string.Empty;
return (T?)(command.Data?.Options?
.FirstOrDefault(option => option.Name == optionName)?.Value ?? default(T));
}
}

View File

@@ -85,7 +85,7 @@ public class PlayHandler : IRequestHandler<PlayCommand>
return;
}
var searchQuery = context.GetOptionValueByName(Option.Input);
var searchQuery = context.GetOptionValueByName<string>(Option.Input);
if (string.IsNullOrWhiteSpace(searchQuery))
{

View File

@@ -136,4 +136,104 @@ sequenceDiagram
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.
## Extensions.cs
#### Namespaces
- **Discord**: Provides classes for interacting with Discord.
- **Discord.WebSocket**: Provides WebSocket-specific classes for Discord.
- **Lavalink4NET**: Provides classes for interacting with Lavalink.
- **Lavalink4NET.Players**: Provides player-related classes for Lavalink.
- **Lavalink4NET.Players.Queued**: Provides queued player-related classes for Lavalink.
- **Microsoft.Extensions.Options**: Provides classes for handling options and configurations.
#### Class: `Extensions`
This static class contains extension methods for various Discord and Lavalink operations.
##### Method: `GetPlayerAsync`
- **Parameters**:
- `IAudioService audioService`: The audio service to retrieve the player from.
- `DiscordSocketClient client`: The Discord client.
- `SocketSlashCommand context`: The context of the slash command.
- `bool connectToVoiceChannel`: Whether to connect to the voice channel (default is true).
- **Returns**: `ValueTask<QueuedLavalinkPlayer?>`
- **Description**: Retrieves a `QueuedLavalinkPlayer` for the given context. If the retrieval fails, it returns null and sends an appropriate error message.
##### Method: `GetGuild`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- `DiscordSocketClient client`: The Discord client.
- **Returns**: `SocketGuild`
- **Description**: Retrieves the guild associated with the given slash command message. Throws an exception if the guild ID is null.
##### Method: `GetVoiceState`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- **Returns**: `IVoiceState`
- **Description**: Retrieves the voice state of the user who issued the slash command. Throws an exception if the user is not connected to a voice channel.
##### Method: `RespondAsync`
- **Parameters**:
- `SocketSlashCommand message`: The slash command message.
- `string content`: The content of the response.
- **Returns**: `Task`
- **Description**: Sends an ephemeral response to the slash command.
##### Method: `GetOptionValueByName`
- **Parameters**:
- `SocketSlashCommand command`: The slash command.
- `string optionName`: The name of the option to retrieve the value for.
- **Returns**: `string`
- **Description**: Retrieves the value of the specified option from the slash command. Returns an empty string if the option is not found.
# MessageModule
The `MessageModule` class provides utility methods for sending and removing messages in a Discord guild using the Discord.Net library. It maintains a dictionary to keep track of message IDs for each guild, allowing for easy removal of messages when needed.
## Methods
### `SendMessageAsync(SocketSlashCommand context, string message, DiscordSocketClient client)`
Sends a follow-up message with the specified text content in response to a slash command.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `message`: The text content of the message to be sent.
- `client`: The `DiscordSocketClient` instance.
### `SendMessageAsync(SocketSlashCommand context, Embed message, DiscordSocketClient client)`
Sends a follow-up message with the specified embed content in response to a slash command.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `message`: The `Embed` content of the message to be sent.
- `client`: The `DiscordSocketClient` instance.
### `RemoveMessages(SocketSlashCommand context, DiscordSocketClient client)`
Removes all tracked messages for the guild in which the command was executed.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `client`: The `DiscordSocketClient` instance.
### `StoreForRemoval(SocketSlashCommand context, DiscordSocketClient client)`
Stores the message ID for removal and deletes any previously tracked messages for the guild.
- **Parameters:**
- `context`: The `SocketSlashCommand` context in which the command was executed.
- `client`: The `DiscordSocketClient` instance.
- **Returns:**
- The guild ID as a `ulong`.
## Usage
To use the `MessageModule` class, simply call the appropriate method from your command handling logic. For example:
```csharp
await context.SendMessageAsync("Hello, world!", client);
```
This will send a follow-up message with the text "Hello, world!" in response to the slash command.

View File

@@ -0,0 +1,30 @@
using Discord.WebSocket;
using MediatR;
namespace Lunaris2.Handler.Scheduler;
public class ProcessMessageCommand : IRequest
{
public ulong? Context { get; set; }
public string Content { get; set; }
}
public class ProcessMessageHandler(DiscordSocketClient client) : IRequestHandler<ProcessMessageCommand>
{
public Task Handle(ProcessMessageCommand request, CancellationToken cancellationToken)
{
if (request.Context == null)
return Task.FromResult(Unit.Value);
var channel = client.GetChannel(request.Context.Value) as ISocketMessageChannel;
if (channel == null)
return Task.FromResult(Unit.Value);
using var setTyping = channel.EnterTypingState();
channel.SendMessageAsync(request.Content);
setTyping.Dispose();
return Task.FromResult(Unit.Value);
}
}

View File

@@ -0,0 +1,137 @@
using System.Globalization;
using System.Text;
using Discord.WebSocket;
using Hangfire;
using Lunaris2.Handler.ChatCommand;
using Lunaris2.Handler.MusicPlayer;
using Lunaris2.SlashCommand;
using MediatR;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using NCrontab;
using OllamaSharp;
using static System.DateTime;
namespace Lunaris2.Handler.Scheduler;
public record ScheduleMessageCommand(SocketSlashCommand Message) : IRequest;
public class ScheduleMessageHandler : IRequestHandler<ScheduleMessageCommand>
{
private readonly ChatSettings _chatSettings;
private readonly OllamaApiClient _ollama;
private readonly ISender _mediator;
private readonly string _cronInstruction = "You are only able to respond in CRON Format. " +
"Current time is: " + Now.ToString("yyyy-MM-dd HH:mm") + ". and it is " +
Now.DayOfWeek + ". " +
"Please use the a format parsable by ncrontab." +
"The user will describe the CRON format and you can only answer with the CRON format the user describes.";
private readonly string _dateInstruction = "You are only able to respond in Date Format. " +
"Current time is: " + Now.ToString("dd/MM/yyyy HH:mm:ss") + ". and it is " +
Now.DayOfWeek + ". " +
"Please use the following format: dd/MM/yyyy HH:mm:ss. Convert following to date string with the current time as a context";
public ScheduleMessageHandler(
IOptions<ChatSettings> chatSettings,
ISender mediator)
{
_mediator = mediator;
_chatSettings = chatSettings.Value;
var uri = new Uri(_chatSettings.Url);
_ollama = new OllamaApiClient(uri)
{
SelectedModel = _chatSettings.Model
};
}
public async Task Handle(ScheduleMessageCommand request, CancellationToken cancellationToken)
{
var userDateInput = request.Message.GetOptionValueByName<string>(Option.Time);
var userMessage = request.Message.GetOptionValueByName<string>(Option.Message);
var recurring = request.Message.GetOptionValueByName<bool>(Option.IsRecurring);
if (recurring)
{
await ScheduleRecurringJob(request, userMessage, userDateInput, cancellationToken);
}
else
{
await ScheduleJob(request, userMessage, userDateInput, cancellationToken);
await request.Message.Channel.SendMessageAsync("Message scheduled successfully.");
}
}
private async Task ScheduleRecurringJob(
ScheduleMessageCommand request,
string message,
string userDateInput,
CancellationToken cancellationToken)
{
using var setTyping = request.Message.Channel.EnterTypingState();
var cron = string.Empty;
var jobManager = new RecurringJobManager();
const int retries = 5;
var userMessage = $"{_cronInstruction}: {userDateInput}";
for (var tries = 0; tries < retries; tries++)
{
var textToCronResponse = await GenerateResponse(userMessage, cancellationToken);
var isValid = CrontabSchedule.TryParse(textToCronResponse).ToString().IsNullOrEmpty();
if(isValid)
{
await request.Message.Channel.SendMessageAsync("Sorry, I didn't understand that date format. Please try again.");
continue;
}
cron = textToCronResponse;
break;
}
var recurringJobId = $"channel_{request.Message.ChannelId}_{request.Message.Id}";
jobManager.AddOrUpdate(
recurringJobId,
() => _mediator.Send(new ProcessMessageCommand { Context = request.Message.ChannelId, Content = message}, cancellationToken),
cron
);
setTyping.Dispose();
await request.Message.Channel.SendMessageAsync("Message scheduled successfully.");
}
private async Task ScheduleJob(
ScheduleMessageCommand request,
string userMessage,
string executeAt,
CancellationToken cancellationToken
)
{
var dateFormat = $"{_dateInstruction}: {executeAt}";
var formattedDate = await GenerateResponse(dateFormat, cancellationToken);
var date = ParseExact(formattedDate, "dd/MM/yyyy HH:mm:ss", CultureInfo.CurrentCulture);
BackgroundJob.Schedule(
() => _mediator.Send(
new ProcessMessageCommand { Context = request.Message.ChannelId, Content = userMessage },
cancellationToken),
date);
}
private async Task<string> GenerateResponse(string userMessage, CancellationToken cancellationToken)
{
var response = new StringBuilder();
var chatContext = _ollama.Chat(stream => response.Append(stream.Message?.Content ?? ""));
await chatContext.Send(userMessage, cancellationToken);
return response.ToString();
}
}

View File

@@ -4,6 +4,7 @@ using Lunaris2.Handler.MusicPlayer.PauseCommand;
using Lunaris2.Handler.MusicPlayer.PlayCommand;
using Lunaris2.Handler.MusicPlayer.ResumeCommand;
using Lunaris2.Handler.MusicPlayer.SkipCommand;
using Lunaris2.Handler.Scheduler;
using Lunaris2.Notification;
using Lunaris2.SlashCommand;
using MediatR;
@@ -36,6 +37,9 @@ public class SlashCommandReceivedHandler(ISender mediator) : INotificationHandle
case Command.Clear.Name:
await mediator.Send(new ClearQueueCommand(notification.Message), cancellationToken);
break;
case Command.Scheduler.Name:
await mediator.Send(new ScheduleMessageCommand(notification.Message), cancellationToken);
break;
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -16,17 +16,25 @@
<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="Hangfire" Version="1.8.17" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageReference Include="Hangfire.Core" Version="1.8.18" />
<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="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<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.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="NCrontab" Version="3.3.3" />
<PackageReference Include="OllamaSharp" Version="1.1.10" />
</ItemGroup>
@@ -37,6 +45,12 @@
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<Resource Include="wwwroot\index.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="wwwroot\logotype.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
</ItemGroup>
</Project>

View File

@@ -1,6 +1,5 @@
using Discord.WebSocket;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace Lunaris2.Notification;

View File

@@ -1,103 +1,27 @@
using System.Reflection;
using Discord;
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 Lavalink4NET.Integrations.SponsorBlock.Extensions;
using Lunaris2.Registration;
namespace Lunaris2;
var builder = WebApplication.CreateBuilder(args);
public class Program
{
public static void Main(string[] args)
{
AppDomain.CurrentDomain.UnhandledException += (sender, eventArgs) =>
{
Console.WriteLine(eventArgs.ExceptionObject);
};
var app = CreateHostBuilder(args).Build();
app.UseSponsorBlock();
app.Run();
}
// Build configuration (using appsettings.json)
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.Build();
private static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices((_, services) =>
{
var config = new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.All
};
var client = new DiscordSocketClient(config);
var configuration = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json")
.Build();
// Register your services
builder.Services.AddDiscordBot(configuration);
builder.Services.AddScheduler(configuration);
builder.Services.AddControllers();
services
.AddMediatR(mediatRServiceConfiguration => mediatRServiceConfiguration.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
.AddLavalink()
.ConfigureLavalink(options =>
{
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<MusicEmbed>()
.AddSingleton<ChatSettings>()
.AddSingleton(client)
.AddSingleton<DiscordEventListener>()
.AddSingleton<VoiceChannelMonitorService>()
.AddSingleton(service => new InteractionService(service.GetRequiredService<DiscordSocketClient>()))
.Configure<ChatSettings>(configuration.GetSection("LLM"));
var app = builder.Build();
client.Ready += () => Client_Ready(client);
client.Log += Log;
client
.LoginAsync(TokenType.Bot, configuration["Token"])
.GetAwaiter()
.GetResult();
client
.StartAsync()
.GetAwaiter()
.GetResult();
// Call your custom middleware (e.g., for SponsorBlock functionality)
app.UseSponsorBlock();
var listener = services
.BuildServiceProvider()
.GetRequiredService<DiscordEventListener>();
listener
.StartAsync()
.GetAwaiter()
.GetResult();
});
// Serve static files
app.UseDefaultFiles();
app.UseStaticFiles();
private static Task Client_Ready(DiscordSocketClient client)
{
client.RegisterCommands();
new VoiceChannelMonitorService(client).StartMonitoring();
return Task.CompletedTask;
}
private static Task Log(LogMessage arg)
{
Console.WriteLine(arg);
return Task.CompletedTask;
}
}
app.UseHangfireDashboardAndServer();
app.Run();

View File

@@ -0,0 +1,14 @@
using Lunaris2.Handler.ChatCommand;
namespace Lunaris2.Registration;
public static class ChatRegistration
{
public static IServiceCollection AddChat(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<ChatSettings>();
services.Configure<ChatSettings>(configuration.GetSection("LLM"));
return services;
}
}

View File

@@ -0,0 +1,69 @@
using System.Reflection;
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using Lunaris2.Notification;
using Lunaris2.Service;
using Lunaris2.SlashCommand;
namespace Lunaris2.Registration;
public static class DiscordBotRegistration
{
public static IServiceCollection AddDiscordBot(this IServiceCollection services, IConfiguration configuration)
{
var config = new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.All
};
var client = new DiscordSocketClient(config);
services
.AddMediatR(mediatRServiceConfiguration =>
mediatRServiceConfiguration.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()))
.AddMusicPlayer(configuration)
.AddSingleton(client)
.AddSingleton<DiscordEventListener>()
.AddSingleton(service => new InteractionService(service.GetRequiredService<DiscordSocketClient>()))
.AddChat(configuration);
client.Ready += () => Client_Ready(client);
client.Log += Log;
client
.LoginAsync(TokenType.Bot, configuration["Token"])
.GetAwaiter()
.GetResult();
client
.StartAsync()
.GetAwaiter()
.GetResult();
var listener = services
.BuildServiceProvider()
.GetRequiredService<DiscordEventListener>();
listener
.StartAsync()
.GetAwaiter()
.GetResult();
return services;
}
private static Task Client_Ready(DiscordSocketClient client)
{
client.RegisterCommands();
new VoiceChannelMonitorService(client).StartMonitoring();
return Task.CompletedTask;
}
private static Task Log(LogMessage arg)
{
Console.WriteLine(arg);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,19 @@
using Hangfire;
namespace Lunaris2.Registration;
public static class HangfireRegistration
{
public static IApplicationBuilder UseHangfireDashboardAndServer(this IApplicationBuilder app, string dashboardPath = "/hangfire")
{
var dashboardOptions = new DashboardOptions
{
DarkModeEnabled = true,
DashboardTitle = "Lunaris Jobs Dashboard"
};
app.UseHangfireDashboard(dashboardPath, dashboardOptions);
return app;
}
}

View File

@@ -0,0 +1,27 @@
using Lavalink4NET.Extensions;
using Lunaris2.Handler.MusicPlayer;
using Lunaris2.Service;
namespace Lunaris2.Registration;
public static class MusicPlayerRegistration
{
public static IServiceCollection AddMusicPlayer(this IServiceCollection services, IConfiguration configuration)
{
services
.AddLavalink()
.ConfigureLavalink(options =>
{
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<MusicEmbed>()
.AddSingleton<VoiceChannelMonitorService>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
using Hangfire;
using Hangfire.AspNetCore;
using Lunaris2.Handler.Scheduler;
namespace Lunaris2.Registration;
public static class SchedulerRegistration
{
public static IServiceCollection AddScheduler(this IServiceCollection services, IConfiguration configuration)
{
services.AddHangfire((serviceProvider, config) =>
{
config.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer();
config.UseSqlServerStorage(configuration.GetValue<string>("HangfireConnectionString"));
});
services.AddHangfireServer();
// Register your handler
// services.AddScoped<ScheduleMessageHandler>();
return services;
}
}

View File

@@ -1,96 +1,95 @@
using Discord;
using Discord.WebSocket;
namespace Lunaris2.Service
namespace Lunaris2.Service;
public class VoiceChannelMonitorService
{
public class VoiceChannelMonitorService
private readonly DiscordSocketClient _client;
private readonly Dictionary<ulong, Timer> _timers = new();
public VoiceChannelMonitorService(DiscordSocketClient client)
{
private readonly DiscordSocketClient _client;
private readonly Dictionary<ulong, Timer> _timers = new();
_client = client;
}
public VoiceChannelMonitorService(DiscordSocketClient client)
public void StartMonitoring()
{
Task.Run(async () =>
{
_client = client;
}
public void StartMonitoring()
{
Task.Run(async () =>
while (true)
{
while (true)
{
await CheckVoiceChannels();
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute
}
});
}
await CheckVoiceChannels();
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute
}
});
}
private async Task CheckVoiceChannels()
{
SetStatus();
await LeaveOnAlone();
}
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
);
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);
}
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()
private async Task LeaveOnAlone()
{
foreach (var guild in _client.Guilds)
{
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));
// 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 (voiceChannel != null)
{
// If timer not set for this channel, start one
if (!_timers.ContainsKey(voiceChannel.Id))
{
// 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}");
}
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
}
}
}
private async Task LeaveChannel(SocketVoiceChannel voiceChannel)
{
if (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id))
else
{
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
// 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
}
}
}

View File

@@ -5,6 +5,9 @@ namespace Lunaris2.SlashCommand;
public static class Option
{
public const string Input = "input";
public const string Time = "time";
public const string IsRecurring = "repeating";
public const string Message = "message";
}
public static class Command
@@ -56,6 +59,38 @@ public static class Command
};
}
public static class Scheduler
{
public const string Name = "scheduler";
public const string Description = "Schedule a message";
public static readonly List<SlashCommandOptionBuilder>? Options =
[
new SlashCommandOptionBuilder
{
Name = "message",
Description = "The message you want to schedule",
Type = ApplicationCommandOptionType.String,
IsRequired = true
},
new SlashCommandOptionBuilder
{
Name = "time",
Description = "The time you want to schedule the message",
Type = ApplicationCommandOptionType.String,
IsRequired = true
},
new SlashCommandOptionBuilder
{
Name = "repeating",
Description = "Whether the message should repeat",
Type = ApplicationCommandOptionType.Boolean,
IsRequired = false,
IsDefault = false
}
];
}
public static string[] GetAllCommands()
{
return typeof(Command)

View File

@@ -14,6 +14,7 @@ public static class SlashCommandRegistration
RegisterCommand(client, Command.Play.Name, Command.Play.Description, Command.Play.Options);
RegisterCommand(client, Command.Resume.Name, Command.Resume.Description);
RegisterCommand(client, Command.Clear.Name, Command.Clear.Description);
RegisterCommand(client, Command.Scheduler.Name, Command.Scheduler.Description, Command.Scheduler.Options);
}
private static void RegisterCommand(

View File

@@ -19,5 +19,6 @@
"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:"
}
]
}
},
"HangfireConnectionString": "Server=localhost, 1433;Database=Hangfire;User Id=sa;Password=SecretPassword!; TrustServerCertificate=True;"
}

55
Bot/wwwroot/index.html Normal file
View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logotype Page</title>
<style>
body {
margin: 0;
height: 100vh;
background-color: #121212; /* Dark background */
display: flex;
justify-content: center;
align-items: center;
color: #d3d3d3; /* Very light gray text */
font-family: Arial, sans-serif;
text-align: center;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.logotype img {
max-width: 100%;
height: auto;
max-height: 200px;
margin-bottom: 20px;
box-shadow: 0px 0px 20px 20px black;
}
a {
color: #d3d3d3;
text-decoration: none;
font-size: 18px;
}
a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="logotype">
<img src="logotype.png" alt="Logotype">
</div>
<a href="/" id="hangfire-link">Go to Hangfire</a>
</div>
<script>
// Update the link dynamically to include the current URL + /hangfire
const currentUrl = window.location.href;
document.getElementById('hangfire-link').href = currentUrl + 'hangfire';
</script>
</body>
</html>

BIN
Bot/wwwroot/logotype.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

View File

@@ -1,15 +1,17 @@
# Lunaris2 - Discord Music Bot
![Lunaris Logotype](./Bot/wwwroot/logotype.png)
# Lunaris - Discord BOT
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
## 🎮Features
- Play music from YouTube directly in your Discord server.
- Skip tracks, pause, and resume playback.
- Skip tracks, pause, resume playback and more music related commands.
- 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
## 🤖 Setup
1. Clone the repo.
2. Extract.
@@ -27,7 +29,8 @@ The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). R
## PM2 Setup
- Install PM2 and configure it following their setup guide
#### Lavalink
#### 🐦‍🔥 Lavalink
* Download Lavalink 4.X.X (.jar)
* Install Java 17
@@ -46,6 +49,11 @@ Register the Lunaris bot with PM2:
- `/play <song>`: Plays the specified song in the voice channel you're currently in.
- `/skip`: Skips the currently playing song.
## Technical Documentations
- [Application Layout](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/README.md)
* 🤖 [AI CHAT](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/Handler/ChatCommand/readme.md)
* 🎵 [Music Player](https://github.com/Myxelium/Lunaris2.0/tree/master/Bot/Handler/MusicPlayer)
## Contributing
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

View File

@@ -64,6 +64,15 @@ services:
networks:
- ollama-docker
mssql:
image: mcr.microsoft.com/mssql/server:2019-latest
container_name: mssql
environment:
SA_PASSWORD: "SecretPassword!"
ACCEPT_EULA: "Y"
ports:
- "1433:1433"
volumes:
ollama: {}