Compare commits

..

3 Commits

Author SHA1 Message Date
e789da1e7e Update Lunaris2.csproj 2026-02-13 21:15:04 +01:00
85e3145f03 Update Contributing section with emoji 2026-02-13 21:11:00 +01:00
430aad71fb Add jellyfin source (#10) 2026-02-13 21:03:21 +01:00
23 changed files with 436 additions and 683 deletions

View File

@@ -4,64 +4,65 @@ using MediatR;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using OllamaSharp; using OllamaSharp;
namespace Lunaris2.Handler.ChatCommand; namespace Lunaris2.Handler.ChatCommand
public record ChatCommand(SocketMessage Message, string FilteredMessage) : IRequest;
public class ChatHandler : IRequestHandler<ChatCommand>
{ {
private readonly OllamaApiClient _ollama; public record ChatCommand(SocketMessage Message, string FilteredMessage) : IRequest;
private readonly Dictionary<ulong, Chat?> _chatContexts = new();
private readonly ChatSettings _chatSettings;
public ChatHandler(IOptions<ChatSettings> chatSettings) public class ChatHandler : IRequestHandler<ChatCommand>
{ {
_chatSettings = chatSettings.Value; private readonly OllamaApiClient _ollama;
var uri = new Uri(chatSettings.Value.Url); private readonly Dictionary<ulong, Chat?> _chatContexts = new();
private readonly ChatSettings _chatSettings;
_ollama = new OllamaApiClient(uri)
public ChatHandler(IOptions<ChatSettings> chatSettings)
{ {
SelectedModel = chatSettings.Value.Model _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) 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?"); 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(); 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) 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 ?? "")); 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();
} }
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); await message.RespondAsync(content, ephemeral: true);
} }
public static T? GetOptionValueByName<T>(this SocketSlashCommand command, string optionName)
public static string GetOptionValueByName(this SocketSlashCommand command, string optionName)
{ {
return (T?)(command.Data?.Options? return command.Data.Options.FirstOrDefault(option => option.Name == optionName)?.Value.ToString() ?? string.Empty;
.FirstOrDefault(option => option.Name == optionName)?.Value ?? default(T));
} }
} }

View File

@@ -6,6 +6,7 @@ using Lavalink4NET;
using Lavalink4NET.Events.Players; using Lavalink4NET.Events.Players;
using Lavalink4NET.Integrations.SponsorBlock; using Lavalink4NET.Integrations.SponsorBlock;
using Lavalink4NET.Integrations.SponsorBlock.Extensions; using Lavalink4NET.Integrations.SponsorBlock.Extensions;
using Lavalink4NET.Jellyfin;
using Lavalink4NET.Players.Queued; using Lavalink4NET.Players.Queued;
using Lavalink4NET.Rest.Entities.Tracks; using Lavalink4NET.Rest.Entities.Tracks;
using Lavalink4NET.Tracks; using Lavalink4NET.Tracks;
@@ -19,8 +20,7 @@ public class PlayHandler : IRequestHandler<PlayCommand>
private readonly MusicEmbed _musicEmbed; private readonly MusicEmbed _musicEmbed;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly IAudioService _audioService; private readonly IAudioService _audioService;
private SocketSlashCommand _context; private SocketSlashCommand _context = default!; // ensure initialized before use
private const int MaxTrackDuration = 30;
private LavalinkTrack? _previousTrack; private LavalinkTrack? _previousTrack;
private static readonly HashSet<ulong> SubscribedGuilds = new(); private static readonly HashSet<ulong> SubscribedGuilds = new();
@@ -34,15 +34,16 @@ public class PlayHandler : IRequestHandler<PlayCommand>
_audioService = audioService; _audioService = audioService;
} }
private async Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs) private Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
{ {
// Reset the previous track when the track ends. // Reset the previous track when the track ends.
_previousTrack = null; _previousTrack = null;
return Task.CompletedTask;
} }
private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs) private async Task OnTrackStarted(object sender, TrackStartedEventArgs eventargs)
{ {
var player = sender as QueuedLavalinkPlayer; var player = eventargs.Player as QueuedLavalinkPlayer;
if (player?.CurrentTrack is null) if (player?.CurrentTrack is null)
{ {
@@ -60,119 +61,132 @@ public class PlayHandler : IRequestHandler<PlayCommand>
// Track has changed, update the previous track and send the embed // Track has changed, update the previous track and send the embed
_previousTrack = currentTrack; _previousTrack = currentTrack;
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client); if (_context != null)
{
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
}
} }
public Task Handle(PlayCommand command, CancellationToken cancellationToken) public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
{ {
new Thread(PlayMusic).Start(); try
return Task.CompletedTask;
async void PlayMusic()
{ {
try var context = command.Message;
_context = context;
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
{ {
RegisterTrackStartedEventListerner(command); await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
return;
await _audioService.StartAsync(cancellationToken); }
var context = command.Message; var searchQuery = context.GetOptionValueByName(Option.Input);
_context = context;
if (string.IsNullOrWhiteSpace(searchQuery))
if ((context.User as SocketGuildUser)?.VoiceChannel == null) {
await context.SendMessageAsync("Please provide search terms.", _client);
return;
}
RegisterTrackStartedEventListerner(command);
await _audioService.StartAsync(cancellationToken);
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);
// Parse the query to extract search mode and clean query
// Supports prefixes like jfsearch:, ytsearch:, scsearch:, etc.
// Default: Jellyfin (jfsearch:) when no prefix is specified
var (searchMode, queryToSearch) = SearchQueryParser.Parse(searchQuery, JellyfinSearchMode.Jellyfin);
var trackLoadOptions = new TrackLoadOptions
{
SearchMode = searchMode,
};
var trackCollection = await _audioService.Tracks.LoadTracksAsync(queryToSearch, 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))
{ {
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client); // Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
return; // Queue only the first track of the search result
} // var firstTrack = trackCollection.Tracks.FirstOrDefault();
if (trackCollection.Track != null)
var searchQuery = context.GetOptionValueByName<string>(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. await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
// Queue only the first track of the search result // rely on TrackStarted event to send Now Playing
// 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 else
{ {
await context.SendMessageAsync("No tracks found.", _client); 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);
}
} }
catch (Exception error) else
{ {
throw new Exception("Error occured in the Play handler!", error); // 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);
// rely on TrackStarted event to send Now Playing
}
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) private void RegisterTrackStartedEventListerner(PlayCommand command)
{ {
if (SubscribedGuilds.Contains((ulong)command.Message.GuildId!)) var guildId = command.Message.GuildId;
if (!guildId.HasValue)
{
// Ignore registration for DMs or contexts without a guild.
return; return;
}
var gid = guildId.Value;
if (SubscribedGuilds.Contains(gid))
return;
// Ensure we don't accidentally double-subscribe.
_audioService.TrackStarted -= OnTrackStarted;
_audioService.TrackEnded -= OnTrackEnded;
_audioService.TrackStarted += OnTrackStarted; _audioService.TrackStarted += OnTrackStarted;
_audioService.TrackEnded += OnTrackEnded; _audioService.TrackEnded += OnTrackEnded;
SubscribedGuilds.Add((ulong)command.Message.GuildId!); SubscribedGuilds.Add(gid);
} }
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player) private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
@@ -193,4 +207,4 @@ public class PlayHandler : IRequestHandler<PlayCommand>
await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken); await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken);
} }
} }

