From 44393f174589dd0bc99a27e5088738bf271c13eb Mon Sep 17 00:00:00 2001 From: skyjoshua Date: Sat, 18 Apr 2026 02:25:11 +0100 Subject: [PATCH] v3.0.0 - Initial Commit --- .gitignore | 2 +- SkyBot/Commands/CommandRegistry.cs | 33 +++++ SkyBot/Commands/CommandTemplate.cs | 19 +++ SkyBot/Commands/Dev/Delete.cs | 55 +++++++++ SkyBot/Commands/Dev/Planet.cs | 139 +++++++++++++++++++++ SkyBot/Commands/Dev/React.cs | 58 +++++++++ SkyBot/Commands/Fun/Echo.cs | 46 +++++++ SkyBot/Commands/Info/Help.cs | 160 +++++++++++++++++++++++++ SkyBot/Helpers/EmbedStyles.cs | 51 ++++++++ SkyBot/Helpers/MessageHelper.cs | 64 ++++++++++ SkyBot/Helpers/PendingConfirmations.cs | 29 +++++ SkyBot/Helpers/PermissionHelper.cs | 42 +++++++ SkyBot/Models/CommandContext.cs | 17 +++ SkyBot/Models/ICommand.cs | 13 ++ SkyBot/Services/BotService.cs | 32 +++++ SkyBot/Services/ChannelService.cs | 47 ++++++++ SkyBot/Services/MessageService.cs | 86 +++++++++++++ SkyBot/Services/PlanetService.cs | 37 ++++++ SkyBot/SkyBot.cs | 29 +++++ SkyBot/SkyBot.csproj | 16 +++ 20 files changed, 974 insertions(+), 1 deletion(-) create mode 100644 SkyBot/Commands/CommandRegistry.cs create mode 100644 SkyBot/Commands/CommandTemplate.cs create mode 100644 SkyBot/Commands/Dev/Delete.cs create mode 100644 SkyBot/Commands/Dev/Planet.cs create mode 100644 SkyBot/Commands/Dev/React.cs create mode 100644 SkyBot/Commands/Fun/Echo.cs create mode 100644 SkyBot/Commands/Info/Help.cs create mode 100644 SkyBot/Helpers/EmbedStyles.cs create mode 100644 SkyBot/Helpers/MessageHelper.cs create mode 100644 SkyBot/Helpers/PendingConfirmations.cs create mode 100644 SkyBot/Helpers/PermissionHelper.cs create mode 100644 SkyBot/Models/CommandContext.cs create mode 100644 SkyBot/Models/ICommand.cs create mode 100644 SkyBot/Services/BotService.cs create mode 100644 SkyBot/Services/ChannelService.cs create mode 100644 SkyBot/Services/MessageService.cs create mode 100644 SkyBot/Services/PlanetService.cs create mode 100644 SkyBot/SkyBot.cs create mode 100644 SkyBot/SkyBot.csproj diff --git a/.gitignore b/.gitignore index c8bbd73..54332bb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ .gitignore **/bin/ **/obj/ -**/SkyBot.sln +**.sln **/database.db **/Config.cs diff --git a/SkyBot/Commands/CommandRegistry.cs b/SkyBot/Commands/CommandRegistry.cs new file mode 100644 index 0000000..b01feae --- /dev/null +++ b/SkyBot/Commands/CommandRegistry.cs @@ -0,0 +1,33 @@ +using SkyBot.Models; + +namespace SkyBot.Commands +{ + public static class CommandRegistry + { + public static readonly Dictionary Commands = new(); + public static readonly Dictionary> Categories = new(); + + static CommandRegistry() + { + var commands = AppDomain.CurrentDomain.GetAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(t => typeof(ICommand).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract) + .Select(t => (ICommand?)Activator.CreateInstance(t)) + .Select(c => c!); + + foreach (var cmd in commands) + { + Commands[cmd.Name.ToLower()] = cmd; + foreach (var alias in cmd.Aliases) + { + Commands[alias.ToLower()] = cmd; + } + + Categories = Commands.Values + .Distinct() + .GroupBy(c => c.Category.ToLower()) + .ToDictionary(g => g.Key, g => g.ToList()); + } + } + } +} \ No newline at end of file diff --git a/SkyBot/Commands/CommandTemplate.cs b/SkyBot/Commands/CommandTemplate.cs new file mode 100644 index 0000000..3e8a324 --- /dev/null +++ b/SkyBot/Commands/CommandTemplate.cs @@ -0,0 +1,19 @@ +using SkyBot.Models; + +namespace SkyBot.Commands +{ + public class CommandTemplate : ICommand + { + public string Name => "template"; + public string[] Aliases => []; + public string Description => ""; + public string Category => "template"; + public string Usage => ""; + public string[] SubCommands => []; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.Channel.Id, out var channel)) return; + } + } +} \ No newline at end of file diff --git a/SkyBot/Commands/Dev/Delete.cs b/SkyBot/Commands/Dev/Delete.cs new file mode 100644 index 0000000..5ea505c --- /dev/null +++ b/SkyBot/Commands/Dev/Delete.cs @@ -0,0 +1,55 @@ +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Shared.Authorization; + +namespace SkyBot.Commands +{ + public class Delete : ICommand + { + public string Name => "delete"; + public string[] Aliases => []; + public string Description => "Deletes a message by the bot"; + public string Category => "Dev"; + public string Usage => "delete"; + public string[] SubCommands => []; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.Channel.Id, out var channel)) return; + + if (!PermissionHelper.IsOwner(ctx.Member)) + { + await MessageHelper.ReplyAsync(ctx, "You do not have permission to execute this command."); + return; + } + + + + if (ctx.Message.ReplyToId is null) + { + await MessageHelper.ReplyAsync(ctx, "Please reply to the message you would like to delete"); + return; + } + + if (ctx.Message.ReplyToId is not long replyToId) + { + await MessageHelper.ReplyAsync(ctx, "Please reply to the message you want to delete."); + return; + } + if (!ctx.Client.Cache.Messages.TryGet(replyToId, out var replyMsg)) + { + await MessageHelper.ReplyAsync(ctx, "Could not find the replied message in cache."); + return; + } + + if (replyMsg!.AuthorUserId != ctx.Client.Me.Id && !await PermissionHelper.HasPermAsync(ctx.Planet.MyMember, [ChatChannelPermissions.ManageMessages], channel)) + { + await MessageHelper.ReplyAsync(ctx, "I do not have permission to delete other members' messages in this channel."); + return; + } + + await replyMsg!.DeleteAsync(); + await ctx.Message.AddReactionAsync("👍"); + } + } +} \ No newline at end of file diff --git a/SkyBot/Commands/Dev/Planet.cs b/SkyBot/Commands/Dev/Planet.cs new file mode 100644 index 0000000..3448a5c --- /dev/null +++ b/SkyBot/Commands/Dev/Planet.cs @@ -0,0 +1,139 @@ +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Models; +using Valour.Sdk.Models.Messages.Embeds; +using Valour.Shared; + +namespace SkyBot.Commands +{ + public class PlanetCmds : ICommand + { + public string Name => "planet"; + public string[] Aliases => []; + public string Description => "Planet Commands"; + public string Category => "Dev"; + public string Usage => "planet "; + public string[] SubCommands => ["join", "leave", "list"]; + + public async Task Execute(CommandContext ctx) + { + if (ctx.Message.AuthorUserId != Config.OwnerId) return; + + string sub = ctx.Args.Length > 0 ? ctx.Args[0].ToLower() : ""; + + switch (sub) + { + case "join": + await HandleJoin(ctx); + break; + + case "leave": + await HandleLeave(ctx); + break; + + case "list": + await HandleList(ctx); + break; + + default: + await MessageHelper.ReplyAsync(ctx, $"Usage: {Config.Prefix}planet "); + break; + } + } + + private static async Task HandleJoin(CommandContext ctx) + { + if (!long.TryParse(ctx.Args.Length > 1 ? ctx.Args[1] : null, out long planetId)) + { + await MessageHelper.ReplyAsync(ctx, "Please provide a valid planet ID."); + return; + } + + if (ctx.Client.PlanetService.JoinedPlanets.Any(p => p.Id == planetId)) + { + Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId); + await MessageHelper.ReplyAsync(ctx, $"Bot is already a member of {planet.Name} (ID: {planet.Id})"); + return; + } + + string? inviteCode = ctx.Args.Length > 2 ? ctx.Args[2] : null; + + TaskResult joinResult = inviteCode is null + ? await ctx.Client.PlanetService.JoinPlanetAsync(planetId) + : await ctx.Client.PlanetService.JoinPlanetAsync(planetId, inviteCode); + + if (!joinResult.Success) + { + await MessageHelper.ReplyAsync(ctx, $"Failed to join planet: {joinResult.Message}"); + } + else + { + Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId); + await MessageHelper.ReplyAsync(ctx, $"Successfully joined planet: {planet.Name} (ID {planet.Id})"); + } + } + + private static async Task HandleLeave(CommandContext ctx) + { + if (!long.TryParse(ctx.Args.Length > 1 ? ctx.Args[1] : ctx.Planet.Id.ToString(), out long planetId)) + { + await MessageHelper.ReplyAsync(ctx, "Please provide a valid planet ID or no planet ID to leave this planet."); + return; + } + + Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId); + + await MessageHelper.ReplyAsync(ctx, $"Are you sure you want to leave planet: {planet.Name}? Type `{Config.Prefix}confirm within 30 seconds to confirm."); + bool confirmed = await PendingConfirmations.WaitAsync(ctx.Member.UserId, TimeSpan.FromSeconds(30)); + + if (!confirmed) + { + await MessageHelper.ReplyAsync(ctx, "Confirmation timed out."); + return; + } + + await MessageHelper.ReplyAsync(ctx, $"Leaving planet: {planet.Name}..."); + await ctx.Client.PlanetService.LeavePlanetAsync(planet); + } + + private static async Task HandleList(CommandContext ctx) + { + const int PageSize = 10; + + var planets = ctx.Client.PlanetService.JoinedPlanets.ToList(); + + var chunks = planets + .Select((p, i) => (p, i)) + .GroupBy(x => x.i / PageSize) + .Select(g => g.Select(x => x.p).ToList()) + .ToList(); + + if (chunks.Count == 0) + { + await MessageHelper.ReplyAsync(ctx, "Bot is not a member of any planets."); + return; + } + + var builder = new EmbedBuilder(); + builder.embed.HideChangePageArrows = true; + + for (int chunkIndex = 0; chunkIndex < chunks.Count; chunkIndex++) + { + int embedPage = chunkIndex; + string? footer = chunks.Count > 1 ? $"Page {chunkIndex + 1}/{chunks.Count}" : null; + + builder.AddPage($"Planets ({planets.Count} total)", footer); + + foreach (var planet in chunks[chunkIndex]) + builder.AddText(planet.Name, $"ID: {planet.Id}"); + + if (chunkIndex > 0) + builder.AddButton("← Prev").OnClickGoToEmbedPage(embedPage - 1); + if (chunkIndex < chunks.Count - 1) + builder.AddButton("Next →").OnClickGoToEmbedPage(embedPage + 1); + } + + await MessageHelper.ReplyAsync(ctx, null, builder.embed); + } + }; +}; \ No newline at end of file diff --git a/SkyBot/Commands/Dev/React.cs b/SkyBot/Commands/Dev/React.cs new file mode 100644 index 0000000..ada0dc2 --- /dev/null +++ b/SkyBot/Commands/Dev/React.cs @@ -0,0 +1,58 @@ +using SkyBot.Helpers; +using SkyBot.Models; + +namespace SkyBot.Commands +{ + public class React : ICommand + { + public string Name => "react"; + public string[] Aliases => []; + public string Description => "Adds a reaction to a replied message."; + public string Category => "Dev"; + public string Usage => "dev [amount]"; + public string[] SubCommands => []; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.Channel.Id, out var channel)) return; + + if (!PermissionHelper.IsOwner(ctx.Member)) + { + await MessageHelper.ReplyAsync(ctx, "You do not have permission to execute this command."); + return; + } + + if (ctx.Args.Length < 1) + { + await MessageHelper.ReplyAsync(ctx, $"Usage: `{Config.Prefix}react [amount]"); + return; + } + + string emoji = ctx.Args[0]; + + int amount = 1; + if (ctx.Args.Length >= 2 && !int.TryParse(ctx.Args[1], out amount)) + { + await MessageHelper.ReplyAsync(ctx, $"`{ctx.Args[1]}` is not a valid number. Defaulting to `1`"); + amount = 1; + } + + if (ctx.Message.ReplyToId is not long replyToId) + { + await MessageHelper.ReplyAsync(ctx, "Please reply to the message you want to add the reaction to."); + return; + } + if (!ctx.Client.Cache.Messages.TryGet(replyToId, out var replyMsg)) + { + await MessageHelper.ReplyAsync(ctx, "Could not find the replied message in cache."); + return; + } + + for (int i = 0; i < amount; i++) + { + await replyMsg!.AddReactionAsync(emoji); + } + await ctx.Message.AddReactionAsync("👍"); + } + } +} \ No newline at end of file diff --git a/SkyBot/Commands/Fun/Echo.cs b/SkyBot/Commands/Fun/Echo.cs new file mode 100644 index 0000000..9174252 --- /dev/null +++ b/SkyBot/Commands/Fun/Echo.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Models; +using Valour.Shared; + +namespace SkyBot.Commands +{ + public class Echo : ICommand + { + public string Name => "echo"; + public string[] Aliases => ["say"]; + public string Description => "Echos your message as the bot"; + public string Category => "Fun"; + public string Usage => "echo "; + public string[] SubCommands => []; + + public static readonly ConcurrentDictionary EchoMap = new(); + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.Channel.Id, out var channel)) return; + + string reply = string.Join(' ', ctx.Args); + if (string.IsNullOrWhiteSpace(reply)) + { + await MessageHelper.ReplyAsync(ctx, "Please enter a message to echo."); + return; + } + + if (reply.Length > 2048) + { + reply = reply.Substring(0, 2048); + } + + TaskResult echoedMsg = await MessageHelper.ReplyAsync(ctx, reply); + + if (echoedMsg.Success && echoedMsg.Data is not null) + { + EchoMap[ctx.Message.Id] = echoedMsg.Data.Id; + } + + + } + } +} \ No newline at end of file diff --git a/SkyBot/Commands/Info/Help.cs b/SkyBot/Commands/Info/Help.cs new file mode 100644 index 0000000..fa7b495 --- /dev/null +++ b/SkyBot/Commands/Info/Help.cs @@ -0,0 +1,160 @@ +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Models.Messages.Embeds; + +namespace SkyBot.Commands +{ + public class Help : ICommand + { + public string Name => "help"; + public string[] Aliases => ["cmds"]; + public string Description => "Shows all commands by category."; + public string Category => "Info"; + public string Usage => "help"; + public string[] SubCommands => []; + + private const int PageSize = 10; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.Channel.Id, out var channel)) return; + + var categories = CommandRegistry.Categories + .Where(c => c.Key != "template") + .Where(c => c.Key != "dev" || PermissionHelper.IsOwner(ctx.Member)) + .OrderBy(c => c.Key) + .ToList(); + + var categoryFirstPage = new Dictionary(); + var allChunks = new List<(string CategoryName, List Cmds, int ChunkIndex, int TotalChunks)>(); + + int nextPageIndex = 1; + foreach (var (categoryName, cmds) in categories) + { + var ordered = cmds.OrderBy(c => c.Name).ToList(); + var chunks = ordered + .Select((cmd, i) => (cmd, i)) + .GroupBy(x => x.i / PageSize) + .Select(g => g.Select(x => x.cmd).ToList()) + .ToList(); + + categoryFirstPage[categoryName] = nextPageIndex; + for (int c = 0; c < chunks.Count; c++) + allChunks.Add((categoryName, chunks[c], c, chunks.Count)); + + nextPageIndex += chunks.Count; + } + + var allCmds = allChunks + .SelectMany(chunk => chunk.Cmds) + .DistinctBy(cmd => cmd.Name) + .ToList(); + + var cmdDetailPage = new Dictionary(); + int detailPageIndex = nextPageIndex; + foreach (var cmd in allCmds) + cmdDetailPage[cmd.Name] = detailPageIndex++; + + var builder = new EmbedBuilder(); + builder.embed.HideChangePageArrows = true; + + // Home page + builder.AddPage("✦ Help Menu", $"Prefix: {Config.Prefix}"); + builder.AddRow() + .AddText("Select a Category") + .WithStyles(EmbedStyles.LabelText) + .CloseRow(); + + builder.AddRow(); + foreach (var (categoryName, cmds) in categories) + { + builder.AddButton($"{categoryName.ToTitleCase()} ({cmds.Count})") + .WithStyles(EmbedStyles.CategoryBtn) + .OnClickGoToEmbedPage(categoryFirstPage[categoryName]); + } + builder.CloseRow(); + + // Category pages + int embedPage = 1; + foreach (var (categoryName, cmds, chunkIndex, totalChunks) in allChunks) + { + string? footer = totalChunks > 1 + ? $"Page {chunkIndex + 1}/{totalChunks} | Prefix: {Config.Prefix}" + : $"Prefix: {Config.Prefix}"; + + builder.AddPage($"✦ {categoryName.ToTitleCase()} Commands", footer); + + foreach (var cmd in cmds) + { + builder.AddRow() + .AddButton(cmd.Name) + .WithStyles(EmbedStyles.CommandBtn) + .OnClickGoToEmbedPage(cmdDetailPage[cmd.Name]) + .CloseRow(); + } + + builder.AddRow() + .AddButton("← Back") + .WithStyles(EmbedStyles.BackBtn) + .OnClickGoToEmbedPage(0); + + if (chunkIndex > 0) + { + builder.AddButton("← Prev") + .WithStyles(EmbedStyles.NavBtn) + .OnClickGoToEmbedPage(embedPage - 1); + } + + if (chunkIndex < totalChunks - 1) + { + builder.AddButton("Next →") + .WithStyles(EmbedStyles.NavBtn) + .OnClickGoToEmbedPage(embedPage + 1); + } + + builder.CloseRow(); + + embedPage++; + } + + // Command detail pages + foreach (var cmd in allCmds) + { + builder.AddPage($"✦ {cmd.Name.ToTitleCase()}", $"Prefix: {Config.Prefix}"); + + builder.AddRow() + .AddText("Description", cmd.Description) + .CloseRow(); + + if (cmd.Aliases.Length > 0) + { + builder.AddRow() + .AddText("Aliases", string.Join(", ", cmd.Aliases)) + .CloseRow(); + } + + if (!string.IsNullOrWhiteSpace(cmd.Usage)) + { + builder.AddRow() + .AddText("Usage", $"{Config.Prefix}{cmd.Usage}") + .CloseRow(); + } + + if (cmd.SubCommands.Length > 0) + { + builder.AddRow() + .AddText("Sub-commands", string.Join(", ", cmd.SubCommands.Select(s => s.ToTitleCase()))) + .CloseRow(); + } + + builder.AddRow() + .AddButton("← Back") + .WithStyles(EmbedStyles.BackBtn) + .OnClickGoToEmbedPage(categoryFirstPage[cmd.Category.ToLower()]) + .CloseRow(); + } + + await MessageHelper.ReplyAsync(ctx, null, builder.embed); + } + } +} diff --git a/SkyBot/Helpers/EmbedStyles.cs b/SkyBot/Helpers/EmbedStyles.cs new file mode 100644 index 0000000..876cfa3 --- /dev/null +++ b/SkyBot/Helpers/EmbedStyles.cs @@ -0,0 +1,51 @@ +using Valour.Sdk.Models.Messages.Embeds.Styles; +using Valour.Sdk.Models.Messages.Embeds.Styles.Basic; + +namespace SkyBot.Helpers +{ + public static class EmbedStyles + { + private static readonly Size Radius = new(Unit.Pixels, 8); + private static readonly Size PadV = new(Unit.Pixels, 2); + private static readonly Size PadH = new(Unit.Pixels, 6); + private static readonly Size FitContent = new(Unit.FitContent); + + public static StyleBase[] LabelText => [ + new TextColor("#a0a0b8"), + new FontWeight(600), + ]; + + public static StyleBase[] CategoryBtn => [ + new BackgroundColor("#5865F2"), + new TextColor("#ffffff"), + new BorderRadius(Radius), + new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV), + new FontWeight(600), + ]; + + public static StyleBase[] CommandBtn => [ + new BackgroundColor("#2b2d3e"), + new TextColor("#e0e0f0"), + new BorderRadius(Radius), + new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV), + new Width(FitContent), + ]; + + public static StyleBase[] NavBtn => [ + new BackgroundColor("#1e1f2e"), + new TextColor("#a0a0b8"), + new BorderRadius(Radius), + new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV), + new Width(FitContent), + ]; + + public static StyleBase[] BackBtn => [ + new BackgroundColor("#5865F2"), + new TextColor("#ffffff"), + new BorderRadius(Radius), + new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV), + new FontWeight(600), + new Width(FitContent), + ]; + } +} diff --git a/SkyBot/Helpers/MessageHelper.cs b/SkyBot/Helpers/MessageHelper.cs new file mode 100644 index 0000000..292165e --- /dev/null +++ b/SkyBot/Helpers/MessageHelper.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Text.Json; +using SkyBot.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; +using Valour.Sdk.Models.Messages.Embeds; +using Valour.Shared; + +namespace SkyBot.Helpers +{ + public static class MessageHelper + { + public static string Mention(this PlanetMember member) => $"«@m-{member.Id}»"; + public static string Mention(this User user) => $"«@u-{user.Id}»"; + public static string ToTitleCase(this string str) => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(str); + + public static async Task> ReplyAsync(CommandContext ctx, string? content, Embed? embed = null, bool reply = false) + { + long? replyToId; + + if (reply) + { + replyToId = ctx.Message.ReplyToId; + } + else + { + replyToId = ctx.Message.Id; + } + + string? embedData; + if (embed is not null) + { + embedData = JsonSerializer.Serialize(embed); + } + else + { + embedData = null; + } + + + + Message msg = new Message(ctx.Client) + { + Content = content, + EmbedData = embedData, + ChannelId = ctx.Channel.Id, + PlanetId = ctx.Planet.Id, + AuthorUserId = ctx.Client.Me.Id, + AuthorMemberId = ctx.Channel.Planet?.MyMember.Id, + ReplyToId = replyToId, + Fingerprint = Guid.NewGuid().ToString() + }; + return await ctx.Client.MessageService.SendMessage(msg); + } + + + public static async Task> EditAsync(Channel channel, Message message, string content) + { + message.Content = content; + return await channel.Planet.Node.PutAsyncWithResponse($"api/messages/{message.Id}", message); + } + + }; +}; \ No newline at end of file diff --git a/SkyBot/Helpers/PendingConfirmations.cs b/SkyBot/Helpers/PendingConfirmations.cs new file mode 100644 index 0000000..6b9a59a --- /dev/null +++ b/SkyBot/Helpers/PendingConfirmations.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace SkyBot.Helpers +{ + public static class PendingConfirmations + { + private static readonly ConcurrentDictionary> pending = new(); + public static bool IsPending(long userId) => pending.ContainsKey(userId); + + public static Task WaitAsync(long userId, TimeSpan timeout) + { + var tcs = new TaskCompletionSource(); + pending[userId] = tcs; + _ = Task.Delay(timeout).ContinueWith(_ => tcs.TrySetResult(false)); + return tcs.Task; + } + + public static bool TryComplete(long userId, bool confirmed) + { + if (pending.TryRemove(userId, out var tcs)) + { + return tcs.TrySetResult(confirmed); + } + return false; + } + + + } +} \ No newline at end of file diff --git a/SkyBot/Helpers/PermissionHelper.cs b/SkyBot/Helpers/PermissionHelper.cs new file mode 100644 index 0000000..5fc2ec4 --- /dev/null +++ b/SkyBot/Helpers/PermissionHelper.cs @@ -0,0 +1,42 @@ +using Valour.Sdk.Models; +using Valour.Shared.Authorization; + +namespace SkyBot.Helpers +{ + public static class PermissionHelper + { + public static async Task HasPermAsync(PlanetMember member, Permission[] permissions, Channel? channel = null, bool requireAll = false) + { + if (member is null) return false; + if (member.HasPermission(PlanetPermissions.FullControl)) return true; + if (member.Roles.Any(r => r.IsAdmin)) return true; + + var results = new List(); + + foreach (var perm in permissions) + { + bool result = perm switch + { + PlanetPermission p => member.HasPermission(p), + ChatChannelPermission p => channel is not null + ? await channel.HasPermissionAsync(member, p) + : throw new ArgumentNullException(nameof(channel), $"Channel is required for ChatChannelPermission '{p.Name}'"), + VoiceChannelPermission p => channel is not null + ? await channel.HasPermissionAsync(member, p) + : throw new ArgumentNullException(nameof(channel), $"Channel is required for VoiceChannelPermission '{p.Name}'"), + _ => throw new ArgumentException($"Unsupported permission type: {perm.GetType().Name}") + }; + + results.Add(result); + } + + return requireAll ? results.All(r => r) : results.Any(r => r); + } + + public static bool IsOwner(PlanetMember member) + { + if (member is null) return false; + return member.UserId == Config.OwnerId; + } + } +} diff --git a/SkyBot/Models/CommandContext.cs b/SkyBot/Models/CommandContext.cs new file mode 100644 index 0000000..a413b65 --- /dev/null +++ b/SkyBot/Models/CommandContext.cs @@ -0,0 +1,17 @@ +using System.Collections.Concurrent; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace SkyBot.Models +{ + public class CommandContext + { + public required ValourClient Client { get; set; } + public required ConcurrentDictionary ChannelCache { get; set; } + public required PlanetMember Member { get; set; } + public required Message Message { get; set; } + public required Planet Planet { get; set; } + public required Channel Channel { get; set; } + public required string[] Args { get; set; } + }; +}; \ No newline at end of file diff --git a/SkyBot/Models/ICommand.cs b/SkyBot/Models/ICommand.cs new file mode 100644 index 0000000..c48f203 --- /dev/null +++ b/SkyBot/Models/ICommand.cs @@ -0,0 +1,13 @@ +namespace SkyBot.Models +{ + public interface ICommand + { + string Name { get; } + string[] Aliases { get; } + string Description { get; } + string Category { get; } + string Usage { get; } + string[] SubCommands { get; } + Task Execute(CommandContext ctx); + } +} \ No newline at end of file diff --git a/SkyBot/Services/BotService.cs b/SkyBot/Services/BotService.cs new file mode 100644 index 0000000..e80f0b6 --- /dev/null +++ b/SkyBot/Services/BotService.cs @@ -0,0 +1,32 @@ +using System.Collections.Concurrent; +using DotNetEnv; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace SkyBot.Services +{ + public static class BotService + { + public static async Task InitialiseBotAsync( + ValourClient client, + ConcurrentDictionary channelCache, + ConcurrentDictionary initialisedPlanets + ) + { + Env.Load(); + var token = Environment.GetEnvironmentVariable("TOKEN"); + if (string.IsNullOrWhiteSpace(token)) {Console.WriteLine($"TOKEN not set in .env"); return;} + + var loginResult = await client.InitializeUser(token); + if (!loginResult.Success) {Console.WriteLine($"Login Failed: {loginResult.Message}"); return;} + Console.WriteLine($"Logged in as {client.Me.NameAndTag} (ID: {client.Me.Id})"); + + await PlanetService.InitialisePlanetsAsync(client, channelCache, initialisedPlanets); + client.PlanetService.JoinedPlanetsUpdated += async () => { await PlanetService.InitialisePlanetsAsync(client, channelCache, initialisedPlanets); }; + + client.MessageService.MessageReceived += async message => { await MessageService.Create(client, channelCache, message); }; + client.MessageService.MessageDeleted += async message => { await MessageService.Delete(client, message); }; + + } + } +} \ No newline at end of file diff --git a/SkyBot/Services/ChannelService.cs b/SkyBot/Services/ChannelService.cs new file mode 100644 index 0000000..fe8571d --- /dev/null +++ b/SkyBot/Services/ChannelService.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; +using Valour.Sdk.Models; +using Valour.Shared.Models; + +namespace SkyBot.Services +{ + public static class ChannelService + { + public static async Task InitialiseChannelsAsync( + ConcurrentDictionary channelCache, + Planet planet + ) + { + foreach (var channel in planet.Channels) + { + channelCache[channel.Id] = channel; + } + + _ = Task.Run(async () => + { + foreach (var channel in planet.Channels.Where(c => c.ChannelType == ChannelTypeEnum.PlanetChat)) + { + try + { + await channel.OpenWithResult("SkyBot"); + Console.WriteLine($"Realtime opened for: {planet.Name} (ID: {planet.Id}) -> {channel.Name} (ID: {channel.Id})"); + } catch (Exception ex) + { + Console.WriteLine($"Error opening realtime for {channel.Id}: {ex.Message}"); + } + } + + Console.WriteLine($"All channels opened for {planet.Name}"); + }); + } + + public static async Task InitialiseChannelAsync( + ConcurrentDictionary channelCache, + Channel channel + ) + { + channelCache[channel.Id] = channel; + await channel.OpenWithResult("SkyBot"); + Console.WriteLine($"Realtime opened for: {channel.Planet.Name} (ID: {channel.Planet.Id}) -> {channel.Name} (ID: {channel.Id})"); + } + } +} \ No newline at end of file diff --git a/SkyBot/Services/MessageService.cs b/SkyBot/Services/MessageService.cs new file mode 100644 index 0000000..9688ee1 --- /dev/null +++ b/SkyBot/Services/MessageService.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using SkyBot.Commands; +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace SkyBot.Services +{ + public static class MessageService + { + private static readonly ConcurrentDictionary _cooldowns = new(); + private static readonly TimeSpan _cooldown = TimeSpan.FromSeconds(2); + public static async Task Create( + ValourClient client, + ConcurrentDictionary channelCache, + Message message + ) + { + if (message.AuthorUserId == client.Me.Id) return; + + string prefix = Config.Prefix; + string content = message.Content; + PlanetMember member = await message.FetchAuthorMemberAsync(); + + if (!content.ToLower().StartsWith(prefix)) return; + if (!channelCache.TryGetValue(message.ChannelId, out var channel)) return; + if (string.IsNullOrWhiteSpace(content)) return; + + string[] parts = content.Substring(prefix.Length).Split(' ', StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length == 0) return; + + string command = parts[0].ToLower(); + string[] args = parts[1..]; + + CommandContext ctx = new CommandContext + { + Client = client, + ChannelCache = channelCache, + Member = member, + Message = message, + Planet = message.Planet, + Channel = channel, + Args = args + }; + + + if (PendingConfirmations.IsPending(member.UserId)) + { + PendingConfirmations.TryComplete(member.UserId, content.Trim().ToLower() == $"{prefix}confirm"); + return; + } + + if (_cooldowns.TryGetValue(member.Id, out var lastUsed) && DateTime.UtcNow - lastUsed < _cooldown) return; + + _cooldowns[member.Id] = DateTime.UtcNow; + + if (CommandRegistry.Commands.TryGetValue(command, out var handler)) + { + await handler.Execute(ctx); + } + else + { + await MessageHelper.ReplyAsync(ctx, $"Unknown command `{command}`."); + } + } + + + + + public static async Task Delete( + ValourClient client, + Message message + ) + { + if (Echo.EchoMap.TryRemove(message.Id, out var echoId)) + { + if (client.Cache.Messages.TryGet(echoId, out var echoMsg)) + { + await echoMsg!.DeleteAsync(); + } + } + } + } +} \ No newline at end of file diff --git a/SkyBot/Services/PlanetService.cs b/SkyBot/Services/PlanetService.cs new file mode 100644 index 0000000..05e9f4f --- /dev/null +++ b/SkyBot/Services/PlanetService.cs @@ -0,0 +1,37 @@ +using System.Collections.Concurrent; +using Valour.Sdk.Client; +using Valour.Sdk.ModelLogic; +using Valour.Sdk.Models; + +namespace SkyBot.Services +{ + public static class PlanetService + { + public static async Task InitialisePlanetsAsync( + ValourClient client, + ConcurrentDictionary channelCache, + ConcurrentDictionary initialisedPlanets + ) + { + foreach (var planet in client.PlanetService.JoinedPlanets.Where(p => !initialisedPlanets.ContainsKey(p.Id))) + { + Console.WriteLine($"Initialising Planet: {planet.Name}"); + + await planet.EnsureReadyAsync(); + await planet.FetchInitialDataAsync(); + await ChannelService.InitialiseChannelsAsync(channelCache, planet); + + planet.Channels.Changed += async channelEvent => + { + if (channelEvent is ModelAddedEvent addedEvent) + { + await ChannelService.InitialiseChannelAsync(channelCache, addedEvent.Model); + } + await ChannelService.InitialiseChannelsAsync(channelCache, planet); + }; + + initialisedPlanets.TryAdd(planet.Id, true); + } + } + }; +}; \ No newline at end of file diff --git a/SkyBot/SkyBot.cs b/SkyBot/SkyBot.cs new file mode 100644 index 0000000..490bdd0 --- /dev/null +++ b/SkyBot/SkyBot.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; +using SkyBot.Services; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace SkyBot +{ + public class Program + { + private static readonly ValourClient client = new("https://api.valour.gg/"); + private static readonly ConcurrentDictionary channelCache = new(); + private static readonly ConcurrentDictionary initialisedPlanets = new(); + + public static async Task Main() + { + client.SetupHttpClient(); + try + { + await BotService.InitialiseBotAsync(client, channelCache, initialisedPlanets); + + Console.WriteLine("Ready and Listening..."); + await Task.Delay(Timeout.Infinite); + } catch (Exception ex) + { + Console.WriteLine($"Fatal Error: {ex}"); + } + } + } +} \ No newline at end of file diff --git a/SkyBot/SkyBot.csproj b/SkyBot/SkyBot.csproj new file mode 100644 index 0000000..1a75759 --- /dev/null +++ b/SkyBot/SkyBot.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + 0.3.0.0 + + + + + + + +