Compare commits

...

21 Commits

Author SHA1 Message Date
Myx
445dfafd8e Add jellyfin source 2026-02-13 21:07:54 +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
e847c1579a Update readme.md 2024-10-25 21:51:44 +02:00
1ccc31d3d2 Update readme.md 2024-10-25 21:50:41 +02:00
7c4d8c246d Update README.md 2024-10-25 21:49:24 +02:00
43f0191752 Update readme.md 2024-10-25 21:47:54 +02:00
872b6d3138 Create README.md 2024-10-25 21:44:08 +02:00
f292124228 Update documentation 2024-10-25 21:38:47 +02:00
4cbee9a625 Fix bug with playback & add statuses (#8)
Co-authored-by: Myx <info@azaaxin.com>
2024-10-25 21:06:26 +02:00
b79e56d3a1 Add version to release file 2024-10-25 20:58:19 +02:00
fa19f8d938 Update dotnet.yml 2024-10-24 19:45:28 +02:00
ac869c43da Update dotnet.yml 2024-10-24 19:20:08 +02:00
e2fdd9a2d7 Update dotnet.yml 2024-10-24 19:10:54 +02:00
98761fc91d Update dotnet.yml 2024-10-24 19:10:12 +02:00
373d482906 Add Spotify support (#7)
Co-authored-by: Myx <info@azaaxin.com>
2024-10-23 10:31:27 +02:00
Myx
e044f2f91b Revert "Update dotnet.yml"
This reverts commit 4855d37d76.
2024-08-22 02:05:51 +02:00
4855d37d76 Update dotnet.yml 2024-08-22 02:02:13 +02:00
25 changed files with 888 additions and 208 deletions

View File

@@ -15,23 +15,6 @@ jobs:
with: with:
fetch-depth: 0 # required for github-action-get-previous-tag 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 - name: Get previous tag
id: previoustag id: previoustag
uses: 'WyriHaximus/github-action-get-previous-tag@v1' uses: 'WyriHaximus/github-action-get-previous-tag@v1'
@@ -44,6 +27,23 @@ jobs:
with: with:
version: ${{ steps.previoustag.outputs.tag }} version: ${{ steps.previoustag.outputs.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 /p:AssemblyVersion=${{ steps.previoustag.outputs.tag }} -o ./out
- name: Publish
run: dotnet publish ./Bot/Lunaris2.csproj --configuration Release --output ./out
- name: Zip the build
run: 7z a -tzip ./out/Lunaris.zip ./out/*
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1
@@ -63,5 +63,5 @@ jobs:
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./out/Lunaris.zip asset_path: ./out/Lunaris.zip
asset_name: Lunaris.zip asset_name: Lunaris_${{steps.semver.outputs.patch}}.zip
asset_content_type: application/zip asset_content_type: application/zip

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
.vs .vs
bin bin
appsettings.json appsettings.json
ollama
plugins

View File

@@ -1,4 +1,6 @@
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Discord;
using Discord.WebSocket; using Discord.WebSocket;
using Lunaris2.Notification; using Lunaris2.Notification;
using MediatR; using MediatR;
@@ -19,13 +21,43 @@ public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotifi
public async Task Handle(MessageReceivedNotification notification, CancellationToken cancellationToken) public async Task Handle(MessageReceivedNotification notification, CancellationToken cancellationToken)
{ {
await BotMentioned(notification, cancellationToken); await BotMentioned(notification, cancellationToken);
await Statistics(notification, cancellationToken);
}
private async Task Statistics(MessageReceivedNotification notification, CancellationToken cancellationToken)
{
if (notification.Message.Content.Contains("!LunarisStats"))
{
var servers = _client.Guilds.Select(guild => guild.Name);
var channels = _client.Guilds
.SelectMany(guild => guild.VoiceChannels)
.Where(channel => channel.ConnectedUsers.Any(guildUser => guildUser.Id == _client.CurrentUser.Id) &&
channel.Users.Count != 1);
var statsList = new StringBuilder();
statsList.AppendLine("➡️ Servers");
foreach (var server in servers)
statsList.AppendLine($"* {server}");
statsList.AppendLine("➡️ Now playing channels: ");
foreach (var channel in channels)
statsList.AppendLine($"* {channel.Name} in {channel.Guild.Name}");
var embed = new EmbedBuilder()
.WithTitle("Lunaris Statistics")
.WithDescription(statsList.ToString())
.Build();
await notification.Message.Channel.SendMessageAsync(embed: embed);
}
} }
private async Task BotMentioned(MessageReceivedNotification notification, CancellationToken cancellationToken) private async Task BotMentioned(MessageReceivedNotification notification, CancellationToken cancellationToken)
{ {
if (notification.Message.MentionedUsers.Any(user => user.Id == _client.CurrentUser.Id)) if (notification.Message.MentionedUsers.Any(user => user.Id == _client.CurrentUser.Id))
{ {
// The bot was mentioned
const string pattern = "<.*?>"; const string pattern = "<.*?>";
const string replacement = ""; const string replacement = "";
var regex = new Regex(pattern); var regex = new Regex(pattern);

View File

@@ -0,0 +1,22 @@
using Discord.WebSocket;
using Lavalink4NET;
using MediatR;
namespace Lunaris2.Handler.MusicPlayer.ClearQueueCommand;
public record ClearQueueCommand(SocketSlashCommand Message) : IRequest;
public class DisconnectHandler(DiscordSocketClient client, IAudioService audioService) : IRequestHandler<ClearQueueCommand>
{
public async Task Handle(ClearQueueCommand command, CancellationToken cancellationToken)
{
var context = command.Message;
var player = await audioService.GetPlayerAsync(client, context, connectToVoiceChannel: true);
if (player is null)
return;
await player.Queue.ClearAsync(cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync("Cleared queue. No songs are queued.", client).ConfigureAwait(false);
}
}

View File

@@ -17,6 +17,6 @@ public class DisconnectHandler(DiscordSocketClient client, IAudioService audioSe
return; return;
await player.DisconnectAsync(cancellationToken).ConfigureAwait(false); await player.DisconnectAsync(cancellationToken).ConfigureAwait(false);
await context.RespondAsync("Disconnected.").ConfigureAwait(false); await context.SendMessageAsync("Disconnected.", client).ConfigureAwait(false);
} }
} }

View File

@@ -1,4 +1,6 @@
using System.Net;
using Discord; using Discord;
using Discord.Net;
using Discord.WebSocket; using Discord.WebSocket;
namespace Lunaris2.Handler.MusicPlayer; namespace Lunaris2.Handler.MusicPlayer;
@@ -68,17 +70,32 @@ public static class MessageModule
if (value.Count <= 0) if (value.Count <= 0)
return guildId; return guildId;
foreach (var messageId in value) // Create a copy of the list to avoid modifying it during iteration
var messagesToDelete = new List<ulong>(value);
foreach (var messageId in messagesToDelete)
{
try
{ {
var messageToDelete = await context.Channel.GetMessageAsync(messageId); var messageToDelete = await context.Channel.GetMessageAsync(messageId);
if (messageToDelete != null) if (messageToDelete != null)
{
await messageToDelete.DeleteAsync(); await messageToDelete.DeleteAsync();
} }
}
catch (HttpException ex)
{
if (ex.HttpCode != HttpStatusCode.NotFound)
throw;
}
}
// Clear the list after we're done with the iteration
value.Clear(); value.Clear();
} }
else else
{ {
// If the guildId does not exist, add it to the dictionary
GuildMessageIds.Add(guildId, new List<ulong>()); GuildMessageIds.Add(guildId, new List<ulong>());
} }

View File

@@ -1,5 +1,6 @@
using Discord; using Discord;
using Discord.WebSocket; using Discord.WebSocket;
using Lavalink4NET.Players.Queued;
using Lavalink4NET.Tracks; using Lavalink4NET.Tracks;
namespace Lunaris2.Handler.MusicPlayer; namespace Lunaris2.Handler.MusicPlayer;
@@ -11,29 +12,37 @@ public class MusicEmbed
string title, string title,
string length, string length,
string artist, string artist,
string queuedBy) string queuedBy,
string? nextSong = null)
{ {
var getNextSong = nextSong is not null ? $"\nNext Song: {nextSong}" : string.Empty;
return new EmbedBuilder() return new EmbedBuilder()
.WithAuthor("Lunaris", "https://media.tenor.com/GqAwMt01UXgAAAAi/cd.gif") .WithAuthor("Lunaris", "https://media.tenor.com/GqAwMt01UXgAAAAi/cd.gif")
.WithTitle(title) .WithTitle(title)
.WithDescription($"Length: {length}\nArtist: {artist}\nQueued by: {queuedBy}") .WithDescription($"Length: {length}\nArtist: {artist}\nQueued by: {queuedBy}{getNextSong}")
.WithColor(Color.Magenta) .WithColor(Color.Magenta)
.WithThumbnailUrl(imageUrl) .WithThumbnailUrl(imageUrl)
.Build(); .Build();
} }
public async Task NowPlayingEmbed( public async Task NowPlayingEmbed(
LavalinkTrack player, LavalinkTrack track,
SocketSlashCommand context, SocketSlashCommand context,
DiscordSocketClient client) DiscordSocketClient client,
ITrackQueue? queue = null)
{ {
var artwork = player.ArtworkUri; var duration = TimeSpan.Parse(track.Duration.ToString());
var artwork = track.ArtworkUri;
var nextSong = queue?.Count > 1 ? queue[1].Track?.Title : null;
var embed = SendMusicEmbed( var embed = SendMusicEmbed(
artwork.ToString(), artwork.ToString(),
player.Title, track.Title,
player.Duration.ToString(), duration.ToString(@"hh\:mm\:ss"),
player.Author, track.Author,
context.User.Username); context.User.Username,
nextSong);
await context.SendMessageAsync(embed, client); await context.SendMessageAsync(embed, client);
} }

View File

@@ -0,0 +1,30 @@
using System.Text.Json;
using Lavalink4NET.Filters;
using Lavalink4NET.Protocol.Models.Filters;
namespace Lunaris2.Handler.MusicPlayer.PlayCommand;
public class NormalizationFilter : IFilterOptions
{
private double MaxAmplitude { get; set; }
private bool Adaptive { get; set; }
public NormalizationFilter(double maxAmplitude, bool adaptive)
{
MaxAmplitude = maxAmplitude;
Adaptive = adaptive;
}
public bool IsDefault => MaxAmplitude == 1.0 && !Adaptive;
public void Apply(ref PlayerFilterMapModel filterMap)
{
filterMap.AdditionalFilters ??= new Dictionary<string, JsonElement>();
var normalizationFilter = new
{
maxAmplitude = MaxAmplitude,
adaptive = Adaptive
};
filterMap.AdditionalFilters["normalization"] = JsonSerializer.SerializeToElement(normalizationFilter);
}
}

View File

@@ -1,10 +1,15 @@
using System.Collections.Immutable;
using Discord.WebSocket; using Discord.WebSocket;
using Lunaris2.SlashCommand; using Lunaris2.SlashCommand;
using MediatR; using MediatR;
using Lavalink4NET; using Lavalink4NET;
using Lavalink4NET.Events.Players; using Lavalink4NET.Events.Players;
using Lavalink4NET.Integrations.SponsorBlock;
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;
namespace Lunaris2.Handler.MusicPlayer.PlayCommand; namespace Lunaris2.Handler.MusicPlayer.PlayCommand;
@@ -15,8 +20,9 @@ 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 static readonly HashSet<ulong> SubscribedGuilds = new();
public PlayHandler( public PlayHandler(
DiscordSocketClient client, DiscordSocketClient client,
@@ -26,29 +32,45 @@ public class PlayHandler : IRequestHandler<PlayCommand>
_client = client; _client = client;
_musicEmbed = musicEmbed; _musicEmbed = musicEmbed;
_audioService = audioService; _audioService = audioService;
_audioService.TrackStarted += OnTrackStarted; }
private Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
{
// Reset the previous track when the track ends.
_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;
var track = player?.CurrentTrack;
if (track != null) if (player?.CurrentTrack is null)
await _musicEmbed.NowPlayingEmbed(track, _context, _client); {
return; // No track is currently playing.
} }
public Task Handle(PlayCommand command, CancellationToken cancellationToken) var currentTrack = player.CurrentTrack;
{
new Thread(PlayMusic).Start();
return Task.CompletedTask;
async void PlayMusic() // Check if the current track is the same as the previous one
if (_previousTrack?.Identifier == currentTrack.Identifier)
{
// The same track is playing, so we don't need to create a new embed.
return;
}
// Track has changed, update the previous track and send the embed
_previousTrack = currentTrack;
if (_context != null)
{
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
}
}
public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
{ {
try try
{ {
await _audioService.StartAsync(cancellationToken);
var context = command.Message; var context = command.Message;
_context = context; _context = context;
@@ -66,6 +88,10 @@ public class PlayHandler : IRequestHandler<PlayCommand>
return; return;
} }
RegisterTrackStartedEventListerner(command);
await _audioService.StartAsync(cancellationToken);
await context.SendMessageAsync("📻 Searching...", _client); await context.SendMessageAsync("📻 Searching...", _client);
var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true); var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true);
@@ -73,34 +99,67 @@ public class PlayHandler : IRequestHandler<PlayCommand>
if (player is null) if (player is null)
return; return;
var trackLoadOptions = new TrackLoadOptions { SearchMode = TrackSearchMode.YouTube, }; await ApplyFilters(cancellationToken, player);
await ConfigureSponsorBlock(cancellationToken, player);
var track = await _audioService.Tracks.LoadTrackAsync(searchQuery, trackLoadOptions, cancellationToken: cancellationToken); // 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);
if (track is null) var trackLoadOptions = new TrackLoadOptions
{ {
await context.SendMessageAsync("😖 No results.", _client); SearchMode = searchMode,
return; };
}
if (player.CurrentTrack?.Duration.TotalMinutes > MaxTrackDuration) 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)
{ {
await context.SendMessageAsync($"🔈 Sorry the track is longer than { MaxTrackDuration } minutes, to save resources this limit is active for now.", _client); // If it's a playlist, check if it's a free-text input.
return; if (!Uri.IsWellFormedUriString(searchQuery, UriKind.Absolute))
}
if (player.CurrentTrack is null)
{ {
await player.PlayAsync(track, cancellationToken: cancellationToken) // Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
.ConfigureAwait(false); // Queue only the first track of the search result
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
await _musicEmbed.NowPlayingEmbed(track, context, _client); if (trackCollection.Track != null)
{
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
// rely on TrackStarted event to send Now Playing
} }
else else
{ {
var queueTracks = new[] { new TrackQueueItem(track) }; 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 player.Queue.AddRangeAsync(queueTracks, cancellationToken);
await context.SendMessageAsync($"🔈 Added to queue: {track.Title}", _client); 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);
// rely on TrackStarted event to send Now Playing
}
else
{
await context.SendMessageAsync("No tracks found.", _client);
}
} }
} }
catch (Exception error) catch (Exception error)
@@ -108,5 +167,44 @@ public class PlayHandler : IRequestHandler<PlayCommand>
throw new Exception("Error occured in the Play handler!", error); throw new Exception("Error occured in the Play handler!", error);
} }
} }
private void RegisterTrackStartedEventListerner(PlayCommand command)
{
var guildId = command.Message.GuildId;
if (!guildId.HasValue)
{
// Ignore registration for DMs or contexts without a guild.
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.TrackEnded += OnTrackEnded;
SubscribedGuilds.Add(gid);
}
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
{
var normalizationFilter = new NormalizationFilter(0.5, true);
player.Filters.SetFilter(normalizationFilter);
await player.Filters.CommitAsync(cancellationToken);
}
private static async Task ConfigureSponsorBlock(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
{
var categories = ImmutableArray.Create(
SegmentCategory.Intro,
SegmentCategory.Sponsor,
SegmentCategory.SelfPromotion,
SegmentCategory.Outro,
SegmentCategory.Filler);
await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken);
} }
} }

View File

@@ -8,6 +8,29 @@ flowchart TD
PlayTrack --> NowPlayingEmbed PlayTrack --> NowPlayingEmbed
``` ```
```mermaid
sequenceDiagram
participant User
participant Bot
participant DiscordSocketClient
participant IAudioService
participant SocketSlashCommand
participant LavalinkPlayer
User->>Bot: /play [song]
Bot->>DiscordSocketClient: Get user voice channel
DiscordSocketClient-->>Bot: Voice channel info
Bot->>IAudioService: Get or create player
IAudioService-->>Bot: Player instance
Bot->>SocketSlashCommand: Get search query
SocketSlashCommand-->>Bot: Search query
Bot->>IAudioService: Load tracks
IAudioService-->>Bot: Track collection
Bot->>LavalinkPlayer: Play track
LavalinkPlayer-->>Bot: Track started
Bot->>User: Now playing embed
```
## Steps in the code ## Steps in the code
| Name | Description | | Name | Description |

View File

@@ -0,0 +1,239 @@
### README.md
# Handlers
Handlers for the Lunaris2 bot, which is built using C#, Discord.Net, and Lavalink4NET. Below is a detailed description of each handler and their responsibilities.
## Handlers
### ClearQueueHandler
Handles the command to clear the music queue.
```csharp
public class ClearQueueHandler : IRequestHandler<ClearQueueCommand>
```
### DisconnectHandler
Handles the command to disconnect the bot from the voice channel.
```csharp
public class DisconnectHandler : IRequestHandler<DisconnectCommand>
```
### PauseHandler
Handles the command to pause the currently playing track.
```csharp
public class PauseHandler : IRequestHandler<PauseCommand>
```
### PlayHandler
Handles the command to play a track or playlist.
```csharp
public class PlayHandler : IRequestHandler<PlayCommand>
```
### ResumeHandler
Handles the command to resume the currently paused track.
```csharp
public class ResumeHandler : IRequestHandler<ResumeCommand>
```
### SkipHandler
Handles the command to skip the currently playing track.
```csharp
public class SkipHandler : IRequestHandler<SkipCommand>
```
### MessageReceivedHandler
Handles incoming messages and processes commands or statistics requests.
```csharp
public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotification>
```
## Mermaid Diagrams
### Class Diagram
```mermaid
sequenceDiagram
participant User as User
participant DiscordSocketClient as DiscordSocketClient
participant MessageReceivedHandler as MessageReceivedHandler
participant MessageReceivedNotification as MessageReceivedNotification
participant EmbedBuilder as EmbedBuilder
participant Channel as Channel
User->>DiscordSocketClient: Send message "!LunarisStats"
DiscordSocketClient->>MessageReceivedHandler: MessageReceivedNotification
MessageReceivedHandler->>MessageReceivedNotification: Handle(notification, cancellationToken)
MessageReceivedNotification->>MessageReceivedHandler: BotMentioned(notification, cancellationToken)
MessageReceivedHandler->>DiscordSocketClient: Get guilds and voice channels
DiscordSocketClient-->>MessageReceivedHandler: List of guilds and voice channels
MessageReceivedHandler->>EmbedBuilder: Create embed with statistics
EmbedBuilder-->>MessageReceivedHandler: Embed
MessageReceivedHandler->>Channel: Send embed message
```
### Sequence Diagram for PlayHandler
```mermaid
sequenceDiagram
participant User
participant Bot
participant DiscordSocketClient
participant IAudioService
participant SocketSlashCommand
participant LavalinkPlayer
User->>Bot: /play [song]
Bot->>DiscordSocketClient: Get user voice channel
DiscordSocketClient-->>Bot: Voice channel info
Bot->>IAudioService: Get or create player
IAudioService-->>Bot: Player instance
Bot->>SocketSlashCommand: Get search query
SocketSlashCommand-->>Bot: Search query
Bot->>IAudioService: Load tracks
IAudioService-->>Bot: Track collection
Bot->>LavalinkPlayer: Play track
LavalinkPlayer-->>Bot: Track started
Bot->>User: Now playing embed
```
### Sequence Diagram for MessageReceivedHandler
```mermaid
sequenceDiagram
participant User
participant Bot
participant DiscordSocketClient
participant ISender
participant MessageReceivedNotification
User->>Bot: Send message
Bot->>MessageReceivedNotification: Create notification
Bot->>DiscordSocketClient: Check if bot is mentioned
DiscordSocketClient-->>Bot: Mention info
alt Bot is mentioned
Bot->>ISender: Send ChatCommand
end
Bot->>DiscordSocketClient: Check for statistics command
alt Statistics command found
Bot->>DiscordSocketClient: Get server and channel info
DiscordSocketClient-->>Bot: Server and channel info
Bot->>User: Send statistics embed
end
```
## 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

@@ -1,3 +1,4 @@
using Lunaris2.Handler.MusicPlayer.ClearQueueCommand;
using Lunaris2.Handler.MusicPlayer.DisconnectCommand; using Lunaris2.Handler.MusicPlayer.DisconnectCommand;
using Lunaris2.Handler.MusicPlayer.PauseCommand; using Lunaris2.Handler.MusicPlayer.PauseCommand;
using Lunaris2.Handler.MusicPlayer.PlayCommand; using Lunaris2.Handler.MusicPlayer.PlayCommand;
@@ -32,6 +33,9 @@ public class SlashCommandReceivedHandler(ISender mediator) : INotificationHandle
case Command.Skip.Name: case Command.Skip.Name:
await mediator.Send(new SkipCommand(notification.Message), cancellationToken); await mediator.Send(new SkipCommand(notification.Message), cancellationToken);
break; break;
case Command.Clear.Name:
await mediator.Send(new ClearQueueCommand(notification.Message), cancellationToken);
break;
} }
} }
} }

View File

@@ -6,28 +6,35 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UserSecretsId>ec2f340f-a44c-4869-ab79-a12ba9459d80</UserSecretsId> <UserSecretsId>ec2f340f-a44c-4869-ab79-a12ba9459d80</UserSecretsId>
<AssemblyVersion>0.0.1337</AssemblyVersion>
</PropertyGroup> </PropertyGroup>
<!-- Lavalink4net 4.0.25 seems to break the Message Module-->
<ItemGroup> <ItemGroup>
<PackageReference Include="Discord.Net" Version="3.15.3" /> <PackageReference Include="Discord.Net" Version="3.16.0" />
<PackageReference Include="Discord.Net.Commands" Version="3.15.3" /> <PackageReference Include="Discord.Net.Commands" Version="3.16.0" />
<PackageReference Include="Discord.Net.Core" Version="3.15.3" /> <PackageReference Include="Discord.Net.Core" Version="3.16.0" />
<PackageReference Include="Discord.Net.Interactions" Version="3.15.3" /> <PackageReference Include="Discord.Net.Interactions" Version="3.16.0" />
<PackageReference Include="Discord.Net.Rest" Version="3.15.3" /> <PackageReference Include="Discord.Net.Rest" Version="3.16.0" />
<PackageReference Include="Lavalink4NET" Version="4.0.20" /> <PackageReference Include="Lavalink4NET" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Artwork" Version="4.0.20" /> <PackageReference Include="Lavalink4NET.Artwork" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Discord.NET" Version="4.0.20" /> <PackageReference Include="Lavalink4NET.Discord.NET" Version="4.0.25" />
<PackageReference Include="MediatR" Version="12.4.0" /> <PackageReference Include="Lavalink4NET.Integrations.Lavasrc" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" />
<PackageReference Include="Lavalink4NET.Jellyfin" Version="1.0.0" />
<PackageReference Include="MediatR" Version="12.4.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.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="OllamaSharp" Version="1.1.10" /> <PackageReference Include="OllamaSharp" Version="1.1.10" />
<PackageReference Include="Victoria" Version="6.0.23.324" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="../docker-compose.yml" pack="true" PackagePath="." />
<None Include="../application.yml" pack="true" PackagePath="." />
<None Include="../start-services.sh" pack="true" PackagePath="." />
<None Update="appsettings.json"> <None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>

View File

@@ -4,6 +4,7 @@ using Discord.Interactions;
using Discord.WebSocket; using Discord.WebSocket;
using Lunaris2.Handler.ChatCommand; using Lunaris2.Handler.ChatCommand;
using Lavalink4NET.Extensions; using Lavalink4NET.Extensions;
using Lavalink4NET.Integrations.SponsorBlock.Extensions;
using Lunaris2.Handler.MusicPlayer; using Lunaris2.Handler.MusicPlayer;
using Lunaris2.Notification; using Lunaris2.Notification;
using Lunaris2.Service; using Lunaris2.Service;
@@ -11,7 +12,6 @@ using Lunaris2.SlashCommand;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Victoria.Node;
namespace Lunaris2; namespace Lunaris2;
@@ -23,7 +23,10 @@ public class Program
{ {
Console.WriteLine(eventArgs.ExceptionObject); Console.WriteLine(eventArgs.ExceptionObject);
}; };
CreateHostBuilder(args).Build().Run(); var app = CreateHostBuilder(args).Build();
app.UseSponsorBlock();
app.Run();
} }
private static IHostBuilder CreateHostBuilder(string[] args) => private static IHostBuilder CreateHostBuilder(string[] args) =>
@@ -53,7 +56,6 @@ public class Program
options.Passphrase = configuration["LavaLinkPassword"] ?? "youshallnotpass"; options.Passphrase = configuration["LavaLinkPassword"] ?? "youshallnotpass";
options.Label = "Node"; options.Label = "Node";
}) })
.AddSingleton<LavaNode>()
.AddSingleton<MusicEmbed>() .AddSingleton<MusicEmbed>()
.AddSingleton<ChatSettings>() .AddSingleton<ChatSettings>()
.AddSingleton(client) .AddSingleton(client)

View File

@@ -2,11 +2,15 @@
```mermaid ```mermaid
flowchart TD flowchart TD
Program[Program] -->|Register| EventListener Program[Program] -->|Register| EventListener
Program --> Intervals[VoiceChannelMonitorService]
Intervals --> SetStatus[SetStatus, Updates status with amount of playing bots]
Intervals --> LeaveChannel[LeaveOnAlone, Leaves channel when alone for a time]
EventListener[DiscordEventListener] --> A[MessageReceivedHandler] EventListener[DiscordEventListener] --> A[MessageReceivedHandler]
EventListener[DiscordEventListener] --> A2[SlashCommandReceivedHandler] EventListener[DiscordEventListener] --> A2[SlashCommandReceivedHandler]
A --> |Message| f{If bot is mentioned} A --> |Message| f{If bot is mentioned}
A --> |Message '!LunarisStats'| p[Responds with Server and Channel Statistics.]
f --> |ChatCommand| v[ChatHandler] f --> |ChatCommand| v[ChatHandler]
A2[SlashCommandReceivedHandler] -->|Message| C{Send to correct command by A2[SlashCommandReceivedHandler] -->|Message| C{Send to correct command by
@@ -14,8 +18,11 @@ flowchart TD
C -->|JoinCommand| D[JoinHandler] C -->|JoinCommand| D[JoinHandler]
C -->|PlayCommand| E[PlayHandler] C -->|PlayCommand| E[PlayHandler]
C -->|HelloCommand| F[HelloHandler] C -->|PauseCommand| F[PauseHandler]
C -->|GoodbyeCommand| G[GoodbyeHandler] C -->|DisconnectCommand| H[DisconnectHandler]
C -->|ResumeCommand| J[ResumeHandler]
C -->|SkipCommand| K[SkipHandler]
C -->|ClearQueueCommand| L[ClearQueueHandler]
``` ```
Program registers an event listener ```DiscordEventListener``` which publish a message : Program registers an event listener ```DiscordEventListener``` which publish a message :
@@ -30,20 +37,33 @@ await Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken)
## Handler integrations ## Handler integrations
```mermaid ```mermaid
flowchart TD flowchart LR
D[JoinHandler] --> Disc[Discord Api] D[JoinHandler] --> Disc[Discord Api]
E[PlayHandler] --> Disc[Discord Api] E[PlayHandler] --> Disc[Discord Api]
F[HelloHandler] --> Disc[Discord Api] F[SkipHandler] --> Disc[Discord Api]
G[GoodbyeHandler] --> Disc[Discord Api] G[PauseHandler] --> Disc[Discord Api]
v[ChatHandler] --> Disc[Discord Api] v[ChatHandler] --> Disc[Discord Api]
ClearQueueHandler --> Disc
ClearQueuehandler --> Lava
DisconnectHandler --> Disc
Resumehandler --> Disc
v --> o[Ollama Server] v --> o[Ollama Server]
o --> v o --> v
E --> Lava[Lavalink] E --> Lava[Lavalink]
F --> Lava
G --> Lava
``` ```
|Name| Description | |Name| Description |
|--|--| |--|--|
| JoinHandler| Handles the logic for **just** joining a voice channel. | | JoinHandler| Handles the logic for **just** joining a voice channel. |
| PlayHandler| Handles the logic for joining and playing music in 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)| | PauseHandler | Handles the logic for pausing currently playing track. |
| GoodbyeHandler| Responds with Goodbye. (Dummy handler, will be removed)| | DisconnectHandler | Handles the logic for disconnecting from voicechannels. |
| ClearQueueHandler | Handles the logic for clearing the queued songs, except the currently playing one. |
| SkipHandler | Handles the logic for skipping tracks that are queued. If 0 trackS is in queue, it stops the current one.|
| Resumehandler | Resumes paused tracks. |
| ChatHandler| Handles the logic for LLM chat with user. | | ChatHandler| Handles the logic for LLM chat with user. |

View File

@@ -1,15 +1,19 @@
using Discord;
using Discord.WebSocket; using Discord.WebSocket;
namespace Lunaris2.Service; namespace Lunaris2.Service
public class VoiceChannelMonitorService
{ {
public class VoiceChannelMonitorService
{
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly Dictionary<ulong, Timer> _timers = new(); // Track a cancellation source per voice channel when the bot is alone
private readonly Dictionary<ulong, CancellationTokenSource> _leaveCtsByChannel = new();
public VoiceChannelMonitorService(DiscordSocketClient client) public VoiceChannelMonitorService(DiscordSocketClient client)
{ {
_client = client; _client = client;
// Subscribe to voice state updates to react immediately
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
} }
public void StartMonitoring() public void StartMonitoring()
@@ -19,41 +23,166 @@ public class VoiceChannelMonitorService
while (true) while (true)
{ {
await CheckVoiceChannels(); await CheckVoiceChannels();
await Task.Delay(TimeSpan.FromMinutes(1)); await Task.Delay(TimeSpan.FromMinutes(1)); // Status refresh every minute
} }
}); });
} }
private async Task CheckVoiceChannels() private async Task CheckVoiceChannels()
{
SetStatus();
await EnsureCurrentAloneStatesScheduled();
}
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);
}
// Monitor existing alone states during the periodic check to ensure timers exist
private async Task EnsureCurrentAloneStatesScheduled()
{ {
foreach (var guild in _client.Guilds) foreach (var guild in _client.Guilds)
{ {
var voiceChannel = guild.VoiceChannels.FirstOrDefault(vc => vc.ConnectedUsers.Count == 1); foreach (var voiceChannel in guild.VoiceChannels)
if (voiceChannel != null)
{ {
if (!_timers.ContainsKey(voiceChannel.Id)) var botInChannel = voiceChannel.ConnectedUsers.Any(u => u.Id == _client.CurrentUser.Id);
var userCount = voiceChannel.ConnectedUsers.Count;
if (botInChannel && userCount == 1)
{ {
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null, TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Schedule leave if not already scheduled
if (!_leaveCtsByChannel.ContainsKey(voiceChannel.Id))
{
ScheduleLeave(voiceChannel);
} }
} }
else else
{ {
if (voiceChannel == null || !_timers.ContainsKey(voiceChannel.Id)) // Cancel if a schedule exists but the bot is not alone anymore
continue; if (_leaveCtsByChannel.TryGetValue(voiceChannel.Id, out var cts))
{
await _timers[voiceChannel.Id].DisposeAsync(); cts.Cancel();
_timers.Remove(voiceChannel.Id); _leaveCtsByChannel.Remove(voiceChannel.Id);
}
} }
} }
} }
private async Task LeaveChannel(SocketVoiceChannel voiceChannel) await Task.CompletedTask;
}
private Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState before, SocketVoiceState after)
{ {
if (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id)) // 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)
{ {
if (beforeChannelId.HasValue && _leaveCtsByChannel.TryGetValue(beforeChannelId.Value, out var oldCts))
{
oldCts.Cancel();
_leaveCtsByChannel.Remove(beforeChannelId.Value);
}
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);
}
}
}
if (beforeChannelId.HasValue)
{
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)
{
var cts = new CancellationTokenSource();
_leaveCtsByChannel[voiceChannel.Id] = cts;
_ = 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(); await voiceChannel.DisconnectAsync();
await _timers[voiceChannel.Id].DisposeAsync(); }
_timers.Remove(voiceChannel.Id); }
catch (OperationCanceledException)
{
// Cancelled because someone joined or bot moved
}
finally
{
_leaveCtsByChannel.Remove(voiceChannel.Id);
cts.Dispose();
}
});
} }
} }
} }

View File

@@ -15,6 +15,12 @@ public static class Command
public const string Description = "Disconnect from the voice channel!"; public const string Description = "Disconnect from the voice channel!";
} }
public static class Clear
{
public const string Name = "clear";
public const string Description = "Clear the music queue!";
}
public static class Skip public static class Skip
{ {
public const string Name = "skip"; public const string Name = "skip";

View File

@@ -13,6 +13,7 @@ public static class SlashCommandRegistration
RegisterCommand(client, Command.Skip.Name, Command.Skip.Description); RegisterCommand(client, Command.Skip.Name, Command.Skip.Description);
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);
} }
private static void RegisterCommand( private static void RegisterCommand(

View File

@@ -0,0 +1,2 @@
docker run quay.io/invidious/youtube-trusted-session-generator
read -p "Copy the codes and press enter to close the terminal."

BIN
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](https://github.com/Myxelium/Lunaris2.0/blob/master/LOGOTYPE.png?raw=true)
# 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. 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. - 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. - Queue system to line up your favorite tracks.
- Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below. - Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below.
## Setup ## 🤖 Setup
1. Clone the repo. 1. Clone the repo.
2. Extract. 2. Extract.
@@ -27,7 +29,8 @@ The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). R
## PM2 Setup ## PM2 Setup
- Install PM2 and configure it following their setup guide - Install PM2 and configure it following their setup guide
#### Lavalink
#### 🐦‍🔥 Lavalink
* Download Lavalink 4.X.X (.jar) * Download Lavalink 4.X.X (.jar)
* Install Java 17 * 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. - `/play <song>`: Plays the specified song in the voice channel you're currently in.
- `/skip`: Skips the currently playing song. - `/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 ## 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,7 +2,53 @@ 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:
providers: # Custom providers for track loading. This is the default
# - "dzisrc:%ISRC%" # Deezer ISRC provider
# - "dzsearch:%QUERY%" # Deezer search provider
- "ytsearch:\"%ISRC%\"" # Will be ignored if track does not have an ISRC. See https://en.wikipedia.org/wiki/International_Standard_Recording_Code
- "ytsearch:%QUERY%" # Will be used if track has no ISRC or no track could be found for the ISRC
# you can add multiple other fallback sources here
sources:
spotify: true # Enable Spotify source
applemusic: false # Enable Apple Music source
deezer: false # Enable Deezer source
yandexmusic: false # Enable Yandex Music source
flowerytts: false # Enable Flowery TTS source
youtube: false # Enable YouTube search source (https://github.com/topi314/LavaSearch)
vkmusic: false # Enable Vk Music source
spotify:
clientId: "ID"
clientSecret: "SECRET"
# spDc: "your sp dc cookie" # the sp dc cookie used for accessing the spotify lyrics api
countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
playlistLoadLimit: 6 # The number of pages at 100 tracks each
albumLoadLimit: 6 # The number of pages at 50 tracks each
resolveArtistsInSearch: true # Whether to resolve artists in track search results (can be slow)
localFiles: false # Enable local files support with Spotify playlists. Please note `uri` & `isrc` will be `null` & `identifier` will be `"local"`
youtube: youtube:
oauth:
# setting "enabled: true" is the bare minimum to get OAuth working.
enabled: true
# if you have a refresh token, you may set it below (make sure to uncomment the line to apply it).
# setting a valid refresh token will skip the OAuth flow entirely. See above note on how to retrieve
# your refreshToken.
# Set this if you don't want the OAuth flow to be triggered, if you intend to supply a refresh token later.
# Initialization is skipped automatically if a valid refresh token is supplied. Leave this commented if you're
# completing the OAuth flow for the first time/do not have a refresh token.
# skipInitialization: true
# pot: // run generate-trusted-session.sh
# token: ""
# visitorData: ""
enabled: true # Whether this source can be used. enabled: true # Whether this source can be used.
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used. allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded. allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
@@ -11,19 +57,22 @@ plugins:
# Clients are queried in the order they are given (so the first client is queried first and so on...) # Clients are queried in the order they are given (so the first client is queried first and so on...)
clients: clients:
- MUSIC - MUSIC
- ANDROID_TESTSUITE
- WEB - WEB
- TVHTML5EMBEDDED - TVHTML5EMBEDDED
# name: # Name of the plugin - ANDROID_TESTSUITE
# some_key: some_value # Some key-value pair for the plugin
# another_key: another_value
lavalink: lavalink:
plugins: plugins:
# - dependency: "group:artifact:version" - dependency: com.github.devoxin:lavadspx-plugin:0.0.5 # replace {VERSION} with the latest version from the "Releases" tab.
# repository: "repository" repository: https://jitpack.io
- dependency: "dev.lavalink.youtube:youtube-plugin:1.5.2" - dependency: "dev.lavalink.youtube:youtube-plugin:1.8.3"
snapshot: false # Set to true if you want to use a snapshot version. snapshot: false # Set to true if you want to use a snapshot version.
- dependency: "com.github.topi314.lavasearch:lavasearch-plugin:1.0.0"
repository: "https://maven.lavalink.dev/releases" # this is optional for lavalink v4.0.0-beta.5 or greater
snapshot: false # set to true if you want to use snapshot builds (see below)
- dependency: "com.github.topi314.sponsorblock:sponsorblock-plugin:3.0.1"
- dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.2.0"
repository: "https://maven.lavalink.dev/releases" # this is optional for lavalink v4.0.0-beta.5 or greater
snapshot: false # set to true if you want to use snapshot builds (see below)
server: server:
password: "youshallnotpass" password: "youshallnotpass"
sources: sources:
@@ -56,20 +105,6 @@ lavalink:
youtubeSearchEnabled: true youtubeSearchEnabled: true
soundcloudSearchEnabled: true soundcloudSearchEnabled: true
gc-warnings: 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: metrics:
prometheus: prometheus:
@@ -79,9 +114,6 @@ metrics:
sentry: sentry:
dsn: "" dsn: ""
environment: "" environment: ""
# tags:
# some_key: some_value
# another_key: another_value
logging: logging:
file: file:
@@ -99,7 +131,6 @@ logging:
includePayload: true includePayload: true
maxPayloadLength: 10000 maxPayloadLength: 10000
logback: logback:
rollingpolicy: rollingpolicy:
max-file-size: 1GB max-file-size: 1GB

View File

@@ -1,7 +1,7 @@
services: services:
lavalink: lavalink:
# pin the image version to Lavalink v4 # pin the image version to Lavalink v4
image: ghcr.io/lavalink-devs/lavalink:4.0.7 image: ghcr.io/lavalink-devs/lavalink:4.0.8
container_name: lavalink container_name: lavalink
restart: unless-stopped restart: unless-stopped
environment: environment:

Binary file not shown.

View File

@@ -1,3 +1 @@
docker compose up -d docker compose up -d
read -p "Press enter to continue"