using axXez.TS3.Audio; using axXez.TS3.Protocol; using System; using System.Collections.Generic; namespace axXez.TS3.Bot.Modules { [Module(Namespace = "axXez", DisplayName = "Music")] internal class MusicModule : Module { /// Default TS3 identity shared by all bots with no per-bot identity. Auto-generated and saved on first load if null. public string Identity { get; set; } = null; /// Minimum security level for the default identity and any per-bot identity without an explicit override. public int IdentitySecurityLevel { get; set; } = 8; /// Persisted list of music bot configurations, recreated on every connect. public List Bots { get; set; } = new(); /// Minimum role required to enqueue and start tracks. public CommandRole PlayRole { get; set; } = CommandRole.Guest; /// Minimum role required to skip the current track. public CommandRole SkipRole { get; set; } = CommandRole.Moderator; /// Minimum role required to pause or resume playback. public CommandRole PauseRole { get; set; } = CommandRole.Moderator; /// Minimum role required to stop playback and clear the queue. public CommandRole StopRole { get; set; } = CommandRole.Moderator; /// Minimum role required to change the volume. public CommandRole VolRole { get; set; } = CommandRole.Moderator; /// Extra arguments passed to every yt-dlp invocation. Use for cookies, rate limits, etc. (e.g. ["--cookies", "/app/cookies.txt"]). public List YtDlpArgs { get; set; } = new(); private readonly Dictionary _bots = new(StringComparer.OrdinalIgnoreCase); private VoiceIdentity _defaultIdentity; protected override void OnLoad() { AudioPlayer.EnsureDependencies(); SetCommandRole("play", PlayRole); SetCommandRole("skip", SkipRole); SetCommandRole("pause", PauseRole); SetCommandRole("resume", PauseRole); SetCommandRole("stop", StopRole); SetCommandRole("vol", VolRole); } protected override void OnStart() { bool dirty = false; if (IdentitySecurityLevel < Server.Configuration.NeededIdentitySecurityLevel) { IdentitySecurityLevel = Server.Configuration.NeededIdentitySecurityLevel; dirty = true; } if (Identity != null) { _defaultIdentity = VoiceIdentity.FromTsIdentity(Identity); if (_defaultIdentity.SecurityLevel < IdentitySecurityLevel) { Log($"Improving default identity security level to {IdentitySecurityLevel} (currently {_defaultIdentity.SecurityLevel})..."); _defaultIdentity.ImproveSecurity(IdentitySecurityLevel); dirty = true; } } else { _defaultIdentity = VoiceIdentity.Generate(IdentitySecurityLevel); dirty = true; } if (dirty) Identity = _defaultIdentity.ToTsIdentity(); foreach (var cfg in Bots) { bool identityImproved = false; if (cfg.Identity != null) { using var id = VoiceIdentity.FromTsIdentity(cfg.Identity); if (id.SecurityLevel < IdentitySecurityLevel) { Log($"Improving identity for bot in '{cfg.Channel}' to security level {IdentitySecurityLevel} (currently {id.SecurityLevel})..."); id.ImproveSecurity(IdentitySecurityLevel); cfg.Identity = id.ToTsIdentity(); dirty = true; identityImproved = true; } } if (!Server.FindChannel(cfg.Channel, out var ch)) { Log($"Saved music bot channel '{cfg.Channel}' not found, skipping.", LogType.Warning); continue; } if (_bots.TryGetValue(cfg.Nickname, out var existing)) { if (!identityImproved) continue; _bots.Remove(cfg.Nickname); existing.Dispose(); RemoveVoiceConnection(existing.Connection); } cfg.Channel = ch.Name; CreateBotInternal(cfg); } if (dirty) Save(); } protected override void OnUnload() { foreach (var bot in _bots.Values) bot.Dispose(); _bots.Clear(); _defaultIdentity?.Dispose(); _defaultIdentity = null; //base.OnUnload(); // don't auto-save; config is only written on explicit create/remove } private void CreateBotInternal(MusicBotConfig cfg) { VoiceIdentity identity = cfg.Identity != null ? VoiceIdentity.FromTsIdentity(cfg.Identity) : _defaultIdentity; var connection = CreateVoiceConnection(cfg.Nickname, identity, new VoiceClientOptions { DefaultChannel = cfg.Channel }); connection.Volume = cfg.Volume; connection.OnChannelChanged += (_, channelId) => { if (Channels.TryGetValue(channelId, out var ch)) { cfg.Channel = ch.Name; Save(); } }; var bot = new MusicBot(connection) { YtDlpArgs = YtDlpArgs }; _bots[cfg.Nickname] = bot; bot.Start(); } #region management commands [Command("music create", Description = "Creates a music bot with the given nickname in the given channel, or your current channel if omitted.", Role = CommandRole.Admin, Scope = CommandScope.Any)] public CommandResponse CreateBot(CommandContext ctx, [CommandParam("name")] string nickname, [CommandParam("channel")] string channelNameParam = null, bool ownIdentity = false, [CommandParam("security")] int securityLevel = 0) { if (_bots.ContainsKey(nickname)) return ctx.Error($"A music bot named [B]{nickname}[/B] already exists."); string channelName; if (channelNameParam != null) { if (!Server.FindChannel(channelNameParam, out var ch)) return ctx.Error($"Channel [B]{channelNameParam}[/B] not found."); channelName = ch.Name; } else { int channelId = ctx.Invoker.ChannelID; channelName = Channels.TryGetValue(channelId, out var ch) ? ch.Name : channelId.ToString(); } string identityStr = null; int targetLevel = securityLevel > 0 ? securityLevel : IdentitySecurityLevel; if (ownIdentity) { var id = VoiceIdentity.Generate(targetLevel); identityStr = id.ToTsIdentity(); } var cfg = new MusicBotConfig(channelName, nickname, identityStr); Bots.Add(cfg); Save(); CreateBotInternal(cfg); return ctx.Ok($"Music bot [B]{nickname}[/B] created in [B]{channelName}[/B]{(ownIdentity ? $" with dedicated identity (level {targetLevel})" : "")}."); } [Command("music name", Description = "Renames a music bot. Takes effect on next reconnect.", Role = CommandRole.Admin, Scope = CommandScope.Any)] public CommandResponse SetBotName(CommandContext ctx, [CommandParam("bot")] string currentNickname, [CommandParam("name")] string newNickname) { var cfg = Bots.Find(c => c.Nickname.Equals(currentNickname, StringComparison.OrdinalIgnoreCase)); if (cfg == null || !_bots.ContainsKey(currentNickname)) return ctx.Error($"No music bot named [B]{currentNickname}[/B]."); cfg.Nickname = newNickname; Save(); return ctx.Ok($"Bot [B]{currentNickname}[/B] renamed to [B]{newNickname}[/B]. Takes effect on next reconnect."); } [Command("music remove", Description = "Removes a music bot by nickname.", Role = CommandRole.Admin, Scope = CommandScope.Any)] public CommandResponse RemoveBot(CommandContext ctx, [CommandParam("name")] string nickname) { if (!_bots.Remove(nickname, out var bot)) return ctx.Error($"No music bot named [B]{nickname}[/B]."); var cfg = Bots.Find(c => c.Nickname.Equals(nickname, StringComparison.OrdinalIgnoreCase)); bot.Dispose(); RemoveVoiceConnection(bot.Connection); Bots.Remove(cfg); Save(); return ctx.Ok($"Music bot [B]{nickname}[/B] removed."); } [Command("music list", Description = "Lists all active music bots and their current channel.", Role = CommandRole.Admin, Scope = CommandScope.Any)] public CommandResponse ListBots(CommandContext ctx) { if (_bots.Count == 0) return ctx.Ok("No music bots active."); ctx.Reply($"[B]{_bots.Count}[/B] music bot(s) active:"); foreach (var (nick, bot) in _bots) { var cfg = Bots.Find(c => c.Nickname.Equals(nick, StringComparison.OrdinalIgnoreCase)); ctx.Reply($" [B]{nick}[/B] in [B]{cfg?.Channel ?? "?"}[/B] — {bot.QueueLength} track(s) queued"); } return ctx.Ok(); } #endregion #region playback commands private bool TryGetBot(CommandContext ctx, out MusicBot bot) { if (ctx.Source is not VoiceConnection vc) { bot = null; return false; } return _bots.TryGetValue(vc.Nickname, out bot); } [Command("play", Description = "Adds a track or playlist URL to the queue. Use list=true to enqueue an entire playlist.", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Play(CommandContext ctx, [CommandParam("url")] string url, [CommandParam("list")] bool list = false) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore // TS3 clients auto-wrap URLs in [URL]...[/URL] BBCode if (url.StartsWith("[URL]", StringComparison.OrdinalIgnoreCase) && url.EndsWith("[/URL]", StringComparison.OrdinalIgnoreCase)) url = url[5..^6]; if (list) { PlayPlaylistAsync(bot, url); return ctx.Ok("Resolving playlist..."); } bool startedNow = bot.Enqueue(url); return ctx.Ok(startedNow ? "Now playing." : "Added to queue."); } private async void PlayPlaylistAsync(MusicBot bot, string url) { try { string[] urls = await YtDlp.GetPlaylistUrlsAsync(url, YtDlpArgs); bot.EnqueueAll(urls); bot.Connection.SendChannelMessage($"Added [B]{urls.Length}[/B] track(s) from playlist to queue."); } catch (Exception ex) { GlobalLogger.LogError("MusicModule", $"Failed to resolve playlist [{url}]", ex); bot.Connection.SendChannelMessage($"Failed to load playlist: {ex.Message}"); } } [Command("skip", Description = "Skips the current track", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Skip(CommandContext ctx) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore bot.Skip(); return ctx.Ok("Skipped."); } [Command("stop", Description = "Stops playback and clears the queue", Role = CommandRole.Moderator, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Stop(CommandContext ctx) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore bot.StopAndClear(); return ctx.Ok("Stopped."); } [Command("pause", Description = "Pauses playback", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Pause(CommandContext ctx) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore bot.Connection.PausePlayback(); return ctx.Ok("Paused."); } [Command("resume", Description = "Resumes paused playback", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Resume(CommandContext ctx) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore bot.Connection.ResumePlayback(); return ctx.Ok("Resumed."); } [Command("playlist", Description = "Shows the tracks currently waiting in the queue", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Playlist(CommandContext ctx) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore var tracks = bot.GetQueue(); if (tracks.Length == 0) return ctx.Ok("Queue is empty."); ctx.Reply($"[B]{tracks.Length}[/B] track(s) in queue:"); for (int i = 0; i < tracks.Length; i++) ctx.Reply($" [B]{i + 1}.[/B] {tracks[i]}"); return ctx.Ok(); } [Command("vol", Description = "Gets or sets the playback volume (0–200%). Omit to show current.", Role = CommandRole.Guest, Scope = CommandScope.Channel | CommandScope.Private)] public CommandResponse Vol(CommandContext ctx, [CommandParam("volume")] string percent = null) { if (!TryGetBot(ctx, out var bot)) return ctx.Ok(); //silently ignore if (percent == null) return ctx.Ok($"Volume: [B]{(int)(bot.Connection.Volume * 100)}%[/B]"); if (!int.TryParse(percent, out int val) || val < 0 || val > 200) return ctx.Error("Volume must be a number between 0 and 200."); bot.Connection.Volume = val / 100f; var cfg = Bots.Find(c => c.Nickname.Equals(bot.Connection.Nickname, StringComparison.OrdinalIgnoreCase)); if (cfg != null) { cfg.Volume = bot.Connection.Volume; Save(); } return ctx.Ok($"Volume set to [B]{val}%[/B]."); } #endregion public class MusicBotConfig { /// Channel name or numeric ID string used to locate the channel on connect. public string Channel { get; set; } /// Nickname for this bot. Null falls back to the module-level . public string Nickname { get; set; } /// TS3 identity string ({KeyOffset}V{base64}). Null uses the module-level default identity. public string Identity { get; set; } /// Playback volume multiplier (0.0–2.0). Persisted across restarts. public float Volume { get; set; } = 0.5f; public MusicBotConfig(string channel, string nickname, string identity) { Channel = channel; Nickname = nickname; Identity = identity; } } } }