View File

@@ -1,30 +0,0 @@
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

@@ -1,137 +0,0 @@
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,7 +4,6 @@ using Lunaris2.Handler.MusicPlayer.PauseCommand;
using Lunaris2.Handler.MusicPlayer.PlayCommand; using Lunaris2.Handler.MusicPlayer.PlayCommand;
using Lunaris2.Handler.MusicPlayer.ResumeCommand; using Lunaris2.Handler.MusicPlayer.ResumeCommand;
using Lunaris2.Handler.MusicPlayer.SkipCommand; using Lunaris2.Handler.MusicPlayer.SkipCommand;
using Lunaris2.Handler.Scheduler;
using Lunaris2.Notification; using Lunaris2.Notification;
using Lunaris2.SlashCommand; using Lunaris2.SlashCommand;
using MediatR; using MediatR;
@@ -37,9 +36,6 @@ public class SlashCommandReceivedHandler(ISender mediator) : INotificationHandle
case Command.Clear.Name: case Command.Clear.Name:
await mediator.Send(new ClearQueueCommand(notification.Message), cancellationToken); await mediator.Send(new ClearQueueCommand(notification.Message), cancellationToken);
break; 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.Web"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -16,26 +16,19 @@
<PackageReference Include="Discord.Net.Core" 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.Interactions" Version="3.16.0" />
<PackageReference Include="Discord.Net.Rest" 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" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Artwork" Version="4.0.25" /> <PackageReference Include="Lavalink4NET.Artwork" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Discord.NET" 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.Lavasrc" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" /> <PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" />
<PackageReference Include="MediatR" Version="12.4.1" /> <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" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="NCrontab" Version="3.3.3" />
<PackageReference Include="OllamaSharp" Version="1.1.10" /> <PackageReference Include="OllamaSharp" Version="1.1.10" />
<PackageReference Include="Lavalink4NET.Jellyfin" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -45,12 +38,6 @@
<None Update="appsettings.json"> <None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<Resource Include="wwwroot\index.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="wwwroot\logotype.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
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

@@ -1,69 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,27 +0,0 @@
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

