From 7050e908335dd880016802028d2e31b391983c0a Mon Sep 17 00:00:00 2001 From: skyjoshua Date: Fri, 20 Mar 2026 20:43:02 +0000 Subject: [PATCH] bunch of small fixes --- PRIVACY.md | 29 ++- README.md | 43 ++- SkyBot/Commands/Fun/Hangman.cs | 323 +++++++++++++++++++++++ SkyBot/Commands/Fun/Trivia.cs | 256 ++++++++++++++++++ SkyBot/Commands/Fun/Wordle.cs | 403 +++++++++++++++++++++++++++++ SkyBot/Commands/Info/Suggest.cs | 2 +- SkyBot/Helpers/PermissionHelper.cs | 53 +++- 7 files changed, 1093 insertions(+), 16 deletions(-) create mode 100644 SkyBot/Commands/Fun/Hangman.cs create mode 100644 SkyBot/Commands/Fun/Trivia.cs create mode 100644 SkyBot/Commands/Fun/Wordle.cs diff --git a/PRIVACY.md b/PRIVACY.md index 3942ccc..ec9cc90 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,23 +5,32 @@

Privacy Policy

-

Effective Date: March 16, 2026

+

Effective Date: March 20, 2026

This Privacy Policy describes how SkyBot ("the Bot") collects, uses, and stores information when used within a Valour planet.


1. Information Collected

-

The Bot collects only the minimum data necessary to provide its intended functionality. All data is stored in-memory and is lost when the Bot restarts. The Bot does not persist any data to disk.

+

The Bot collects only the minimum data necessary to provide its intended functionality. Most data is stored in-memory and is lost when the Bot restarts. A small amount of server configuration data is persisted to a local SQLite database for features that require it.

Information Temporarily Held in Memory:

  1. Channel IDs (for routing messages and commands)
  2. Planet IDs (for planet-specific operations)
  3. -
  4. Member IDs (for moderation commands)
  5. +
  6. Member IDs (for moderation commands and game session tracking)
  7. +
  8. Member display names (for game contributor lists in Hangman, Wordle, and Trivia)
+

Information Persisted to Disk:

+

The following server configuration data is saved to a local SQLite database so that it survives restarts:

+
    +
  1. Planet IDs (to associate configuration with a planet)
  2. +
  3. Channel IDs (to remember the configured welcome channel)
  4. +
  5. Welcome message template (the text set by a moderator via setwelcome message)
  6. +
  7. Welcome system active state (enabled/disabled)
  8. +
+

This data contains no personal user information. It is server configuration set by planet moderators and is stored locally on the host running the Bot.

Information Never Stored:

  1. Message content
  2. Direct Messages ("DMs")
  3. Personal account information (including usernames, email addresses, or other personally identifiable information)
  4. -
  5. Any data that persists beyond the Bot's current session

2. Purpose of Data Collection

@@ -29,16 +38,24 @@
  1. Route commands to the correct channels and planets
  2. Enable moderation commands such as ban, unban, and kick
  3. +
  4. Track active game sessions (Hangman, Wordle, Trivia) and display contributor lists
  5. Enable core bot functionality during the current session

The Bot does not use any information for profiling, marketing, analytics, or tracking purposes.


3. Data Storage and Security

-

Since all data is stored in-memory only, no data is written to disk, databases, or any external storage. All temporarily held data is automatically cleared when the Bot restarts.

+

Most data is stored in-memory only and is automatically cleared when the Bot restarts. The exception is server configuration for the welcome system (see Section 1), which is written to a local SQLite database on the host machine. No data is sent to any external storage or cloud service.

The Bot does not sell, rent, trade, or otherwise share any data with third parties.

+

Some features make outbound requests to third-party APIs to fetch content. These requests do not include any user data:

+

4. Data Retention

-

All data is held only for the duration of the Bot's current session. No data is retained beyond a restart. There is no mechanism for long-term data storage in this Bot.

+

In-memory data (game sessions, moderation context, etc.) is held only for the duration of the Bot's current session and is cleared on restart. Server configuration data for the welcome system is retained in a local SQLite database until explicitly changed or deleted by a planet moderator.


5. Self-Hosting

SkyBot is designed for self-hosting. If you choose to host your own instance of SkyBot, you are responsible for the privacy and security of any data processed by your instance. This policy applies to the official instance of SkyBot only.

