mirror of
https://github.com/Myxelium/Lunaris2.0.git
synced 2026-04-13 08:00:37 +00:00
Compare commits
1 Commits
Add-jellyf
...
Add-spotif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8aec8be8 |
38
.github/workflows/dotnet.yml
vendored
38
.github/workflows/dotnet.yml
vendored
@@ -14,7 +14,24 @@ jobs:
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0 # required for github-action-get-previous-tag
|
||||
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore ./Bot/Lunaris2.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ./Bot/Lunaris2.csproj --no-restore -c Release -o ./out
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish ./Bot/Lunaris2.csproj --configuration Release --output ./out
|
||||
|
||||
- name: Zip the build
|
||||
run: 7z a -tzip ./out/Lunaris.zip ./out/*
|
||||
|
||||
- name: Get previous tag
|
||||
id: previoustag
|
||||
uses: 'WyriHaximus/github-action-get-previous-tag@v1'
|
||||
@@ -27,23 +44,6 @@ jobs:
|
||||
with:
|
||||
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
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
@@ -63,5 +63,5 @@ jobs:
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./out/Lunaris.zip
|
||||
asset_name: Lunaris_${{steps.semver.outputs.patch}}.zip
|
||||
asset_name: Lunaris.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
@@ -31,23 +31,21 @@ public class MessageReceivedHandler : INotificationHandler<MessageReceivedNotifi
|
||||
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);
|
||||
.Where(channel => channel.Users.Any(user => user.IsBot));
|
||||
|
||||
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 table = new StringBuilder();
|
||||
var serverColumnWidth = 25; // Width for server column
|
||||
var channelColumnWidth = 25; // Width for channel column
|
||||
table.AppendLine($"{"Servers".PadRight(serverColumnWidth - 1)}|{"Channels".PadRight(channelColumnWidth - 1)}");
|
||||
table.AppendLine($"{new string('-', serverColumnWidth - 1)}|{new string('-', channelColumnWidth - 1)}");
|
||||
foreach (var (server, channel) in servers.Zip(channels))
|
||||
{
|
||||
table.AppendLine($"{server.PadRight(serverColumnWidth - 1)}|{channel.Name.PadRight(channelColumnWidth - 1)}");
|
||||
}
|
||||
|
||||
var embed = new EmbedBuilder()
|
||||
.WithTitle("Lunaris Statistics")
|
||||
.WithDescription(statsList.ToString())
|
||||
.WithDescription(table.ToString())
|
||||
.Build();
|
||||
|
||||
await notification.Message.Channel.SendMessageAsync(embed: embed);
|
||||
|
||||
@@ -6,7 +6,6 @@ using Lavalink4NET;
|
||||
using Lavalink4NET.Events.Players;
|
||||
using Lavalink4NET.Integrations.SponsorBlock;
|
||||
using Lavalink4NET.Integrations.SponsorBlock.Extensions;
|
||||
using Lavalink4NET.Jellyfin;
|
||||
using Lavalink4NET.Players.Queued;
|
||||
using Lavalink4NET.Rest.Entities.Tracks;
|
||||
using Lavalink4NET.Tracks;
|
||||
@@ -20,7 +19,8 @@ public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
private readonly MusicEmbed _musicEmbed;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IAudioService _audioService;
|
||||
private SocketSlashCommand _context = default!; // ensure initialized before use
|
||||
private SocketSlashCommand _context;
|
||||
private const int MaxTrackDuration = 30;
|
||||
private LavalinkTrack? _previousTrack;
|
||||
private static readonly HashSet<ulong> SubscribedGuilds = new();
|
||||
|
||||
@@ -34,16 +34,15 @@ public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
_audioService = audioService;
|
||||
}
|
||||
|
||||
private Task OnTrackEnded(object sender, TrackEndedEventArgs eventargs)
|
||||
private async 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)
|
||||
{
|
||||
var player = eventargs.Player as QueuedLavalinkPlayer;
|
||||
var player = sender as QueuedLavalinkPlayer;
|
||||
|
||||
if (player?.CurrentTrack is null)
|
||||
{
|
||||
@@ -61,132 +60,119 @@ public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
|
||||
// Track has changed, update the previous track and send the embed
|
||||
_previousTrack = currentTrack;
|
||||
if (_context != null)
|
||||
{
|
||||
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
|
||||
}
|
||||
await _musicEmbed.NowPlayingEmbed(currentTrack, _context, _client);
|
||||
}
|
||||
|
||||
public async Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||
public Task Handle(PlayCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
new Thread(PlayMusic).Start();
|
||||
return Task.CompletedTask;
|
||||
|
||||
async void PlayMusic()
|
||||
{
|
||||
var context = command.Message;
|
||||
_context = context;
|
||||
|
||||
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
|
||||
try
|
||||
{
|
||||
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
var searchQuery = context.GetOptionValueByName(Option.Input);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
await context.SendMessageAsync("Please provide search terms.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
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))
|
||||
RegisterTrackStartedEventListerner(command);
|
||||
|
||||
await _audioService.StartAsync(cancellationToken);
|
||||
|
||||
var context = command.Message;
|
||||
_context = context;
|
||||
|
||||
if ((context.User as SocketGuildUser)?.VoiceChannel == null)
|
||||
{
|
||||
// Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
|
||||
// Queue only the first track of the search result
|
||||
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
|
||||
if (trackCollection.Track != null)
|
||||
await context.SendMessageAsync("You must be in a voice channel to use this command.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
var searchQuery = context.GetOptionValueByName(Option.Input);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
await context.SendMessageAsync("Please provide search terms.", _client);
|
||||
return;
|
||||
}
|
||||
|
||||
await context.SendMessageAsync("📻 Searching...", _client);
|
||||
|
||||
var player = await _audioService.GetPlayerAsync(_client, context, connectToVoiceChannel: true);
|
||||
|
||||
if (player is null)
|
||||
return;
|
||||
|
||||
await ApplyFilters(cancellationToken, player);
|
||||
await ConfigureSponsorBlock(cancellationToken, player);
|
||||
|
||||
var trackLoadOptions = new TrackLoadOptions
|
||||
{
|
||||
SearchMode = TrackSearchMode.YouTube,
|
||||
};
|
||||
|
||||
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))
|
||||
{
|
||||
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
// rely on TrackStarted event to send Now Playing
|
||||
// Free text was used (not a direct URL to a playlist), let's prevent queueing the whole playlist.
|
||||
// Queue only the first track of the search result
|
||||
// var firstTrack = trackCollection.Tracks.FirstOrDefault();
|
||||
if (trackCollection.Track != null)
|
||||
{
|
||||
await player.PlayAsync(trackCollection.Track, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _musicEmbed.NowPlayingEmbed(trackCollection.Track, _context, _client);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync("No tracks found.", _client);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's a playlist and a URL was used, so we can queue all tracks as usual
|
||||
var queueTracks = trackCollection.Tracks
|
||||
.Skip(1)
|
||||
.Select(t => new TrackQueueItem(t))
|
||||
.ToList();
|
||||
|
||||
await player.PlayAsync(trackCollection.Tracks.First(), cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await player.Queue.AddRangeAsync(queueTracks, cancellationToken);
|
||||
await context.SendMessageAsync($"Queued playlist {trackCollection.Playlist?.Name} with {queueTracks.Count} tracks.", _client);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// It's just a single track or a search result.
|
||||
var track = trackCollection.Tracks.FirstOrDefault();
|
||||
|
||||
if (track != null)
|
||||
{
|
||||
await player.PlayAsync(track, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
await _musicEmbed.NowPlayingEmbed(track, _context, _client);
|
||||
}
|
||||
else
|
||||
{
|
||||
await context.SendMessageAsync("No tracks found.", _client);
|
||||
}
|
||||
}
|
||||
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
|
||||
catch (Exception 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);
|
||||
}
|
||||
throw new Exception("Error occured in the Play handler!", error);
|
||||
}
|
||||
}
|
||||
catch (Exception 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.
|
||||
if (SubscribedGuilds.Contains((ulong)command.Message.GuildId!))
|
||||
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);
|
||||
SubscribedGuilds.Add((ulong)command.Message.GuildId!);
|
||||
}
|
||||
|
||||
private static async Task ApplyFilters(CancellationToken cancellationToken, QueuedLavalinkPlayer player)
|
||||
@@ -207,4 +193,4 @@ public class PlayHandler : IRequestHandler<PlayCommand>
|
||||
|
||||
await player.UpdateSponsorBlockCategoriesAsync(categories, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,6 @@ flowchart TD
|
||||
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
|
||||
|
||||
| Name | Description |
|
||||
@@ -55,4 +32,4 @@ There is also OnTrackEnd, when it get called an attempt is made to play the next
|
||||
| `player` | `LavaPlayer` | An instance of the `LavaPlayer` class, representing a music player connected to a specific voice channel. Used to play, pause, skip, and queue tracks. |
|
||||
| `guildMessageIds` | `Dictionary<ulong, List<ulong>>` | A dictionary that maps guild IDs to lists of message IDs. Used to keep track of messages sent by the bot in each guild, allowing the bot to delete its old messages when it sends new ones. |
|
||||
| `songName` | `string` | A string that represents the name or URL of a song to play. Used to search for and queue tracks. |
|
||||
| `searchResponse` | `SearchResponse` | An instance of the `SearchResponse` class, representing the result of a search for tracks. Used to get the tracks that were found and queue them in the player. |
|
||||
| `searchResponse` | `SearchResponse` | An instance of the `SearchResponse` class, representing the result of a search for tracks. Used to get the tracks that were found and queue them in the player. |
|
||||
@@ -1,239 +0,0 @@
|
||||
### 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.
|
||||
@@ -6,7 +6,6 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>ec2f340f-a44c-4869-ab79-a12ba9459d80</UserSecretsId>
|
||||
<AssemblyVersion>0.0.1337</AssemblyVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Lavalink4net 4.0.25 seems to break the Message Module-->
|
||||
@@ -21,7 +20,6 @@
|
||||
<PackageReference Include="Lavalink4NET.Discord.NET" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Integrations.Lavasrc" Version="4.0.25" />
|
||||
<PackageReference Include="Lavalink4NET.Integrations.SponsorBlock" Version="4.0.25" />
|
||||
<PackageReference Include="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.Binder" Version="8.0.2" />
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
```mermaid
|
||||
flowchart TD
|
||||
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] --> A2[SlashCommandReceivedHandler]
|
||||
|
||||
A --> |Message| f{If bot is mentioned}
|
||||
A --> |Message '!LunarisStats'| p[Responds with Server and Channel Statistics.]
|
||||
f --> |ChatCommand| v[ChatHandler]
|
||||
|
||||
A2[SlashCommandReceivedHandler] -->|Message| C{Send to correct command by
|
||||
@@ -18,11 +14,8 @@ flowchart TD
|
||||
|
||||
C -->|JoinCommand| D[JoinHandler]
|
||||
C -->|PlayCommand| E[PlayHandler]
|
||||
C -->|PauseCommand| F[PauseHandler]
|
||||
C -->|DisconnectCommand| H[DisconnectHandler]
|
||||
C -->|ResumeCommand| J[ResumeHandler]
|
||||
C -->|SkipCommand| K[SkipHandler]
|
||||
C -->|ClearQueueCommand| L[ClearQueueHandler]
|
||||
C -->|HelloCommand| F[HelloHandler]
|
||||
C -->|GoodbyeCommand| G[GoodbyeHandler]
|
||||
```
|
||||
Program registers an event listener ```DiscordEventListener``` which publish a message :
|
||||
|
||||
@@ -37,33 +30,20 @@ await Mediator.Publish(new MessageReceivedNotification(arg), _cancellationToken)
|
||||
|
||||
## Handler integrations
|
||||
```mermaid
|
||||
flowchart LR
|
||||
flowchart TD
|
||||
D[JoinHandler] --> Disc[Discord Api]
|
||||
E[PlayHandler] --> Disc[Discord Api]
|
||||
F[SkipHandler] --> Disc[Discord Api]
|
||||
G[PauseHandler] --> Disc[Discord Api]
|
||||
F[HelloHandler] --> Disc[Discord Api]
|
||||
G[GoodbyeHandler] --> Disc[Discord Api]
|
||||
v[ChatHandler] --> Disc[Discord Api]
|
||||
ClearQueueHandler --> Disc
|
||||
ClearQueuehandler --> Lava
|
||||
DisconnectHandler --> Disc
|
||||
Resumehandler --> Disc
|
||||
v --> o[Ollama Server]
|
||||
o --> v
|
||||
E --> Lava[Lavalink]
|
||||
F --> Lava
|
||||
G --> Lava
|
||||
```
|
||||
|Name| Description |
|
||||
|--|--|
|
||||
| JoinHandler| Handles the logic for **just** joining a voice channel. |
|
||||
| PlayHandler| Handles the logic for joining and playing music in a voice channel. |
|
||||
| PauseHandler | Handles the logic for pausing currently playing track. |
|
||||
| DisconnectHandler | Handles the logic for disconnecting from voicechannels. |
|
||||
| ClearQueueHandler | Handles the logic for clearing the queued songs, except the currently playing one. |
|
||||
| SkipHandler | Handles the logic for skipping tracks that are queued. If 0 trackS is in queue, it stops the current one.|
|
||||
| Resumehandler | Resumes paused tracks. |
|
||||
| HelloHandler| Responds with Hello. (Dummy handler, will be removed)|
|
||||
| GoodbyeHandler| Responds with Goodbye. (Dummy handler, will be removed)|
|
||||
| ChatHandler| Handles the logic for LLM chat with user. |
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Discord;
|
||||
using Discord.WebSocket;
|
||||
|
||||
namespace Lunaris2.Service
|
||||
@@ -6,14 +5,11 @@ namespace Lunaris2.Service
|
||||
public class VoiceChannelMonitorService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
// Track a cancellation source per voice channel when the bot is alone
|
||||
private readonly Dictionary<ulong, CancellationTokenSource> _leaveCtsByChannel = new();
|
||||
private readonly Dictionary<ulong, Timer> _timers = new();
|
||||
|
||||
public VoiceChannelMonitorService(DiscordSocketClient client)
|
||||
{
|
||||
_client = client;
|
||||
// Subscribe to voice state updates to react immediately
|
||||
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
|
||||
}
|
||||
|
||||
public void StartMonitoring()
|
||||
@@ -23,166 +19,53 @@ namespace Lunaris2.Service
|
||||
while (true)
|
||||
{
|
||||
await CheckVoiceChannels();
|
||||
await Task.Delay(TimeSpan.FromMinutes(1)); // Status refresh every minute
|
||||
await Task.Delay(TimeSpan.FromMinutes(1)); // Monitor every minute
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 voiceChannel in guild.VoiceChannels)
|
||||
{
|
||||
var botInChannel = voiceChannel.ConnectedUsers.Any(u => u.Id == _client.CurrentUser.Id);
|
||||
var userCount = voiceChannel.ConnectedUsers.Count;
|
||||
// Find voice channels where only the bot is left
|
||||
var voiceChannel = guild.VoiceChannels.FirstOrDefault(vc =>
|
||||
vc.ConnectedUsers.Count == 1 &&
|
||||
vc.Users.Any(u => u.Id == _client.CurrentUser.Id));
|
||||
|
||||
if (botInChannel && userCount == 1)
|
||||
if (voiceChannel != null)
|
||||
{
|
||||
// If timer not set for this channel, start one
|
||||
if (!_timers.ContainsKey(voiceChannel.Id))
|
||||
{
|
||||
// Schedule leave if not already scheduled
|
||||
if (!_leaveCtsByChannel.ContainsKey(voiceChannel.Id))
|
||||
{
|
||||
ScheduleLeave(voiceChannel);
|
||||
}
|
||||
Console.WriteLine($"Bot is alone in channel {voiceChannel.Name}, starting timer...");
|
||||
_timers[voiceChannel.Id] = new Timer(async _ => await LeaveChannel(voiceChannel), null,
|
||||
TimeSpan.FromMinutes(3), Timeout.InfiniteTimeSpan); // Set delay before leaving
|
||||
}
|
||||
else
|
||||
}
|
||||
else
|
||||
{
|
||||
// Clean up timer if channel is no longer active
|
||||
var timersToDispose = _timers.Where(t => guild.VoiceChannels.All(vc => vc.Id != t.Key)).ToList();
|
||||
foreach (var timer in timersToDispose)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
await timer.Value.DisposeAsync();
|
||||
_timers.Remove(timer.Key);
|
||||
Console.WriteLine($"Disposed timer for inactive voice channel ID: {timer.Key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnUserVoiceStateUpdated(SocketUser user, SocketVoiceState before, SocketVoiceState after)
|
||||
private async Task LeaveChannel(SocketVoiceChannel voiceChannel)
|
||||
{
|
||||
// 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 (voiceChannel.ConnectedUsers.Count == 1 && voiceChannel.Users.Any(u => u.Id == _client.CurrentUser.Id))
|
||||
{
|
||||
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;
|
||||
Console.WriteLine($"Leaving channel {voiceChannel.Name} due to inactivity...");
|
||||
await voiceChannel.DisconnectAsync();
|
||||
await _timers[voiceChannel.Id].DisposeAsync();
|
||||
_timers.Remove(voiceChannel.Id); // Clean up after leaving
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Cancelled because someone joined or bot moved
|
||||
}
|
||||
finally
|
||||
{
|
||||
_leaveCtsByChannel.Remove(voiceChannel.Id);
|
||||
cts.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
LOGOTYPE.png
BIN
LOGOTYPE.png
Binary file not shown.
|
Before Width: | Height: | Size: 230 KiB |
18
README.md
18
README.md
@@ -1,17 +1,15 @@
|
||||

|
||||
|
||||
# Lunaris - Discord BOT
|
||||
# Lunaris2 - Discord Music Bot
|
||||
|
||||
Lunaris2 is a Discord bot designed to play music in your server's voice channels. It's built using C# and the Discord.Net library, and it uses the LavaLink music client for audio streaming.
|
||||
|
||||
## 🎮Features
|
||||
## Features
|
||||
|
||||
- Play music from YouTube directly in your Discord server.
|
||||
- Skip tracks, pause, resume playback and more music related commands.
|
||||
- Skip tracks, pause, and resume playback.
|
||||
- Queue system to line up your favorite tracks.
|
||||
- Local LLM (AI chatbot) that answers on @mentions in Discord chat. See more about it below.
|
||||
|
||||
## 🤖 Setup
|
||||
## Setup
|
||||
|
||||
1. Clone the repo.
|
||||
2. Extract.
|
||||
@@ -29,8 +27,7 @@ The LLM is run using Ollama see more about Ollama [here](https://ollama.com/). R
|
||||
|
||||
## PM2 Setup
|
||||
- Install PM2 and configure it following their setup guide
|
||||
|
||||
#### 🐦🔥 Lavalink
|
||||
#### Lavalink
|
||||
* Download Lavalink 4.X.X (.jar)
|
||||
* Install Java 17
|
||||
|
||||
@@ -49,11 +46,6 @@ Register the Lunaris bot with PM2:
|
||||
- `/play <song>`: Plays the specified song in the voice channel you're currently in.
|
||||
- `/skip`: Skips the currently playing song.
|
||||
|
||||
## Technical Documentations
|
||||
- [Application Layout](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/README.md)
|
||||
* 🤖 [AI CHAT](https://github.com/Myxelium/Lunaris2.0/blob/master/Bot/Handler/ChatCommand/readme.md)
|
||||
* 🎵 [Music Player](https://github.com/Myxelium/Lunaris2.0/tree/master/Bot/Handler/MusicPlayer)
|
||||
|
||||
## Contributing
|
||||
|
||||
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
@@ -2,14 +2,6 @@ server: # REST and WS server
|
||||
port: 2333
|
||||
address: 0.0.0.0
|
||||
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
|
||||
@@ -57,9 +49,12 @@ plugins:
|
||||
# Clients are queried in the order they are given (so the first client is queried first and so on...)
|
||||
clients:
|
||||
- MUSIC
|
||||
- ANDROID_TESTSUITE
|
||||
- WEB
|
||||
- TVHTML5EMBEDDED
|
||||
- ANDROID_TESTSUITE
|
||||
# name: # Name of the plugin
|
||||
# some_key: some_value # Some key-value pair for the plugin
|
||||
# another_key: another_value
|
||||
lavalink:
|
||||
plugins:
|
||||
- dependency: com.github.devoxin:lavadspx-plugin:0.0.5 # replace {VERSION} with the latest version from the "Releases" tab.
|
||||
|
||||
Reference in New Issue
Block a user