29
PRIVACY.md
29
PRIVACY.md
@@ -5,23 +5,32 @@
|
||||
</head>
|
||||
<body>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p><strong>Effective Date:</strong> March 16, 2026</p>
|
||||
<p><strong>Effective Date:</strong> March 20, 2026</p>
|
||||
<p>This Privacy Policy describes how SkyBot ("the Bot") collects, uses, and stores information when used within a Valour planet.</p>
|
||||
<hr>
|
||||
<h2>1. Information Collected</h2>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<h3>Information Temporarily Held in Memory:</h3>
|
||||
<ol>
|
||||
<li>Channel IDs (for routing messages and commands)</li>
|
||||
<li>Planet IDs (for planet-specific operations)</li>
|
||||
<li>Member IDs (for moderation commands)</li>
|
||||
<li>Member IDs (for moderation commands and game session tracking)</li>
|
||||
<li>Member display names (for game contributor lists in Hangman, Wordle, and Trivia)</li>
|
||||
</ol>
|
||||
<h3>Information Persisted to Disk:</h3>
|
||||
<p>The following server configuration data is saved to a local SQLite database so that it survives restarts:</p>
|
||||
<ol>
|
||||
<li>Planet IDs (to associate configuration with a planet)</li>
|
||||
<li>Channel IDs (to remember the configured welcome channel)</li>
|
||||
<li>Welcome message template (the text set by a moderator via <code>setwelcome message</code>)</li>
|
||||
<li>Welcome system active state (enabled/disabled)</li>
|
||||
</ol>
|
||||
<p>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.</p>
|
||||
<h3>Information Never Stored:</h3>
|
||||
<ol>
|
||||
<li>Message content</li>
|
||||
<li>Direct Messages ("DMs")</li>
|
||||
<li>Personal account information (including usernames, email addresses, or other personally identifiable information)</li>
|
||||
<li>Any data that persists beyond the Bot's current session</li>
|
||||
</ol>
|
||||
<hr>
|
||||
<h2>2. Purpose of Data Collection</h2>
|
||||
@@ -29,16 +38,24 @@
|
||||
<ol>
|
||||
<li>Route commands to the correct channels and planets</li>
|
||||
<li>Enable moderation commands such as ban, unban, and kick</li>
|
||||
<li>Track active game sessions (Hangman, Wordle, Trivia) and display contributor lists</li>
|
||||
<li>Enable core bot functionality during the current session</li>
|
||||
</ol>
|
||||
<p>The Bot does not use any information for profiling, marketing, analytics, or tracking purposes.</p>
|
||||
<hr>
|
||||
<h2>3. Data Storage and Security</h2>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<p>The Bot does not sell, rent, trade, or otherwise share any data with third parties.</p>
|
||||
<p>Some features make outbound requests to third-party APIs to fetch content. These requests do not include any user data:</p>
|
||||
<ul>
|
||||
<li><strong>Datamuse</strong> (datamuse.com) — word lists for Hangman and Wordle</li>
|
||||
<li><strong>Open Trivia Database</strong> (opentdb.com) — trivia questions for Trivia</li>
|
||||
<li><strong>The Cat API</strong> (thecatapi.com) — random cat images for the cat command</li>
|
||||
<li><strong>nekos.best</strong> (nekos.best) — hug GIFs for the hug command</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<h2>4. Data Retention</h2>
|
||||
<p>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.</p>
|
||||
<p>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.</p>
|
||||
<hr>
|
||||
<h2>5. Self-Hosting</h2>
|
||||
<p>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.</p>
|
||||
|
||||
43
README.md
43
README.md
@@ -11,9 +11,46 @@ SkyBot is a Valour.gg bot built with .NET 10.
|
||||
<li>Open-source under AGPL-3.0</li>
|
||||
<li>Built with .NET 10</li>
|
||||
<li>Command system with automatic registration</li>
|
||||
<li>Moderation commands (ban, unban, kick)</li>
|
||||
<li>Fun commands (8ball, coinflip, dice, rock paper scissors, and more)</li>
|
||||
<li>Info commands (user info, planet info, ping, uptime)</li>
|
||||
</ul>
|
||||
<h3>Fun</h3>
|
||||
<ul>
|
||||
<li>8ball — ask the magic 8 ball a question</li>
|
||||
<li>coinflip — flip a coin</li>
|
||||
<li>dice — roll a die</li>
|
||||
<li>rockpaperscissors — play rock paper scissors against the bot</li>
|
||||
<li>choose — pick one of the given options</li>
|
||||
<li>echo — repeat text through the bot</li>
|
||||
<li>reverse — reverse yours or a replied message</li>
|
||||
<li>mock — mOcK tExT</li>
|
||||
<li>t9encode / t9decode — encode or decode old phone keypad multi-tap digits</li>
|
||||
<li>hangman — channel-wide game of hangman with optional category (<code>hg <letter or word></code> to guess)</li>
|
||||
<li>wordle — channel-wide Wordle; guess the 5-letter word in 6 tries (<code>wg <word></code> to guess)</li>
|
||||
<li>trivia — channel-wide trivia question with 30 seconds to answer (<code>tg <A/B/C/D></code> to guess)</li>
|
||||
</ul>
|
||||
<h3>Chill</h3>
|
||||
<ul>
|
||||
<li>cat — post a random cat picture</li>
|
||||
<li>hug — send a hug with a random gif</li>
|
||||
</ul>
|
||||
<h3>Info</h3>
|
||||
<ul>
|
||||
<li>ping — check bot latency</li>
|
||||
<li>uptime — show how long the bot has been running</li>
|
||||
<li>info — user and planet info</li>
|
||||
<li>version — show the current bot and Valour SDK version</li>
|
||||
<li>usercount — show the total Valour user count</li>
|
||||
<li>source — link to the bot's source code</li>
|
||||
<li>joinsite — link to a site to help bots join a planet</li>
|
||||
<li>devcentral — invite link to the Dev Central planet</li>
|
||||
<li>swagger — link to the Valour API docs</li>
|
||||
<li>minecraft — Unofficial ValourSMP server IPs</li>
|
||||
<li>suggest — submit a suggestion for the bot</li>
|
||||
</ul>
|
||||
<h3>Moderation</h3>
|
||||
<ul>
|
||||
<li>ban / unban / kick — member moderation</li>
|
||||
<li>bans — list all bans in the planet</li>
|
||||
<li>setwelcome — configure a welcome channel and message</li>
|
||||
</ul>
|
||||
<h2>Data & Privacy</h2>
|
||||
<p>SkyBot stores only the minimum data required for operation. All data is stored in-memory and is lost on restart. SkyBot does <strong>not</strong> persist any data to disk.</p>
|
||||
|
||||
323
SkyBot/Commands/Fun/Hangman.cs
Normal file
323
SkyBot/Commands/Fun/Hangman.cs
Normal file
@@ -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<char> Guessed,
|
||||
HashSet<char> Wrong,
|
||||
HashSet<string> Contributors,
|
||||
long StarterId,
|
||||
Message BotMessage,
|
||||
Channel Channel,
|
||||
ValourClient Client);
|
||||
|
||||
private static readonly ConcurrentDictionary<long, HangmanSession> _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<Message?> 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<string> 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<char> guessed, HashSet<char> 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 <letter or word>` to guess!*"
|
||||
);
|
||||
}
|
||||
|
||||
public async Task Execute(CommandContext ctx)
|
||||
{
|
||||
ConcurrentDictionary<long, Channel> 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<char>();
|
||||
var wrong = new HashSet<char>();
|
||||
var contributors = new HashSet<string>();
|
||||
|
||||
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 <letter or word>";
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
256
SkyBot/Commands/Fun/Trivia.cs
Normal file
256
SkyBot/Commands/Fun/Trivia.cs
Normal file
@@ -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<string> Answers,
|
||||
Message BotMessage,
|
||||
Channel Channel,
|
||||
ValourClient Client,
|
||||
ConcurrentDictionary<long, GuessEntry> Guesses);
|
||||
|
||||
private static readonly ConcurrentDictionary<long, TriviaSession> _sessions = new();
|
||||
private static readonly HttpClient _http = new();
|
||||
|
||||
private static readonly string[] Difficulties = ["easy", "medium", "hard"];
|
||||
|
||||
private static readonly Dictionary<string, int> 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<long, Channel> 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<string> 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 <A/B/C/D>` — 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<string> corrects = [..newSession.Guesses
|
||||
.Where(kv => kv.Value.Letter == newSession.CorrectLetter)
|
||||
.Select(kv => kv.Value.MemberName)];
|
||||
|
||||
List<string> 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 <A/B/C/D>";
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
403
SkyBot/Commands/Fun/Wordle.cs
Normal file
403
SkyBot/Commands/Fun/Wordle.cs
Normal file
@@ -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<string> Guesses,
|
||||
List<string[]> Feedback,
|
||||
HashSet<string> Contributors,
|
||||
long StarterId,
|
||||
Message BotMessage,
|
||||
Channel Channel,
|
||||
ValourClient Client);
|
||||
|
||||
private static readonly ConcurrentDictionary<long, WordleSession> _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<string[]> 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<bool> 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<string>();
|
||||
|
||||
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 <word>` 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<Message?> 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<long, Channel> 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 <word>";
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -12,16 +13,56 @@ namespace SkyBot.Helpers
|
||||
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<bool> 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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user