diff --git a/README.md b/README.md index 604ce59..b1a1a7f 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,46 @@ SkyBot is a Valour.gg bot built with .NET 10.
  • Open-source under AGPL-3.0
  • Built with .NET 10
  • Command system with automatic registration
  • -
  • Moderation commands (ban, unban, kick)
  • -
  • Fun commands (8ball, coinflip, dice, rock paper scissors, and more)
  • -
  • Info commands (user info, planet info, ping, uptime)
  • + +

    Fun

    + +

    Chill

    + +

    Info

    + +

    Moderation

    +

    Data & Privacy

    SkyBot stores only the minimum data required for operation. All data is stored in-memory and is lost on restart. SkyBot does not persist any data to disk.

    diff --git a/SkyBot/Commands/Fun/Hangman.cs b/SkyBot/Commands/Fun/Hangman.cs new file mode 100644 index 0000000..68e9c3a --- /dev/null +++ b/SkyBot/Commands/Fun/Hangman.cs @@ -0,0 +1,323 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; +using Valour.Shared.Authorization; + +namespace SkyBot.Commands +{ + public class Hangman : ICommand + { + public string Name => "hangman"; + public string[] Aliases => ["hm"]; + public string Description => "Starts a channel-wide game of hangman. Optionally specify a category."; + public string Section => "Fun"; + public string Usage => "hangman [category] | hangman end"; + + private record HangmanSession( + string Word, + string? Category, + HashSet Guessed, + HashSet Wrong, + HashSet Contributors, + long StarterId, + Message BotMessage, + Channel Channel, + ValourClient Client); + + private static readonly ConcurrentDictionary _sessions = new(); + private static readonly HttpClient _http = new(); + + private const int MaxWrong = 6; + + private static readonly string[] Topics = + [ + "animals", "food", "sports", "music", "science", "geography", + "movies", "nature", "technology", "history", "mythology", "space", + "ocean", "weather", "games", "art", "clothing", "vehicles", + ]; + + private static readonly string[] FallbackWords = + [ + "APPLE", "BRIDGE", "CASTLE", "DRAGON", "ELEPHANT", "FOREST", "GUITAR", + "HARBOR", "ISLAND", "JUNGLE", "KNIGHT", "LEMON", "MANGO", "OCEAN", + "PLANET", "ROBOT", "SNAKE", "TIGER", "UMBRELLA", "WIZARD", "ANCHOR", + "BUTTER", "CANDLE", "DONKEY", "ENGINE", "FALCON", "GOBLIN", "HAMMER", + "IGLOO", "JACKET", "KITTEN", "LADDER", "MIRROR", "NEEDLE", "ORANGE", + "PENCIL", "RABBIT", "SILVER", "TEMPLE", "TURTLE", "VALLEY", "WALRUS", + "ZIPPER", "BLANKET", "CACTUS", "DAISY", "GLOVES", "HOCKEY", "INSECT", + "JELLY", "KETTLE", "LOBSTER", "MARBLE", "NAPKIN", "OYSTER", "PEPPER", + "QUARTZ", "ROCKET", "SALMON", "THRONE", "VELVET", "WINDOW", "YOGURT", + "ZOMBIE", "ALMOND", "BISON", "COBRA", "DAGGER", "EMBER", "FROST", + "GHOST", "HONEY", "IVORY", "JEWEL", "KOALA", "MAPLE", "NINJA", + "OLIVE", "PIRATE", "RAVEN", "SPHINX", "TORNADO", "UNICORN", "VENOM", + "WITCH", "PIXEL", "STORM", "CLOUD", "FLAME", "COMET", "DUSK", + "ECHO", "FABLE", "GLYPH", "HAZE", "JINX", "KNACK", "LUNAR", + "MYTH", "NEON", "ORBIT", "PRISM", "QUEST", "RIDGE", "SHARD", + ]; + + // 7 stages: 0 wrong → 6 wrong (using +--+ to avoid markdown eating underscores) + private static readonly string[] Stages = + [ + "```\n +--------+\n | |\n | \n | \n | \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | \n | \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | |\n | \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | /|\n | \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | /|\\\n | \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | /|\\\n | / \n | \n==+==\n```", + "```\n +--------+\n | |\n | O\n | /|\\\n | / \\\n | \n==+==\n```", + ]; + + private static async Task DeleteBotMessageAsync(CommandContext ctx, Message botMessage) + { + if (ctx.Client.Cache.Messages.TryGet(botMessage.Id, out var cached) && cached is not null) + try { await cached.DeleteAsync(); } catch { } + else + try { await botMessage.DeleteAsync(); } catch { } + } + + private static async Task RepostAsync(CommandContext ctx, Channel channel, Message botMessage, string content) + { + await DeleteBotMessageAsync(ctx, botMessage); + var result = await MessageHelper.ReplyAsync(ctx, channel, content); + if (!result.Success || result.Data is null) return null; + return ctx.Client.Cache.Messages.TryGet(result.Data.Id, out var cached) ? cached : result.Data; + } + + private static async Task FetchWord(string topic) + { + try + { + string url = $"https://api.datamuse.com/words?ml={Uri.EscapeDataString(topic)}&topic={Uri.EscapeDataString(topic)}&max=500"; + string json = await _http.GetStringAsync(url); + + using var doc = JsonDocument.Parse(json); + var words = doc.RootElement.EnumerateArray() + .Select(e => e.GetProperty("word").GetString() ?? "") + .Where(w => w.Length >= 4 && w.Length <= 12 && w.All(char.IsLetter)) + .ToList(); + + if (words.Count > 0) + return words[Random.Shared.Next(words.Count)].ToUpper(); + } + catch { } + + return FallbackWords[Random.Shared.Next(FallbackWords.Length)]; + } + + private static string BuildDisplay(string word, string? category, HashSet guessed, HashSet wrong) + { + string wordDisplay = string.Join(" ", word.Select(c => guessed.Contains(c) ? c.ToString() : "_")); + string wrongLetters = wrong.Count > 0 ? string.Join(", ", wrong.OrderBy(c => c)) : "none"; + string categoryLine = category is not null ? $"📂 **Category**: {category.ToTitleCase()}\n" : ""; + + return string.Join("\n", + $"🎮 **HANGMAN**", + categoryLine, + $"`{wordDisplay}`", + "", + Stages[wrong.Count], + "", + $"❌ Wrong ({wrong.Count}/{MaxWrong}): {wrongLetters}", + "", + $"*Use `{Config.Prefix}hg ` to guess!*" + ); + } + + public async Task Execute(CommandContext ctx) + { + ConcurrentDictionary channelCache = ctx.ChannelCache; + long channelId = ctx.ChannelId; + PlanetMember member = ctx.Member; + Message message = ctx.Message; + string[] args = ctx.Args; + + if (!channelCache.TryGetValue(channelId, out var channel)) return; + + // sd/hangman end — end the current game + if (args.Length >= 1 && args[0].ToLower() == "end") + { + if (!_sessions.TryGetValue(channelId, out var session)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's no active hangman game in this channel."); + return; + } + + bool isStarter = member.UserId == session.StarterId; + bool isMod = await PermissionHelper.HasPermAsync(member, channel, [ChatChannelPermissions.ManageMessages]); + + if (!isStarter && !isMod) + { + await MessageHelper.ReplyAsync(ctx, channel, "Only the person who started the game (or a moderator) can end it."); + return; + } + + _sessions.TryRemove(channelId, out _); + await DeleteBotMessageAsync(ctx, session.BotMessage); + await MessageHelper.ReplyAsync(ctx, channel, $"🛑 Hangman ended by {member.Name}. The word was `{session.Word}`."); + return; + } + + // sd/hangman [category] — start a new game + if (_sessions.ContainsKey(channelId)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's already an active hangman game in this channel!"); + return; + } + + string? category = args.Length >= 1 + ? args[0].ToLower() + : Topics[Random.Shared.Next(Topics.Length)]; + + string word = await FetchWord(category); + + var guessed = new HashSet(); + var wrong = new HashSet(); + var contributors = new HashSet(); + + string display = BuildDisplay(word, category, guessed, wrong); + var sent = await MessageHelper.ReplyAsync(ctx, channel, display); + if (!sent.Success || sent.Data is null) return; + + _sessions[channelId] = new HangmanSession(word, category, guessed, wrong, contributors, member.UserId, sent.Data, channel, ctx.Client); + } + + public static async Task ProcessGuessAsync(CommandContext ctx, Channel channel, string rawGuess) + { + long channelId = ctx.ChannelId; + + if (!_sessions.TryGetValue(channelId, out var session)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's no active hangman game in this channel."); + return; + } + + string guess = rawGuess.ToUpper(); + string memberName = ctx.Member.Name ?? "Unknown"; + + // Full word guess + if (guess.Length > 1) + { + if (guess == session.Word) + { + foreach (char c in session.Word) session.Guessed.Add(c); + session.Contributors.Add(memberName); + _sessions.TryRemove(channelId, out _); + + string contributorList = string.Join(", ", session.Contributors); + await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong) + + $"\n\n🎉 **{memberName} guessed the word! The word was `{session.Word}`!**\nContributors: {contributorList}"); + await MessageHelper.ReplyAsync(ctx, channel, $"🎉 **{memberName} guessed the word!** The word was `{session.Word}`!"); + } + else + { + session.Wrong.Add(guess[0]); + + if (session.Wrong.Count >= MaxWrong) + { + _sessions.TryRemove(channelId, out _); + await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong) + + $"\n\n💀 **Game over!** The word was `{session.Word}`."); + await MessageHelper.ReplyAsync(ctx, channel, $"💀 **Game Over!** Out of guesses. The word was `{session.Word}`"); + } + else + { + var newMsg = await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong)); + if (newMsg is not null) + _sessions[channelId] = session with { BotMessage = newMsg }; + await MessageHelper.ReplyAsync(ctx, channel, $"❌ `{guess}` is not the word!"); + } + } + return; + } + + // Single letter guess + char letter = guess[0]; + + if (!char.IsLetter(letter)) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please guess a letter or a full word."); + return; + } + + if (session.Guessed.Contains(letter) || session.Wrong.Contains(letter)) + { + await MessageHelper.ReplyAsync(ctx, channel, $"`{letter}` has already been guessed!"); + return; + } + + if (session.Word.Contains(letter)) + { + session.Guessed.Add(letter); + session.Contributors.Add(memberName); + + bool won = session.Word.All(c => session.Guessed.Contains(c)); + if (won) + { + _sessions.TryRemove(channelId, out _); + string contributorList = string.Join(", ", session.Contributors); + await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong) + + $"\n\n🎉 **The channel wins! The word was `{session.Word}`!**\nContributors: {contributorList}"); + await MessageHelper.ReplyAsync(ctx, channel, $"🎉 **The channel wins!** The word was `{session.Word}`!"); + } + else + { + var newMsg = await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong)); + if (newMsg is not null) + _sessions[channelId] = session with { BotMessage = newMsg }; + await MessageHelper.ReplyAsync(ctx, channel, $"✅ `{letter}` is in the word!"); + } + } + else + { + session.Wrong.Add(letter); + + if (session.Wrong.Count >= MaxWrong) + { + _sessions.TryRemove(channelId, out _); + await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong) + + $"\n\n💀 **Game over!** The word was `{session.Word}`."); + await MessageHelper.ReplyAsync(ctx, channel, $"💀 **Game over!** The word was `{session.Word}`."); + } + else + { + var newMsg = await RepostAsync(ctx, channel, session.BotMessage, + BuildDisplay(session.Word, session.Category, session.Guessed, session.Wrong)); + if (newMsg is not null) + _sessions[channelId] = session with { BotMessage = newMsg }; + await MessageHelper.ReplyAsync(ctx, channel, $"❌ No `{letter}` in the word!"); + } + } + } + } + + public class HangmanGuess : ICommand + { + public string Name => "hg"; + public string[] Aliases => []; + public string Description => "Guess a letter or word in the active Hangman game."; + public string Section => "Fun"; + public string Usage => "hg "; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.ChannelId, out var channel)) return; + + if (ctx.Args.Length == 0 || string.IsNullOrWhiteSpace(ctx.Args[0])) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a letter or word to guess."); + return; + } + + await Hangman.ProcessGuessAsync(ctx, channel, ctx.Args[0]); + } + } +} diff --git a/SkyBot/Commands/Fun/Trivia.cs b/SkyBot/Commands/Fun/Trivia.cs new file mode 100644 index 0000000..8bbabcf --- /dev/null +++ b/SkyBot/Commands/Fun/Trivia.cs @@ -0,0 +1,256 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace SkyBot.Commands +{ + public class Trivia : ICommand + { + public string Name => "trivia"; + public string[] Aliases => ["triv"]; + public string Description => "Starts a channel-wide trivia question. Everyone has 30 seconds to guess."; + public string Section => "Fun"; + public string Usage => "trivia [easy|medium|hard] [topic] | trivia topics"; + + private record GuessEntry(char Letter, string MemberName); + + private record TriviaSession( + char CorrectLetter, + string QuestionText, + List Answers, + Message BotMessage, + Channel Channel, + ValourClient Client, + ConcurrentDictionary Guesses); + + private static readonly ConcurrentDictionary _sessions = new(); + private static readonly HttpClient _http = new(); + + private static readonly string[] Difficulties = ["easy", "medium", "hard"]; + + private static readonly Dictionary Categories = new() + { + ["general"] = 9, + ["books"] = 10, + ["film"] = 11, + ["movies"] = 11, + ["music"] = 12, + ["musicals"] = 13, + ["tv"] = 14, + ["television"] = 14, + ["games"] = 15, + ["videogames"] = 15, + ["boardgames"] = 16, + ["science"] = 17, + ["nature"] = 17, + ["computers"] = 18, + ["tech"] = 18, + ["math"] = 19, + ["maths"] = 19, + ["mythology"] = 20, + ["sports"] = 21, + ["geography"] = 22, + ["geo"] = 22, + ["history"] = 23, + ["politics"] = 24, + ["art"] = 25, + ["celebrities"] = 26, + ["animals"] = 27, + ["vehicles"] = 28, + ["cars"] = 28, + ["comics"] = 29, + ["anime"] = 31, + ["manga"] = 31, + ["cartoons"] = 32, + }; + + public static async Task ProcessGuessAsync(CommandContext ctx, Channel channel, string rawGuess) + { + long channelId = ctx.ChannelId; + + if (!_sessions.TryGetValue(channelId, out var session)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's no active trivia question in this channel."); + return; + } + + if (session.Guesses.ContainsKey(ctx.Message.AuthorUserId)) + { + await MessageHelper.ReplyAsync(ctx, channel, "You've already submitted an answer!"); + return; + } + + if (string.IsNullOrWhiteSpace(rawGuess)) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a letter. `A, B, C, or D`."); + return; + } + + char given = char.ToUpper(rawGuess[0]); + if (given < 'A' || given > 'D') + { + await MessageHelper.ReplyAsync(ctx, channel, "Invalid choice. Please guess A, B, C, or D."); + return; + } + + session.Guesses[ctx.Message.AuthorUserId] = new GuessEntry(given, ctx.Member.Name ?? "Unknown"); + await MessageHelper.ReplyAsync(ctx, channel, "📬 Answer submitted!"); + } + + public async Task Execute(CommandContext ctx) + { + ConcurrentDictionary channelCache = ctx.ChannelCache; + long channelId = ctx.ChannelId; + PlanetMember member = ctx.Member; + string[] args = ctx.Args; + + if (!channelCache.TryGetValue(channelId, out var channel)) return; + + // sd/trivia topics + if (args.Length >= 1 && (args[0].ToLower() == "topics" || args[0].ToLower() == "t")) + { + string topicList = string.Join(", ", Categories.Keys.Order()); + await MessageHelper.ReplyAsync(ctx, channel, $"**Available topics:** {topicList}\n**Difficulties:** easy, medium, hard"); + return; + } + + // sd/trivia — fetch a new question + if (_sessions.ContainsKey(channelId)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's already an active trivia question in this channel!"); + return; + } + + string? difficulty = null; + int? categoryId = null; + + foreach (string arg in args.Select(a => a.ToLower())) + { + if (Difficulties.Contains(arg)) + difficulty = arg; + else if (Categories.TryGetValue(arg, out int id)) + categoryId = id; + } + + // Pick a random category if none was specified + if (categoryId is null) + { + var ids = Categories.Values.Distinct().ToArray(); + categoryId = ids[Random.Shared.Next(ids.Length)]; + } + + string url = "https://opentdb.com/api.php?amount=1&type=multiple"; + if (difficulty is not null) url += $"&difficulty={difficulty}"; + if (categoryId is not null) url += $"&category={categoryId}"; + + string rawJson; + try + { + rawJson = await _http.GetStringAsync(url); + } + catch + { + await MessageHelper.ReplyAsync(ctx, channel, "Failed to fetch a trivia question. Try again in a moment."); + return; + } + + using var doc = JsonDocument.Parse(rawJson); + var result = doc.RootElement.GetProperty("results")[0]; + + string question = WebUtility.HtmlDecode(result.GetProperty("question").GetString()!); + string correct = WebUtility.HtmlDecode(result.GetProperty("correct_answer").GetString()!); + string category = WebUtility.HtmlDecode(result.GetProperty("category").GetString()!); + string fetchedDifficulty = result.GetProperty("difficulty").GetString() ?? "unknown"; + + List answers = result.GetProperty("incorrect_answers").EnumerateArray() + .Select(x => WebUtility.HtmlDecode(x.GetString()!)) + .Append(correct) + .OrderBy(_ => Random.Shared.Next()) + .ToList(); + + char correctLetter = (char)('A' + answers.IndexOf(correct)); + + string questionText = string.Join("\n", + $"**Category**: {category} | **Difficulty**: {fetchedDifficulty.ToTitleCase()}", + "", + $"**{question}**", + "", + string.Join("\n", answers.Select((a, i) => $"{(char)('A' + i)}) {a}")), + "", + $"*Use `{Config.Prefix}tg ` — you have 30 seconds!*" + ); + + var sent = await MessageHelper.ReplyAsync(ctx, channel, questionText); + if (!sent.Success || sent.Data is null) return; + + Message botMessage = ctx.Client.Cache.Messages.TryGet(sent.Data.Id, out var cachedSent) && cachedSent is not null + ? cachedSent : sent.Data; + + var newSession = new TriviaSession(correctLetter, questionText, answers, botMessage, channel, ctx.Client, new()); + _sessions[channelId] = newSession; + + _ = Task.Run(async () => + { + await Task.Delay(30_000); + _sessions.TryRemove(channelId, out _); + + List corrects = [..newSession.Guesses + .Where(kv => kv.Value.Letter == newSession.CorrectLetter) + .Select(kv => kv.Value.MemberName)]; + + List wrongs = [..newSession.Guesses + .Where(kv => kv.Value.Letter != newSession.CorrectLetter) + .Select(kv => kv.Value.MemberName)]; + + string correctAnswer = newSession.Answers[newSession.CorrectLetter - 'A']; + string resultsText = $"⏰ **Time's up!** The answer was **{newSession.CorrectLetter}) {correctAnswer}**\n"; + + if (corrects.Count > 0) + resultsText += $"\n✅ **Correct:** {string.Join(", ", corrects)}"; + if (wrongs.Count > 0) + resultsText += $"\n❌ **Wrong:** {string.Join(", ", wrongs)}"; + if (newSession.Guesses.IsEmpty) + resultsText += "\n😶 Nobody answered!"; + + var resultMsg = new Message(newSession.Client) + { + Content = resultsText, + ChannelId = newSession.Channel.Id, + PlanetId = newSession.Channel.Planet!.Id, + AuthorUserId = newSession.Client.Me.Id, + AuthorMemberId = newSession.Channel.Planet?.MyMember.Id, + ReplyToId = newSession.BotMessage.Id, + Fingerprint = Guid.NewGuid().ToString() + }; + + await newSession.Client.MessageService.SendMessage(resultMsg); + }); + } + } + + public class TriviaGuess : ICommand + { + public string Name => "tg"; + public string[] Aliases => []; + public string Description => "Submit your answer to the active Trivia question."; + public string Section => "Fun"; + public string Usage => "tg "; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.ChannelId, out var channel)) return; + + if (ctx.Args.Length == 0 || string.IsNullOrWhiteSpace(ctx.Args[0])) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a letter. `A, B, C, or D`."); + return; + } + + await Trivia.ProcessGuessAsync(ctx, channel, ctx.Args[0]); + } + } +} diff --git a/SkyBot/Commands/Fun/Wordle.cs b/SkyBot/Commands/Fun/Wordle.cs new file mode 100644 index 0000000..b99df7f --- /dev/null +++ b/SkyBot/Commands/Fun/Wordle.cs @@ -0,0 +1,403 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using SkyBot.Helpers; +using SkyBot.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; +using Valour.Shared.Authorization; + +namespace SkyBot.Commands +{ + public class Wordle : ICommand + { + public string Name => "wordle"; + public string[] Aliases => ["wd"]; + public string Description => "Starts a channel-wide game of Wordle. Guess the 5-letter word in 6 tries!"; + public string Section => "Fun"; + public string Usage => "wordle | wordle board"; + + private record WordleSession( + string Word, + List Guesses, + List Feedback, + HashSet Contributors, + long StarterId, + Message BotMessage, + Channel Channel, + ValourClient Client); + + private static readonly ConcurrentDictionary _sessions = new(); + private static readonly HttpClient _http = new(); + private static readonly SemaphoreSlim _fetchLock = new(1, 1); + private static string[]? _cachedWords; + + private const int MaxGuesses = 6; + private const int WordLength = 5; + + private static readonly string[] WordList = + [ + "ABOUT", "ABUSE", "ACUTE", "ADMIT", "ADOPT", "AFTER", "AGENT", "AGREE", + "AHEAD", "ALIKE", "ALIVE", "ALONE", "ALONG", "ALTER", "ANGEL", "ANGER", + "ANGLE", "APART", "APPLE", "APPLY", "ARGUE", "ARISE", "ARMOR", "ASIDE", + "ASSET", "AVOID", "AWARD", "AWARE", "BADLY", "BASIC", "BEACH", "BEARD", + "BEGAN", "BEGIN", "BEING", "BELOW", "BENCH", "BLACK", "BLADE", "BLAME", + "BLANK", "BLAST", "BLAZE", "BLEED", "BLESS", "BLIND", "BLOCK", "BLOOD", + "BLOOM", "BLUNT", "BOARD", "BOOST", "BOUND", "BRAND", "BRAVE", "BREAD", + "BREAK", "BREED", "BRICK", "BRIDE", "BRIEF", "BRING", "BROAD", "BROOK", + "BROWN", "BRUSH", "BUILD", "BUILT", "BURST", "BUYER", "CABIN", "CABLE", + "CANDY", "CARRY", "CHAIN", "CHAIR", "CHAOS", "CHARM", "CHEAP", "CHECK", + "CHESS", "CHEST", "CHIEF", "CHILD", "CHILL", "CIVIC", "CIVIL", "CLAIM", + "CLASS", "CLEAN", "CLEAR", "CLIMB", "CLOCK", "CLOSE", "CLOUD", "COACH", + "COAST", "COUNT", "COURT", "COVER", "CRACK", "CRAFT", "CRANE", "CRAZY", + "CREAM", "CREEK", "CRIME", "CROSS", "CROWD", "CRUSH", "CURVE", "CYCLE", + "DAILY", "DANCE", "DEATH", "DEBUT", "DENSE", "DEPOT", "DEPTH", "DERBY", + "DINER", "DIRTY", "DISCO", "DITCH", "DIZZY", "DOUBT", "DOUGH", "DRAFT", + "DRAIN", "DRAMA", "DRANK", "DREAM", "DRESS", "DRIFT", "DRINK", "DRIVE", + "DRONE", "DROVE", "DROWN", "DRUNK", "DYING", "EAGER", "EAGLE", "EARLY", + "EARTH", "EIGHT", "ELITE", "EMPTY", "ENEMY", "ENJOY", "ENTER", "ENTRY", + "EQUAL", "ERROR", "ESSAY", "EVENT", "EVERY", "EXACT", "EXIST", "EXTRA", + "FABLE", "FAINT", "FAITH", "FALSE", "FANCY", "FATAL", "FAULT", "FEAST", + "FENCE", "FEVER", "FIELD", "FIERY", "FIFTH", "FIFTY", "FIGHT", "FINAL", + "FIRST", "FIXED", "FLAME", "FLASH", "FLEET", "FLESH", "FLOAT", "FLOCK", + "FLOOD", "FLOOR", "FLUSH", "FOCUS", "FORCE", "FORGE", "FORTH", "FOUND", + "FRAME", "FRANK", "FRAUD", "FRESH", "FRONT", "FROST", "FRUIT", "FULLY", + "FUNNY", "GIANT", "GIVEN", "GLASS", "GLEAM", "GLOBE", "GLOOM", "GLORY", + "GLOVE", "GOING", "GRACE", "GRADE", "GRAIN", "GRAND", "GRANT", "GRAPE", + "GRASP", "GRASS", "GRAVE", "GREAT", "GREEN", "GREET", "GRIEF", "GRIND", + "GROAN", "GROOM", "GROSS", "GROUP", "GROVE", "GROWN", "GUARD", "GUESS", + "GUIDE", "GUILT", "GUISE", "HARSH", "HEART", "HEAVY", "HENCE", "HERBS", + "HINGE", "HONEY", "HONOR", "HORSE", "HOTEL", "HOUSE", "HUMAN", "HURRY", + "IDEAL", "IMAGE", "INNER", "INPUT", "ISSUE", "IVORY", "JEWEL", "JOINT", + "JUDGE", "JUICE", "JUICY", "JUMBO", "KARMA", "KNACK", "KNEEL", "KNIFE", + "KNOCK", "KNOWN", "LABEL", "LANCE", "LARGE", "LASER", "LATER", "LAUGH", + "LAYER", "LEARN", "LEASE", "LEAST", "LEGAL", "LEMON", "LEVEL", "LIGHT", + "LIMIT", "LIVER", "LOCAL", "LODGE", "LOGIC", "LOOSE", "LOVER", "LOWER", + "LUCKY", "LUNAR", "MAGIC", "MAJOR", "MAKER", "MANOR", "MAPLE", "MARCH", + "MATCH", "MAYOR", "MEDIA", "MERCY", "MERIT", "METAL", "MIGHT", "MINOR", + "MINUS", "MODEL", "MONEY", "MONTH", "MORAL", "MOTOR", "MOUNT", "MOUSE", + "MOUTH", "MOVIE", "MUSIC", "NAIVE", "NERVE", "NEVER", "NIGHT", "NINJA", + "NOBLE", "NOISE", "NORTH", "NOVEL", "NURSE", "NYMPH", "OCEAN", "OFFER", + "OFTEN", "OLIVE", "ONSET", "OPERA", "ORDER", "OTHER", "OUTER", "OWNED", + "OWNER", "OZONE", "PAINT", "PANIC", "PAPER", "PARTY", "PEACE", "PEACH", + "PEARL", "PENNY", "PHASE", "PHONE", "PHOTO", "PIANO", "PIECE", "PILOT", + "PIXEL", "PLACE", "PLAIN", "PLANE", "PLANT", "PLATE", "PLAZA", "PLEAD", + "PLUMB", "PLUMP", "POINT", "POLAR", "POPPY", "POWER", "PRESS", "PRICE", + "PRIDE", "PRIME", "PRINT", "PRIOR", "PRISM", "PROBE", "PROOF", "PROSE", + "PROUD", "PROVE", "PULSE", "PUPIL", "QUEEN", "QUERY", "QUEST", "QUEUE", + "QUIET", "QUOTA", "QUOTE", "RADAR", "RADIO", "RAISE", "RALLY", "RANGE", + "RAPID", "RATIO", "REACH", "READY", "REALM", "REBEL", "REFER", "REIGN", + "RELAX", "REPLY", "RIDER", "RIDGE", "RISKY", "RIVER", "ROBIN", "ROBOT", + "ROCKY", "ROUGH", "ROUND", "ROUTE", "ROYAL", "RULER", "RURAL", "SADLY", + "SAINT", "SALAD", "SAUCE", "SCALE", "SCENE", "SCENT", "SCOUT", "SENSE", + "SEVEN", "SHADE", "SHAFT", "SHALL", "SHAME", "SHAPE", "SHARE", "SHARK", + "SHARP", "SHEEP", "SHEER", "SHELF", "SHELL", "SHIFT", "SHINE", "SHIRT", + "SHOCK", "SHOOT", "SHORT", "SHOUT", "SIEGE", "SIGHT", "SILLY", "SINCE", + "SIXTH", "SIXTY", "SKILL", "SKULL", "SLATE", "SLAVE", "SLEEP", "SLICE", + "SLIDE", "SLOPE", "SMALL", "SMART", "SMELL", "SMILE", "SMOKE", "SNAKE", + "SOLAR", "SOLID", "SOLVE", "SORRY", "SOUND", "SOUTH", "SPACE", "SPARE", + "SPARK", "SPEAK", "SPEAR", "SPEED", "SPEND", "SPICE", "SPINE", "SPITE", + "SPLIT", "SPOKE", "SPOON", "SPORT", "SPRAY", "SQUAD", "STACK", "STAFF", + "STAGE", "STAIN", "STAIR", "STAKE", "STAND", "STARK", "STATE", "STEAM", + "STEEL", "STEEP", "STEER", "STERN", "STICK", "STIFF", "STILL", "STOCK", + "STOMP", "STONE", "STORE", "STORM", "STORY", "STRAP", "STRAW", "STRAY", + "STRIP", "STUCK", "STUDY", "STUFF", "STYLE", "SUGAR", "SUITE", "SUNNY", + "SUPER", "SURGE", "SWAMP", "SWEAR", "SWEEP", "SWEET", "SWIFT", "SWIPE", + "SWORD", "TABLE", "TASTE", "TEACH", "TEARS", "TEMPT", "TENSE", "TENTH", + "THEFT", "THEIR", "THERE", "THICK", "THING", "THINK", "THIRD", "THREE", + "THREW", "THROW", "TIGER", "TIGHT", "TIMER", "TIRED", "TITAN", "TITLE", + "TOKEN", "TOPIC", "TOTAL", "TOUCH", "TOUGH", "TOWER", "TOXIC", "TRACE", + "TRACK", "TRADE", "TRAIL", "TRAIN", "TRAIT", "TRASH", "TREND", "TRIAL", + "TRIBE", "TRICK", "TRIED", "TROOP", "TROUT", "TRUCK", "TRULY", "TRUNK", + "TRUST", "TRUTH", "TWIST", "ULTRA", "UNCLE", "UNDER", "UNION", "UNITY", + "UNTIL", "UPPER", "UPSET", "URBAN", "USAGE", "USHER", "USUAL", "UTTER", + "VAGUE", "VALID", "VALUE", "VAPOR", "VAULT", "VIGOR", "VIRAL", "VISIT", + "VITAL", "VIVID", "VOCAL", "VOICE", "VOTER", "WAGER", "WATCH", "WATER", + "WEARY", "WEAVE", "WEDGE", "WEIRD", "WHERE", "WHILE", "WHITE", "WHOLE", + "WIDER", "WITCH", "WOMAN", "WOMEN", "WORLD", "WORRY", "WORSE", "WORST", + "WORTH", "WOULD", "WRATH", "WRITE", "WRONG", "YACHT", "YIELD", "YOUNG", + "YOUTH", "ZEBRA", + ]; + + private static async Task GetWordPoolAsync() + { + if (_cachedWords is not null) return _cachedWords; + + await _fetchLock.WaitAsync(); + try + { + if (_cachedWords is not null) return _cachedWords; + + string json = await _http.GetStringAsync( + "https://api.datamuse.com/words?sp=?????&max=1000&md=f"); + + using var doc = JsonDocument.Parse(json); + var words = doc.RootElement.EnumerateArray() + .Select(e => e.GetProperty("word").GetString() ?? "") + .Where(w => w.Length == WordLength && w.All(char.IsLetter)) + .Select(w => w.ToUpper()) + .ToArray(); + + if (words.Length > 0) + { + _cachedWords = words; + return words; + } + } + catch { } + finally + { + _fetchLock.Release(); + } + + return WordList; + } + + private static async Task IsValidWordAsync(string word) + { + try + { + string json = await _http.GetStringAsync( + $"https://api.datamuse.com/words?sp={word.ToLower()}&max=1"); + + using var doc = JsonDocument.Parse(json); + var arr = doc.RootElement; + if (arr.GetArrayLength() > 0) + { + string? returned = arr[0].GetProperty("word").GetString(); + return string.Equals(returned, word, StringComparison.OrdinalIgnoreCase); + } + } + catch { } + return false; + } + + // Returns per-letter emoji feedback using standard Wordle rules + private static string[] GetFeedback(string guess, string word) + { + var result = new string[WordLength]; + var wordLeft = word.ToCharArray(); + var guessLeft = guess.ToCharArray(); + + // First pass: greens + for (int i = 0; i < WordLength; i++) + { + if (guessLeft[i] == wordLeft[i]) + { + result[i] = "🟩"; + wordLeft[i] = '\0'; + guessLeft[i] = '\0'; + } + } + + // Second pass: yellows and grays + for (int i = 0; i < WordLength; i++) + { + if (guessLeft[i] == '\0') continue; + + int idx = Array.IndexOf(wordLeft, guessLeft[i]); + if (idx >= 0) + { + result[i] = "🟨"; + wordLeft[idx] = '\0'; + } + else + { + result[i] = "⬛"; + } + } + + return result; + } + + private static string BuildDisplay(WordleSession session) + { + var rows = new List(); + + for (int i = 0; i < MaxGuesses; i++) + { + if (i < session.Guesses.Count) + { + string emojis = string.Join("", session.Feedback[i]); + string letters = string.Join(" ", session.Guesses[i].ToCharArray()); + rows.Add($"{emojis} {letters}"); + } + else + { + rows.Add("⬜⬜⬜⬜⬜"); + } + } + + return string.Join("\n", + "🟩 **WORDLE** — Guess the 5-letter word!", + "", + string.Join("\n", rows), + "", + $"Guesses: {session.Guesses.Count}/{MaxGuesses}", + "", + $"*Use `{Config.Prefix}wg ` to guess!*" + ); + } + + public static async Task ProcessGuessAsync(CommandContext ctx, Channel channel, string rawGuess) + { + long channelId = ctx.ChannelId; + if (!_sessions.TryGetValue(channelId, out var session)) return; + + string guess = rawGuess.ToUpper(); + string memberName = ctx.Member.Name ?? "Unknown"; + + if (guess.Length != WordLength || !guess.All(char.IsLetter)) + { + await MessageHelper.ReplyAsync(ctx, channel, $"Your guess must be exactly {WordLength} letters with no numbers or symbols."); + return; + } + + if (!await IsValidWordAsync(guess)) + { + await MessageHelper.ReplyAsync(ctx, channel, $"`{guess}` isn't a valid word!"); + return; + } + + if (session.Guesses.Contains(guess)) + { + await MessageHelper.ReplyAsync(ctx, channel, $"`{guess}` has already been guessed!"); + return; + } + + var feedback = GetFeedback(guess, session.Word); + session.Guesses.Add(guess); + session.Feedback.Add(feedback); + session.Contributors.Add(memberName); + + bool won = guess == session.Word; + bool lost = !won && session.Guesses.Count >= MaxGuesses; + + if (won) + { + _sessions.TryRemove(channelId, out _); + string contributorList = string.Join(", ", session.Contributors); + await RepostBoardAsync(ctx, channel, session, + BuildDisplay(session) + + $"\n\n🎉 **{memberName} got it in {session.Guesses.Count}!**\nContributors: {contributorList}"); + await MessageHelper.ReplyAsync(ctx, channel, $"🎉 **The word was `{session.Word}`!** Got it in {session.Guesses.Count}/{MaxGuesses}!"); + } + else if (lost) + { + _sessions.TryRemove(channelId, out _); + await RepostBoardAsync(ctx, channel, session, + BuildDisplay(session) + + $"\n\n💀 **Game over!** The word was `{session.Word}`."); + await MessageHelper.ReplyAsync(ctx, channel, $"💀 **Game over!** The word was `{session.Word}`."); + } + else + { + var newMsg = await RepostBoardAsync(ctx, channel, session, BuildDisplay(session)); + if (newMsg is not null) + _sessions[channelId] = session with { BotMessage = newMsg }; + } + } + + private static async Task RepostBoardAsync(CommandContext ctx, Channel channel, WordleSession session, string content) + { + if (ctx.Client.Cache.Messages.TryGet(session.BotMessage.Id, out var old) && old is not null) + try { await old.DeleteAsync(); } catch { } + else + try { await session.BotMessage.DeleteAsync(); } catch { } + + var result = await MessageHelper.ReplyAsync(ctx, channel, content); + if (!result.Success || result.Data is null) return null; + return ctx.Client.Cache.Messages.TryGet(result.Data.Id, out var cached) && cached is not null ? cached : result.Data; + } + + public async Task Execute(CommandContext ctx) + { + ConcurrentDictionary channelCache = ctx.ChannelCache; + long channelId = ctx.ChannelId; + string[] args = ctx.Args; + PlanetMember member = ctx.Member; + + if (!channelCache.TryGetValue(channelId, out var channel)) return; + + // sd/wordle end — end the current game + if (args.Length >= 1 && args[0].ToLower() == "end") + { + if (!_sessions.TryGetValue(channelId, out var session)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's no active Wordle game in this channel."); + return; + } + + bool isStarter = member.UserId == session.StarterId; + bool isMod = await PermissionHelper.HasPermAsync(member, channel, [ChatChannelPermissions.ManageMessages]); + + if (!isStarter && !isMod) + { + await MessageHelper.ReplyAsync(ctx, channel, "Only the person who started the game (or a moderator) can end it."); + return; + } + + _sessions.TryRemove(channelId, out _); + if (ctx.Client.Cache.Messages.TryGet(session.BotMessage.Id, out var old) && old is not null) + try { await old.DeleteAsync(); } catch { } + else + try { await session.BotMessage.DeleteAsync(); } catch { } + await MessageHelper.ReplyAsync(ctx, channel, $"🛑 Wordle ended by {member.Name}. The word was `{session.Word}`."); + return; + } + + // sd/wordle board — repost the current board + if (args.Length >= 1 && args[0].ToLower() == "board") + { + if (!_sessions.TryGetValue(channelId, out var session)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's no active Wordle game in this channel."); + return; + } + + var newMsg = await RepostBoardAsync(ctx, channel, session, BuildDisplay(session)); + if (newMsg is not null) + _sessions[channelId] = session with { BotMessage = newMsg }; + return; + } + + // sd/wordle — start a new game + if (_sessions.ContainsKey(channelId)) + { + await MessageHelper.ReplyAsync(ctx, channel, "There's already an active Wordle game in this channel!"); + return; + } + + var pool = await GetWordPoolAsync(); + string word = pool[Random.Shared.Next(pool.Length)]; + + var newSession = new WordleSession(word, [], [], [], member.UserId, null!, channel, ctx.Client); + string display = BuildDisplay(newSession); + var sent = await MessageHelper.ReplyAsync(ctx, channel, display); + if (!sent.Success || sent.Data is null) return; + + _sessions[channelId] = newSession with { BotMessage = sent.Data }; + } + } + + public class WordleGuess : ICommand + { + public string Name => "wg"; + public string[] Aliases => []; + public string Description => "Guess a word in the active Wordle game."; + public string Section => "Fun"; + public string Usage => "wg "; + + public async Task Execute(CommandContext ctx) + { + if (!ctx.ChannelCache.TryGetValue(ctx.ChannelId, out var channel)) return; + + if (ctx.Args.Length == 0 || string.IsNullOrWhiteSpace(ctx.Args[0])) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a word to guess."); + return; + } + + await Wordle.ProcessGuessAsync(ctx, channel, ctx.Args[0]); + } + } +} diff --git a/SkyBot/Commands/Info/Suggest.cs b/SkyBot/Commands/Info/Suggest.cs index 6a85cb6..946ccb1 100644 --- a/SkyBot/Commands/Info/Suggest.cs +++ b/SkyBot/Commands/Info/Suggest.cs @@ -9,7 +9,7 @@ namespace SkyBot.Commands { public string Name => "suggest"; public string[] Aliases => []; - public string Description => "Shows the source code for this bot."; + public string Description => "Sends a link to where you can suggest commands."; public string Section => "Info"; public string Usage => "source"; diff --git a/SkyBot/Helpers/PermissionHelper.cs b/SkyBot/Helpers/PermissionHelper.cs index 91289ed..a6e89f9 100644 --- a/SkyBot/Helpers/PermissionHelper.cs +++ b/SkyBot/Helpers/PermissionHelper.cs @@ -5,23 +5,64 @@ namespace SkyBot.Helpers { public static class PermissionHelper { + // Planet-level permissions public static bool HasPerm(PlanetMember member, PlanetPermission[] permissions, bool requireAll = false) { if (member == null) return false; if (member.HasPermission(PlanetPermissions.FullControl)) return true; if (member.Roles.Any(r => r.IsAdmin)) return true; - + return requireAll - ? permissions.All(permission => member.HasPermission(permission)) - : permissions.Any(permission => member.HasPermission(permission)); + ? permissions.All(p => member.HasPermission(p)) + : permissions.Any(p => member.HasPermission(p)); + } + + // Chat channel permissions + public static async Task HasPermAsync(PlanetMember member, Channel channel, ChatChannelPermission[] permissions, bool requireAll = false) + { + if (member == null) return false; + if (member.HasPermission(PlanetPermissions.FullControl)) return true; + if (member.Roles.Any(r => r.IsAdmin)) return true; + + if (requireAll) + { + foreach (var p in permissions) + if (!await channel.HasPermissionAsync(member, p)) return false; + return true; + } + else + { + foreach (var p in permissions) + if (await channel.HasPermissionAsync(member, p)) return true; + return false; + } + } + + // Voice channel permissions + public static async Task HasPermAsync(PlanetMember member, Channel channel, VoiceChannelPermission[] permissions, bool requireAll = false) + { + if (member == null) return false; + if (member.HasPermission(PlanetPermissions.FullControl)) return true; + if (member.Roles.Any(r => r.IsAdmin)) return true; + + if (requireAll) + { + foreach (var p in permissions) + if (!await channel.HasPermissionAsync(member, p)) return false; + return true; + } + else + { + foreach (var p in permissions) + if (await channel.HasPermissionAsync(member, p)) return true; + return false; + } } public static bool IsOwner(PlanetMember member) { - if (member == null) return false; - if (member.UserId == Config.OwnerId) return true; - return false; + return member.UserId == Config.OwnerId; } } } \ No newline at end of file