v3.0.0 - Initial Commit

This commit is contained in:
2026-04-18 02:25:11 +01:00
parent 042b4dceaf
commit 44393f1745
20 changed files with 974 additions and 1 deletions

2
.gitignore vendored
View File

@@ -2,6 +2,6 @@
.gitignore .gitignore
**/bin/ **/bin/
**/obj/ **/obj/
**/SkyBot.sln **.sln
**/database.db **/database.db
**/Config.cs **/Config.cs

View File

@@ -0,0 +1,33 @@
using SkyBot.Models;
namespace SkyBot.Commands
{
public static class CommandRegistry
{
public static readonly Dictionary<string, ICommand> Commands = new();
public static readonly Dictionary<string, List<ICommand>> 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());
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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("👍");
}
}
}

View File

@@ -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 <join|leave|list>";
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 <join|leave|list>");
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<PlanetMember> 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);
}
};
};

View File

@@ -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 <emoji> [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 <emoji> [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("👍");
}
}
}

View File

@@ -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 <text>";
public string[] SubCommands => [];
public static readonly ConcurrentDictionary<long, long> 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<Message> echoedMsg = await MessageHelper.ReplyAsync(ctx, reply);
if (echoedMsg.Success && echoedMsg.Data is not null)
{
EchoMap[ctx.Message.Id] = echoedMsg.Data.Id;
}
}
}
}

View File

@@ -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<string, int>();
var allChunks = new List<(string CategoryName, List<ICommand> 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<string, int>();
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);
}
}
}

View File

@@ -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),
];
}
}

View File

@@ -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<TaskResult<Message>> 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<TaskResult<Message>> EditAsync(Channel channel, Message message, string content)
{
message.Content = content;
return await channel.Planet.Node.PutAsyncWithResponse<Message>($"api/messages/{message.Id}", message);
}
};
};

View File

@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
namespace SkyBot.Helpers
{
public static class PendingConfirmations
{
private static readonly ConcurrentDictionary<long, TaskCompletionSource<bool>> pending = new();
public static bool IsPending(long userId) => pending.ContainsKey(userId);
public static Task<bool> WaitAsync(long userId, TimeSpan timeout)
{
var tcs = new TaskCompletionSource<bool>();
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;
}
}
}

View File

@@ -0,0 +1,42 @@
using Valour.Sdk.Models;
using Valour.Shared.Authorization;
namespace SkyBot.Helpers
{
public static class PermissionHelper
{
public static async Task<bool> 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<bool>();
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;
}
}
}

View File

@@ -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<long, Channel> 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; }
};
};

13
SkyBot/Models/ICommand.cs Normal file
View File

@@ -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);
}
}

View File

@@ -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<long, Channel> channelCache,
ConcurrentDictionary<long, bool> 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); };
}
}
}

View File

@@ -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<long, Channel> 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<long, Channel> 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})");
}
}
}

View File

@@ -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<long, DateTime> _cooldowns = new();
private static readonly TimeSpan _cooldown = TimeSpan.FromSeconds(2);
public static async Task Create(
ValourClient client,
ConcurrentDictionary<long, Channel> 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();
}
}
}
}
}

View File

@@ -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<long, Channel> channelCache,
ConcurrentDictionary<long, bool> 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<Channel> addedEvent)
{
await ChannelService.InitialiseChannelAsync(channelCache, addedEvent.Model);
}
await ChannelService.InitialiseChannelsAsync(channelCache, planet);
};
initialisedPlanets.TryAdd(planet.Id, true);
}
}
};
};

29
SkyBot/SkyBot.cs Normal file
View File

@@ -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<long, Channel> channelCache = new();
private static readonly ConcurrentDictionary<long, bool> 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}");
}
}
}
}

16
SkyBot/SkyBot.csproj Normal file
View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.3.0.0</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Valour.Sdk" Version="0.5.21" />
</ItemGroup>
</Project>