@@ -1,26 +0,0 @@
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,95 +1,188 @@
using Discord; using Discord;
using Discord.WebSocket; using Discord.WebSocket;
namespace Lunaris2.Service; namespace Lunaris2.Service
public class VoiceChannelMonitorService
{ {
private readonly DiscordSocketClient _client; public class VoiceChannelMonitorService
private readonly Dictionary<ulong, Timer> _timers = new();
public VoiceChannelMonitorService(DiscordSocketClient client)
{ {
_client = client; private readonly DiscordSocketClient _client;
} // Track a cancellation source per voice channel when the bot is alone
private readonly Dictionary<ulong, CancellationTokenSource> _leaveCtsByChannel = new();
public void StartMonitoring() public VoiceChannelMonitorService(DiscordSocketClient client)
{
Task.Run(async () =>
{ {
while (true) _client = client;
{ // Subscribe to voice state updates to react immediately
await CheckVoiceChannels(); _client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute }
}
});
}
private async Task CheckVoiceChannels() public void StartMonitoring()
{ {
SetStatus(); Task.Run(async () =>
await LeaveOnAlone(); {
} while (true)
{
await CheckVoiceChannels();
await Task.Delay(TimeSpan.FromMinutes(1)); // Status refresh every minute
}
});
}
private async Task CheckVoiceChannels()
{
SetStatus();
await EnsureCurrentAloneStatesScheduled();
}
private void SetStatus() private void SetStatus()
{ {
var channels = _client.Guilds var channels = _client.Guilds
.SelectMany(guild => guild.VoiceChannels) .SelectMany(guild => guild.VoiceChannels)
.Count(channel => .Count(channel =>
channel.ConnectedUsers channel.ConnectedUsers
.Any(guildUser => guildUser.Id == _client.CurrentUser.Id) && .Any(guildUser => guildUser.Id == _client.CurrentUser.Id) &&
channel.Users.Count > 1 channel.Users.Count > 1
); );
if (channels == 0) if (channels == 0)
_client.SetGameAsync(System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), type: ActivityType.CustomStatus); _client.SetGameAsync(System.Reflection.Assembly.GetEntryAssembly()?.GetName().Version?.ToString(), type: ActivityType.CustomStatus);
else if(channels == 1) else if(channels == 1)
_client.SetGameAsync("in 1 server", type: ActivityType.Playing); _client.SetGameAsync("in 1 server", type: ActivityType.Playing);
else if(channels > 1) else if(channels > 1)
_client.SetGameAsync($" in {channels} servers", type: ActivityType.Playing); _client.SetGameAsync($" in {channels} servers", type: ActivityType.Playing);
} }
private async Task LeaveOnAlone() // Monitor existing alone states during the periodic check to ensure timers exist
{ private async Task EnsureCurrentAloneStatesScheduled()
foreach (var guild in _client.Guilds)
{ {
// Find voice channels where only the bot is left foreach (var guild in _client.Guilds)
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 foreach (var voiceChannel in guild.VoiceChannels)
if (!_timers.ContainsKey(voiceChannel.Id))
{ {
Console.WriteLine($"Bot is alone in channel {voiceChannel.Name}, starting timer..."); var botInChannel = voiceChannel.ConnectedUsers.Any(u => u.Id == _client.CurrentUser.Id);
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null, var userCount = voiceChannel.ConnectedUsers.Count;
TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Set delay before leaving
if (botInChannel && userCount == 1)
{
// Schedule leave if not already scheduled
if (!_leaveCtsByChannel.ContainsKey(voiceChannel.Id))
{
ScheduleLeave(voiceChannel);
}
}
else
{
// Cancel if a schedule exists but the bot is not alone anymore
if (_leaveCtsByChannel.TryGetValue(voiceChannel.Id, out var cts))
{
cts.Cancel();
_leaveCtsByChannel.Remove(voiceChannel.Id);
}
}
} }
} }
else
await Task.CompletedTask;
}
private Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState before, SocketVoiceState after)
{
// React only when events relate to the guild(s) and voice channels where the bot might be
var botId = _client.CurrentUser.Id;
// Determine affected channels
var beforeChannelId = before.VoiceChannel?.Id;
var afterChannelId = after.VoiceChannel?.Id;
// If the bot itself moved, we should cancel any old schedule and possibly set a new one
if (user.Id == botId)
{ {
// Clean up timer if channel is no longer active if (beforeChannelId.HasValue && _leaveCtsByChannel.TryGetValue(beforeChannelId.Value, out var oldCts))
var timersToDispose = _timers.Where(t => guild.VoiceChannels.All(vc => vc.Id != t.Key)).ToList();
foreach (var timer in timersToDispose)
{ {
await timer.Value.DisposeAsync(); oldCts.Cancel();
_timers.Remove(timer.Key); _leaveCtsByChannel.Remove(beforeChannelId.Value);
Console.WriteLine($"Disposed timer for inactive voice channel ID: {timer.Key}"); }
if (afterChannelId.HasValue)
{
var channel = after.VoiceChannel!;
var isAlone = channel.ConnectedUsers.Count == 1 && channel.ConnectedUsers.Any(u => u.Id == botId);
if (isAlone && !_leaveCtsByChannel.ContainsKey(channel.Id))
{
ScheduleLeave(channel);
}
}
return Task.CompletedTask;
}
// For other users, if they join the bot's channel, cancel the leave; if they leave and bot becomes alone, schedule leave
if (afterChannelId.HasValue)
{
var channel = after.VoiceChannel!;
var botInChannel = channel.ConnectedUsers.Any(u => u.Id == botId);
var userCount = channel.ConnectedUsers.Count;
if (botInChannel && userCount > 1)
{
// Cancel any pending leave
if (_leaveCtsByChannel.TryGetValue(channel.Id, out var cts))
{
cts.Cancel();
_leaveCtsByChannel.Remove(channel.Id);
}
} }
} }
}
}
private async Task LeaveChannel(SocketVoiceChannel voiceChannel) if (beforeChannelId.HasValue)
{ {
if (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id)) var channel = before.VoiceChannel!; // user left this channel
var botInChannel = channel.ConnectedUsers.Any(u => u.Id == botId);
var userCount = channel.ConnectedUsers.Count;
if (botInChannel && userCount == 1)
{
// Bot became alone, schedule leave
if (!_leaveCtsByChannel.ContainsKey(channel.Id))
{
ScheduleLeave(channel);
}
}
}
return Task.CompletedTask;
}
private void ScheduleLeave(SocketVoiceChannel voiceChannel)
{ {
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity..."); var cts = new CancellationTokenSource();
await voiceChannel.DisconnectAsync(); _leaveCtsByChannel[voiceChannel.Id] = cts;
await _timers[voiceChannel.Id].DisposeAsync();
_timers.Remove(voiceChannel.Id); // Clean up after leaving _ = Task.Run(async () =>
{
try
{
await Task.Delay(TimeSpan.FromMinutes(3), cts.Token);
// After delay, verify still alone
var botId = _client.CurrentUser.Id;
var isStillAlone = voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.ConnectedUsers.Any(u => u.Id == botId);
if (isStillAlone)
{
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity...");
await voiceChannel.DisconnectAsync();
}
}
catch (OperationCanceledException)
{
// Cancelled because someone joined or bot moved
}
finally
{
_leaveCtsByChannel.Remove(voiceChannel.Id);
cts.Dispose();
}
});
} }
} }
} }

View File

@@ -5,9 +5,6 @@ namespace Lunaris2.SlashCommand;
public static class Option public static class Option
{ {
public const string Input = "input"; public const string Input = "input";
public const string Time = "time";
public const string IsRecurring = "repeating";
public const string Message = "message";
} }
public static class Command public static class Command
@@ -59,38 +56,6 @@ 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() public static string[] GetAllCommands()
{ {
return typeof(Command) return typeof(Command)

View File

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

View File

@@ -19,6 +19,5 @@
"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:" "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;"
} }

View File

@@ -1,55 +0,0 @@
<!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>

View File

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 230 KiB

View File

@@ -1,4 +1,4 @@
![Lunaris Logotype](./Bot/wwwroot/logotype.png) ![Lunaris Logotype](https://github.com/Myxelium/Lunaris2.0/blob/master/LOGOTYPE.png?raw=true)
# Lunaris - Discord BOT # Lunaris - Discord BOT
@@ -54,6 +54,6 @@ Register the Lunaris bot with PM2:
* 🤖 [AI CHAT](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/Handler/ChatCommand/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) * 🎵 [Music Player](https://github.com/Myxelium/Lunaris2.0/tree/master/Bot/Handler/MusicPlayer)
## Contributing ## Contributing 🐈
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.

View File

@@ -2,6 +2,14 @@ server: # REST and WS server
port: 2333 port: 2333
address: 0.0.0.0 address: 0.0.0.0
plugins: plugins:
jellylink:
jellyfin:
baseUrl: "http://192.168.50.244:8096"
username: "Lunaris"
password: "Lunaris"
searchLimit: 5
audioQuality: "ORIGINAL" # ORIGINAL | HIGH | MEDIUM | LOW | custom number (kbps)
audioCodec: "mp3" # mp3 | aac | opus | vorbis | flac (only used when not ORIGINAL)
lavasrc: lavasrc:
providers: # Custom providers for track loading. This is the default providers: # Custom providers for track loading. This is the default
# - "dzisrc:%ISRC%" # Deezer ISRC provider # - "dzisrc:%ISRC%" # Deezer ISRC provider

View File

@@ -64,15 +64,6 @@ services:
networks: networks:
- ollama-docker - 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: volumes:
ollama: {} ollama: {}