Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7e5ad5ece2
|
|||
|
9f5131346f
|
|||
|
51fbd0524e
|
|||
|
c0e90d633e
|
|||
|
fd9c18d953
|
|||
|
459973b40c
|
|||
| 1dfd00a256 | |||
| 19e95473b5 | |||
| 28c59d90f3 | |||
| 74f193ccb2 | |||
| 675ea71948 | |||
| f7fd877023 | |||
| 4f41c465d1 | |||
| 44393f1745 | |||
| 042b4dceaf | |||
| ebbdc17b15 | |||
| d5e51d0cba | |||
| 9cd0a6df78 | |||
| b787a9e3c6 | |||
| 4f6ae37eca | |||
| 2cf3798728 | |||
| 88b647b134 | |||
| cebba3948a | |||
| 8a1f295591 | |||
| 9e4a3ffb16 | |||
| 5b1394ad6a | |||
| 06c088c4f8 | |||
| c410cbbdc3 | |||
|
|
5e3da35bd1 | ||
| b979073772 | |||
| a02ed2d389 | |||
|
|
c330c3f2e3 | ||
| 4c9788ab0b | |||
|
|
aa8b3bbb8c | ||
| 80a86553ef | |||
|
|
ff9fbc67f2 | ||
| aba6eb9fd4 | |||
| b41d7268f2 | |||
|
|
7059b5dce5 | ||
| 8c9bc41c83 | |||
| 395ead9061 | |||
|
|
5e60a4b7a3 | ||
|
|
5b18a2170b | ||
| 5ea0cf536c | |||
|
|
6d546d81c0 | ||
| 7050e90833 | |||
|
|
f8085d508b | ||
| 9063bf8fcf | |||
| 2c5fb3cb6a | |||
|
|
5e91b3063b | ||
| 22750c2960 | |||
| 7a7ea3fc26 | |||
|
|
3cc66250e5 | ||
| fbf0fb92cd | |||
| 5b9362ddd6 | |||
|
|
ad87e93e6a | ||
| 51cde67339 | |||
|
|
ddf4e3cc87 | ||
|
|
b43cfa4b17 | ||
|
|
72dfaf7434 | ||
|
|
3631f71ab5 | ||
| eb06fc8102 | |||
| ab9c7223ca | |||
| ea4aa56f5d | |||
| 0fb95eedef | |||
| 418d2ba00e | |||
| 44448acde6 | |||
| bf27843e82 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,5 +1,7 @@
|
|||||||
bin/
|
|
||||||
obj/
|
|
||||||
SkyBot.sln
|
|
||||||
.env
|
.env
|
||||||
Program.cs.old
|
.gitignore
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**.sln
|
||||||
|
**/database.db
|
||||||
|
|
||||||
|
|||||||
94
COMMANDS.md
Normal file
94
COMMANDS.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# SkyBot Commands
|
||||||
|
|
||||||
|
All commands are prefixed with your configured prefix (e.g. `sd/`). Arguments in `[brackets]` are optional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fun
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `8ball <question>` | — | Ask the magic 8ball a question. Replies with a random response after a short delay. |
|
||||||
|
| `echo <text>` | `say` | Repeat text through the bot. If the command message is deleted, the echoed message is also deleted. |
|
||||||
|
| `mock <text\|reply>` | — | Mocks text by alternating upper and lower case. Provide text or reply to a message. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Info
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `help` | `cmds` | Shows all commands organised by category with an interactive embed |
|
||||||
|
| `info <planet\|user> [@user]` | — | Shows detailed info about the current planet or a user |
|
||||||
|
| `ping` | — | Shows the bot's response time |
|
||||||
|
| `source` | `src` | Link to the bot's source code |
|
||||||
|
| `uptime` | — | Shows how long the bot has been running |
|
||||||
|
| `version` | `ver` | Shows the bot version, Valour server version, and Valour SDK version (current and latest) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
> Requires the appropriate planet permissions.
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `ban <@member\|id> [duration] [reason]` | — | Bans a member from the planet. Duration is optional (e.g. `7d`, `2h`, `30m`). Permanent if omitted. |
|
||||||
|
| `kick <@member\|id> [reason]` | — | Kicks a member from the planet. Sends them a DM embed before kicking. |
|
||||||
|
| `restrict <disable\|enable\|list> <category> [all\|command]` | `cr`, `channelrestrict` | Disable or enable command categories or individual commands in the current channel. Requires Manage Channel permission. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RP
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `emote <action> [@user]` | `e` | Send an animated RP emote. Target is optional for most actions. |
|
||||||
|
| `marriage <propose\|status\|divorce\|force> [...]` | `marry` | Marriage system — propose, check status, or divorce. |
|
||||||
|
|
||||||
|
### emote actions
|
||||||
|
|
||||||
|
`angry`, `baka`, `bite`, `blowkiss`, `blush`, `bonk`, `carry`, `clap`, `cry`, `cuddle`, `dance`, `facepalm`, `happy`, `holdhand`, `hug`, `kiss`, `laugh`, `lurk`, `nom`, `nya`, `pat`, `poke`, `pout`, `punch`, `run`, `shocked`, `sleep`, `smug`, `spin`, `tableflip`, `teehee`, `tickle`, `wave`, `wink`, `yawn`
|
||||||
|
|
||||||
|
### marriage subcommands
|
||||||
|
|
||||||
|
| Subcommand | Usage | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `propose` | `marriage propose` | Propose to someone (reply to their message or mention them) |
|
||||||
|
| `status` | `marriage status [@user]` | Check your own or another user's marriage status |
|
||||||
|
| `divorce` | `marriage divorce` | Begin a divorce (requires `confirm` to finalise) |
|
||||||
|
| `force` | `marriage force marry @u1 @u2` / `marriage force divorce @user` | Owner only — force marry or divorce users |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Utils
|
||||||
|
|
||||||
|
> These commands respond to pending actions created by other commands.
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `accept` | — | Accept a pending action (e.g. a marriage proposal) |
|
||||||
|
| `decline` | — | Decline a pending action |
|
||||||
|
| `confirm` | — | Confirm a pending action (e.g. a divorce) |
|
||||||
|
| `cancel` | — | Cancel a pending action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev
|
||||||
|
|
||||||
|
> These commands are only accessible to the bot owner.
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `blacklist <add\|remove> <@user\|userid>` | — | Add or remove a user from the bot blacklist |
|
||||||
|
| `delete` | — | Deletes a replied-to message. Deletes bot messages directly; requires `ManageMessages` for other members' messages. Reply to a message to use. |
|
||||||
|
| `planet <join\|leave\|list>` | — | Manage the planets the bot is a member of |
|
||||||
|
| `react <emoji> [amount]` | — | Add a reaction to a replied-to message a given number of times |
|
||||||
|
|
||||||
|
### planet subcommands
|
||||||
|
|
||||||
|
| Subcommand | Usage | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `join` | `planet join <id> [invite]` | Join a planet by ID, optionally with an invite code |
|
||||||
|
| `leave` | `planet leave [id]` | Leave a planet by ID, or the current planet if no ID is given |
|
||||||
|
| `list` | `planet list` | List all planets the bot is currently a member of |
|
||||||
131
PRIVACY.md
131
PRIVACY.md
@@ -1,62 +1,89 @@
|
|||||||
<!DOCTYPE html>
|
# Privacy Policy
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>Privacy Policy</h1>
|
**Effective Date:** April 18, 2026
|
||||||
<p><span class="bold">Effective Date:</span> February 26, 2026</p>
|
|
||||||
<p>This Privacy Policy describes how the bot (“the Bot”) collects, uses, and stores information when used within a server.</p>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2>1. Information Collected</h2>
|
This Privacy Policy describes how SkyBot ("the Bot") collects, uses, and stores information when used within a Valour planet.
|
||||||
<p>The Bot collects and stores only the minimum data necessary to provide its intended functionality.</p>
|
|
||||||
|
|
||||||
<h3>Information Stored:</h3>
|
---
|
||||||
<ol>
|
|
||||||
<li>Message IDs</li>
|
|
||||||
<li>Channel IDs</li>
|
|
||||||
<li>Server (“Planet”) IDs</li>
|
|
||||||
<li>Planet Configuration data associated with those channels</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>Information Not Stored:</h3>
|
## 1. Information Collected
|
||||||
<ol>
|
|
||||||
<li>Message content</li>
|
|
||||||
<li>User-generated message content</li>
|
|
||||||
<li>Direct Messages (“DMs”)</li>
|
|
||||||
<li>Personal account information (including usernames, email addresses, or other personally identifiable information)</li>
|
|
||||||
</ol>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2>2. Purpose of Data Collection</h2>
|
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. No data is persisted to disk.
|
||||||
<p>Stored information is used exclusively to:</p>
|
|
||||||
<ol>
|
|
||||||
<li>Maintain server-specific configuration settings</li>
|
|
||||||
<li>Associate Planets with designated channels</li>
|
|
||||||
<li>Enable and maintain core bot functionality</li>
|
|
||||||
</ol>
|
|
||||||
<p>The Bot does not use stored information for profiling, marketing, analytics, or tracking purposes.</p>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2>3. Data Storage and Security</h2>
|
### Information Temporarily Held in Memory
|
||||||
<p>All stored data is maintained securely on the Bot’s hosting server. Reasonable technical measures are implemented to protect stored information against unauthorized access, alteration, or disclosure.</p>
|
|
||||||
<p>The Bot does not sell, rent, trade, or otherwise share stored data with third parties.</p>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2>4. Data Retention</h2>
|
1. Channel IDs (for routing messages and commands)
|
||||||
<p>Configuration data is retained only while the Bot remains active within a server.</p>
|
2. Planet IDs (for planet-specific operations)
|
||||||
<p>If the Bot is removed from a server, associated configuration data may be deleted within a reasonable timeframe.</p>
|
3. Member IDs (for command execution context)
|
||||||
<hr>
|
4. Echo message mappings (command message ID → echoed message ID, used to delete echoed messages if the original is deleted)
|
||||||
|
5. Pending marriage proposals (user ID pairs and expiry times, cleared on acceptance, decline, expiry, or restart)
|
||||||
|
6. Pending divorce confirmations (user ID and expiry time, cleared on confirm, cancel, expiry, or restart)
|
||||||
|
|
||||||
<h2>5. Future Changes to Logging or Data Practices</h2>
|
### Information Persisted to Disk
|
||||||
<p>If additional operational logging or data collection practices are introduced in the future, this Privacy Policy will be updated to reflect those changes prior to implementation.</p>
|
|
||||||
<p>Continued use of the Bot after updates to this policy constitutes acceptance of the revised policy.</p>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<h2>6. Contact Information</h2>
|
The following data is written to a local SQLite database (`database.db`) and is retained across restarts:
|
||||||
<p>For privacy-related inquiries, requests, or concerns, please contact:</p>
|
|
||||||
<p><span class="bold">Email:</span> contact@skyjoshua.xyz</p>
|
|
||||||
|
|
||||||
</body>
|
1. Marriage records — the Valour user IDs of both partners and the timestamp the marriage was created
|
||||||
</html>
|
2. Blacklist entries — the Valour user ID of each blacklisted user
|
||||||
|
3. Channel restrictions — channel IDs and the categories or commands disabled within them
|
||||||
|
|
||||||
|
### Information Never Stored
|
||||||
|
|
||||||
|
1. Message content
|
||||||
|
2. Direct messages sent by the bot to users as part of moderation actions
|
||||||
|
3. Personal account information (including usernames, email addresses, or other personally identifiable information)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Purpose of Data Collection
|
||||||
|
|
||||||
|
Temporarily held information is used exclusively to:
|
||||||
|
|
||||||
|
1. Route commands to the correct channels and planets
|
||||||
|
2. Enable core bot functionality during the current session
|
||||||
|
3. Track echo message pairs to support automatic cleanup when the original command message is deleted
|
||||||
|
4. Operate the marriage system (tracking active marriages and pending proposals/divorces)
|
||||||
|
5. Enforce the blacklist (preventing blacklisted users from using bot commands)
|
||||||
|
|
||||||
|
The Bot does not use any information for profiling, marketing, analytics, or tracking purposes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Data Storage and Security
|
||||||
|
|
||||||
|
In-memory data is automatically cleared when the Bot restarts. Marriage records, blacklist entries, and channel restrictions are written to a local SQLite database file (`database.db`) on the machine running the bot. 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.
|
||||||
|
|
||||||
|
The Bot makes outbound requests to the following third-party APIs:
|
||||||
|
|
||||||
|
- **NuGet** (api.nuget.org) — fetched by the `version` command to check the latest Valour SDK version. No user data is included.
|
||||||
|
- **nekos.best** (nekos.best) — fetched by the `emote` command to retrieve animated GIFs. No user data is included.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Retention
|
||||||
|
|
||||||
|
All in-memory data is held only for the duration of the Bot's current session and is cleared on restart. Echo message mappings are additionally cleared as soon as the original command message is deleted or the session ends.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Future Changes to Logging or Data Practices
|
||||||
|
|
||||||
|
If additional operational logging or data collection practices are introduced in the future, this Privacy Policy will be updated to reflect those changes prior to implementation.
|
||||||
|
|
||||||
|
Continued use of the Bot after updates to this policy constitutes acceptance of the revised policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contact Information
|
||||||
|
|
||||||
|
For privacy-related inquiries, requests, or concerns, please contact:
|
||||||
|
|
||||||
|
**Email:** contact@skyjoshua.xyz
|
||||||
248
Program.cs
248
Program.cs
@@ -1,248 +0,0 @@
|
|||||||
using Valour.Sdk.Client;
|
|
||||||
using Valour.Sdk.Models;
|
|
||||||
using DotNetEnv;
|
|
||||||
using SkyBot;
|
|
||||||
|
|
||||||
Env.Load();
|
|
||||||
|
|
||||||
var token = Environment.GetEnvironmentVariable("TOKEN");
|
|
||||||
var allowedUserIds = new List<long> { 15652354820931584 };
|
|
||||||
var ownerId = 15652354820931584;
|
|
||||||
var prefix = Environment.GetEnvironmentVariable("PREFIX");
|
|
||||||
|
|
||||||
var client = new ValourClient("https://api.valour.gg/");
|
|
||||||
client.SetupHttpClient();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
Console.WriteLine("TOKEN environment variable not set.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginResult = await client.InitializeUser(token);
|
|
||||||
if (!loginResult.Success)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Login Failed: {loginResult.Message}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Console.WriteLine($"Logged in as {client.Me.Name} (ID: {client.Me.Id})");
|
|
||||||
|
|
||||||
await Utils.UpdateValourUserCountAsync();
|
|
||||||
Utils.StartValourUserUpdater();
|
|
||||||
|
|
||||||
|
|
||||||
//Dictionaries
|
|
||||||
var channelCache = new Dictionary<long, Channel>();
|
|
||||||
var InitializedPlanets = new HashSet<long>();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await Utils.InitializePlanetsAsync(client, channelCache, InitializedPlanets);
|
|
||||||
|
|
||||||
client.PlanetService.JoinedPlanetsUpdated += async () =>
|
|
||||||
{
|
|
||||||
await Utils.InitializePlanetsAsync(client, channelCache, InitializedPlanets);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
client.MessageService.MessageReceived += async (message) =>
|
|
||||||
{
|
|
||||||
string content = message.Content ?? "";
|
|
||||||
long channelId = message.ChannelId;
|
|
||||||
var member = await message.FetchAuthorMemberAsync();
|
|
||||||
var pingMember = $"«@m-{member.Id}»";
|
|
||||||
|
|
||||||
if (content is null) return;
|
|
||||||
|
|
||||||
if (message.AuthorUserId == client.Me.Id) return;
|
|
||||||
|
|
||||||
|
|
||||||
if (allowedUserIds.Contains(message.AuthorUserId))
|
|
||||||
{
|
|
||||||
if (Utils.IsSingleEmoji(content))
|
|
||||||
{
|
|
||||||
await message.AddReactionAsync(content);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}react"))
|
|
||||||
{
|
|
||||||
if (message.AuthorUserId != ownerId) return;
|
|
||||||
|
|
||||||
string[] args = content.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
if (args.Length < 2) return;
|
|
||||||
string emoji = args[1];
|
|
||||||
|
|
||||||
var interceptor = new Utils.ReactionInterceptor(Console.Out);
|
|
||||||
Console.SetOut(interceptor);
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
interceptor.Reset();
|
|
||||||
await message.AddReactionAsync(emoji);
|
|
||||||
if (interceptor.DetectedAlreadyExists)
|
|
||||||
{
|
|
||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
|
||||||
Console.WriteLine("Reaction already exists, stopping.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true });
|
|
||||||
}
|
|
||||||
|
|
||||||
var echoprefixes = new[] { $"{prefix}echo"};
|
|
||||||
if (Utils.ContainsAny(content, echoprefixes))
|
|
||||||
{
|
|
||||||
|
|
||||||
var matchedPrefix = echoprefixes.First(p => content.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
var reply = content.Substring(matchedPrefix.Length).TrimStart();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(reply)) await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} Enter a message to echo.");
|
|
||||||
|
|
||||||
reply = $"{pingMember} {reply}";
|
|
||||||
|
|
||||||
if (reply.Length > 2048)
|
|
||||||
{
|
|
||||||
reply = reply.Substring(0, 2048);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, reply);
|
|
||||||
};
|
|
||||||
|
|
||||||
var echorawprefixes = new[] { $"{prefix}rawecho"};
|
|
||||||
if (Utils.ContainsAny(content, echorawprefixes))
|
|
||||||
{
|
|
||||||
|
|
||||||
if (message.AuthorUserId != ownerId)
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, "You do not have permission to execute this command.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var matchedPrefix = echorawprefixes.First(p => content.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
var reply = content.Substring(matchedPrefix.Length).TrimStart();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(reply))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} Enter a message to echo.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reply = $"{reply}";
|
|
||||||
|
|
||||||
if (reply.Length > 2048)
|
|
||||||
{
|
|
||||||
reply = reply.Substring(0, 2048);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, reply);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}suggest"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} You can suggest a command to be added here: https://docs.google.com/spreadsheets/d/1CzcpLAuMiPL_RODrZ5x25cPj8yE-rR3mEnqrd_2Fbmk");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}source"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} You can see my source code here: https://github.com/SkyJoshua/SkyBot");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}joincode"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} You can use this to join a planet: https://github.com/SkyJoshua/JoinPlanet");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}joinsite"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} You can use this website to easily add your bot to a planet: https://skyjoshua.xyz/planetjoiner");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}api", $"{prefix}swagger"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"{pingMember} Here is a link to the Swagger API: https://api.valour.gg/swagger");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}cmds", $"{prefix}help"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, @$"{pingMember} Here is a list of my commands:
|
|
||||||
- `s/echo <text> - Echos text into the chat`
|
|
||||||
- `s/suggest - Shares the suggestions link`
|
|
||||||
- `s/source - Sends link for the source code`
|
|
||||||
- `s/joincode - Sends a link to a github that you can use to make your bot join your planet.`
|
|
||||||
- `s/joinsite - Sends a link to a website that you can use to make yout bot join your planet.`
|
|
||||||
- `s/api|swagger - Sends a link to the Swagger API`
|
|
||||||
- `s/cmds|help - Shows this list`
|
|
||||||
- `s/usercount - Shows the user count of Valour`
|
|
||||||
- `s/devcentral - Sends the invite link to the Dev Central Planet`
|
|
||||||
- `s/mc - Sends Unofficial ValourSMP IP`
|
|
||||||
");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}usercount"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, @$"{pingMember}
|
|
||||||
Current Valour user count is: {Utils.ValourUserCount:N0}
|
|
||||||
You can see a graph of the user count here: /meow");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}devcentral"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, @$"{pingMember} you can join the Dev Central (ID: 42439954653511681) planet here: https://app.valour.gg/I/k2tz9c4i");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}mc"))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, @$"{pingMember} you can join the Unofficial ValourSMP Minecraft Server by using this ip:
|
|
||||||
Java: `valour.sxsc.xyz`, Bedrock: `valourbr.sxsc.xyz` Both with the default ports.
|
|
||||||
Cool features can be found here: https://sxsc.xyz/servers/valour/");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Utils.ContainsAny(content, $"{prefix}invite"))
|
|
||||||
{
|
|
||||||
if(message.AuthorUserId != ownerId) return;
|
|
||||||
|
|
||||||
string[] args = content.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
if (args.Length < 2)
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, "Usage: s/invite <planetId> [inviteCode]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!long.TryParse(args[1], out long planetId))
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, "Planet ID is not valid.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
string inviteCode = args.Length > 2 ? args[2] : "";
|
|
||||||
|
|
||||||
var joinResult = await client.PlanetService.JoinPlanetAsync(planetId, inviteCode);
|
|
||||||
|
|
||||||
if (joinResult.Success && joinResult.Data != null)
|
|
||||||
{
|
|
||||||
await Task.Delay(200);
|
|
||||||
|
|
||||||
if (client.Cache.Planets.TryGet(planetId, out var planet))
|
|
||||||
{
|
|
||||||
if (planet is null) return;
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"Joined planet: {planet.Name}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, "Joined planet, but could not retrieve its name.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await Utils.SendReplyAsync(channelCache, channelId, $"Failed to join planet: {joinResult.Message}");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Console.WriteLine("Listening for messages...");
|
|
||||||
await Task.Delay(Timeout.Infinite);
|
|
||||||
166
README.md
166
README.md
@@ -1,106 +1,102 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>SkyBot</h1>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
SkyBot is a Valour.gg bot.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Features</h2>
|
|
||||||
<ul>
|
|
||||||
<li>Designed for self-hosting</li>
|
|
||||||
<li>Open-source under AGPL-3.0</li>
|
|
||||||
<li>Built with .NET</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2>Data & Privacy</h2>
|
|
||||||
<p>SkyBot stores only the minimum data required for operation:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Message IDs</li>
|
|
||||||
<li>Server (Planet) IDs</li>
|
|
||||||
<li>Channel IDs</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>SkyBot does <strong>not</strong> store:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Message content</li>
|
|
||||||
<li>Direct messages</li>
|
|
||||||
<li>Personal user data</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Full privacy policy:<br>
|
|
||||||
<a href="https://github.com/SkyJoshua/SkyBot/blob/main/PRIVACY.md">
|
|
||||||
https://github.com/SkyJoshua/SkyBot/blob/main/PRIVACY.md
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>License</h2>
|
|
||||||
<p>
|
|
||||||
This project is licensed under the
|
|
||||||
<strong>GNU Affero General Public License v3.0 (AGPL-3.0)</strong>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
# SkyBot
|
||||||
See the LICENSE file for details:<br>
|
|
||||||
<a href="https://github.com/SkyJoshua/SkyBot/blob/main/LICENSE">
|
|
||||||
https://github.com/SkyJoshua/SkyBot/blob/main/LICENSE
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
SkyBot is a Valour.gg bot built with .NET 10.
|
||||||
Because this project is licensed under AGPL-3.0, if you modify and deploy it
|
|
||||||
publicly (including as a hosted service), you must make your source code
|
|
||||||
available under the same license.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Installation</h2>
|
---
|
||||||
Fork this Repository
|
|
||||||
<pre><code>git clone https://github.com/YOUR_USERNAME/SkyBot.git
|
## Features
|
||||||
cd SkyBot
|
|
||||||
|
- Designed for self-hosting
|
||||||
|
- Open-source under AGPL-3.0
|
||||||
|
- Built with .NET 10
|
||||||
|
- Command system with automatic registration
|
||||||
|
|
||||||
|
### Command Categories
|
||||||
|
|
||||||
|
- **Fun** — echo, 8ball, mock and similar utilities
|
||||||
|
- **Info** — bot info, ping, uptime, command listing, planet/user info
|
||||||
|
- **Moderation** — kick, ban, and per-channel command restrictions
|
||||||
|
- **RP** — emotes (35 actions via nekos.best) and a marriage system
|
||||||
|
- **Utils** — accept, decline, confirm, cancel for pending actions
|
||||||
|
- **Dev** — owner-only tools including blacklist management, planet control, and message utilities
|
||||||
|
|
||||||
|
Full command list: [COMMANDS.md](COMMANDS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data & Privacy
|
||||||
|
|
||||||
|
SkyBot stores only the minimum data required for operation. Marriage records, blacklist entries, and channel restrictions are persisted to a local SQLite database. All other data is stored in-memory and is lost on restart.
|
||||||
|
|
||||||
|
SkyBot does **not** store:
|
||||||
|
|
||||||
|
- Message content
|
||||||
|
- Direct messages
|
||||||
|
- Personal user data beyond what is needed for the marriage and blacklist systems
|
||||||
|
|
||||||
|
Full privacy policy: [PRIVACY.md](PRIVACY.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the **GNU Affero General Public License v3.0 (AGPL-3.0)**.
|
||||||
|
|
||||||
|
See the LICENSE file for details.
|
||||||
|
|
||||||
|
Because this project is licensed under AGPL-3.0, if you modify and deploy it publicly (including as a hosted service), you must make your source code available under the same license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- .NET 10
|
||||||
|
- A Valour bot token
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url>
|
||||||
|
cd SkyBot/SkyBot
|
||||||
dotnet restore
|
dotnet restore
|
||||||
</code></pre>
|
```
|
||||||
|
|
||||||
<p>
|
All required NuGet packages will be installed automatically using the provided `SkyBot.csproj` file.
|
||||||
All required NuGet packages will be installed automatically using the
|
|
||||||
provided <code>SkyBot.csproj</code> file.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Configuration</h2>
|
---
|
||||||
<p>Before running the bot, create a <code>.env</code> file in the root directory of the project with the following content:</p>
|
|
||||||
|
|
||||||
<pre><code>TOKEN=your-bot-token-here
|
## Configuration
|
||||||
PREFIX=your-prefix-here
|
|
||||||
</code></pre>
|
|
||||||
|
|
||||||
<ul>
|
Create a `.env` file in the root directory of the project:
|
||||||
<li>Replace <code>your-bot-token-here</code> with your actual bot token.</li>
|
```
|
||||||
<li>Ensure the bot has proper permissions in the target server.</li>
|
TOKEN=your-bot-token-here
|
||||||
</ul>
|
```
|
||||||
|
|
||||||
<p>
|
Then open `Config.cs` and update the following values:
|
||||||
Sensitive data such as bot tokens should never be committed to the repository.
|
```cs
|
||||||
Use environment variables or secure configuration methods.
|
public static readonly long OwnerId = your-owner-id-here;
|
||||||
</p>
|
public static readonly string Prefix = "your-prefix-here";
|
||||||
|
```
|
||||||
|
|
||||||
<h2>Running the Bot</h2>
|
- Replace `your-owner-id-here` with your Valour user ID.
|
||||||
|
- Replace `your-prefix-here` with your desired command prefix (e.g. `sd/`).
|
||||||
|
|
||||||
<pre><code>dotnet run
|
Never commit your `.env` file to the repository. Ensure it is listed in your `.gitignore`.
|
||||||
</code></pre>
|
|
||||||
|
|
||||||
<h2>Contributing</h2>
|
---
|
||||||
<p>
|
|
||||||
Contributions are welcome. By submitting a contribution, you agree that your
|
|
||||||
contributions will be licensed under AGPL-3.0.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol>
|
## Running the Bot
|
||||||
<li>Fork the repository</li>
|
|
||||||
<li>Create a feature branch</li>
|
|
||||||
<li>Submit a pull request</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
</body>
|
```bash
|
||||||
</html>
|
dotnet run
|
||||||
|
```
|
||||||
|
|||||||
33
SkyBot/Commands/CommandRegistry.cs
Normal file
33
SkyBot/Commands/CommandRegistry.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public static class CommandRegistry
|
||||||
|
{
|
||||||
|
public static readonly Dictionary<string, ICommand> Commands = new();
|
||||||
|
public static readonly Dictionary<string, List<ICommand>> Categories = new();
|
||||||
|
|
||||||
|
static CommandRegistry()
|
||||||
|
{
|
||||||
|
var commands = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.SelectMany(a => a.GetTypes())
|
||||||
|
.Where(t => typeof(ICommand).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
|
||||||
|
.Select(t => (ICommand?)Activator.CreateInstance(t))
|
||||||
|
.Select(c => c!);
|
||||||
|
|
||||||
|
foreach (var cmd in commands)
|
||||||
|
{
|
||||||
|
Commands[cmd.Name.ToLower()] = cmd;
|
||||||
|
foreach (var alias in cmd.Aliases)
|
||||||
|
{
|
||||||
|
Commands[alias.ToLower()] = cmd;
|
||||||
|
}
|
||||||
|
|
||||||
|
Categories = Commands.Values
|
||||||
|
.Distinct()
|
||||||
|
.GroupBy(c => c.Category.ToLower())
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
SkyBot/Commands/CommandTemplate.cs
Normal file
18
SkyBot/Commands/CommandTemplate.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class CommandTemplate : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "template";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "";
|
||||||
|
public string Category => "template";
|
||||||
|
public string Usage => "";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
SkyBot/Commands/Dev/Blacklist.cs
Normal file
91
SkyBot/Commands/Dev/Blacklist.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Runtime.Serialization;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using SkyBot.Services;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Blacklist : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "blacklist";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "adds or removes a user from the blacklist";
|
||||||
|
public string Category => "Dev";
|
||||||
|
public string Usage => "blacklist <add|remove> <@user|userid>";
|
||||||
|
public string[] SubCommands => ["add", "remove"];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(ctx.Member))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You do not have permission to execute this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long? userId = null;
|
||||||
|
|
||||||
|
if (ctx.Message.Mentions?.Any() == true)
|
||||||
|
{
|
||||||
|
var member = await ctx.Planet.FetchMemberAsync(ctx.Message.Mentions.First().TargetId);
|
||||||
|
if (member is null) { await MessageHelper.ReplyAsync(ctx, "Could not find that member."); return; }
|
||||||
|
userId = member.UserId;
|
||||||
|
}
|
||||||
|
else if (long.TryParse(ctx.Args.ElementAtOrDefault(1), out long parsed))
|
||||||
|
{
|
||||||
|
var member = await ctx.Planet.FetchMemberAsync(parsed);
|
||||||
|
userId = member?.UserId ?? parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Mention a user or provide their user ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = await ctx.Client.UserService.FetchUserAsync(userId.Value);
|
||||||
|
if (user is null) { await MessageHelper.ReplyAsync(ctx, "Could not find that user."); return; }
|
||||||
|
|
||||||
|
switch (ctx.Args[0])
|
||||||
|
{
|
||||||
|
case "add":
|
||||||
|
await handleAdd(ctx, user);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
await handleRemove(ctx, user);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Usage: blacklist <add|remove> <@user|userid>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task handleAdd(CommandContext ctx, User user)
|
||||||
|
{
|
||||||
|
var result = await BlacklistService.Blacklist(user.Id);
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case BlacklistService.BlacklistResult.AlreadyBlacklisted:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "User is already blacklisted");
|
||||||
|
break;
|
||||||
|
case BlacklistService.BlacklistResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Blacklisted {user.Name} successfully.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task handleRemove(CommandContext ctx, User user)
|
||||||
|
{
|
||||||
|
var result = await BlacklistService.UnBlacklist(user.Id);
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case BlacklistService.UnBlacklistResult.NotBlacklisted:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "User is not blacklisted.");
|
||||||
|
break;
|
||||||
|
case BlacklistService.UnBlacklistResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Removed {user.Name} from the blacklist.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
SkyBot/Commands/Dev/Delete.cs
Normal file
53
SkyBot/Commands/Dev/Delete.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Shared.Authorization;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Delete : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "delete";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Deletes a message by the bot";
|
||||||
|
public string Category => "Dev";
|
||||||
|
public string Usage => "delete";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(ctx.Member))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You do not have permission to execute this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (ctx.Message.ReplyToId is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please reply to the message you would like to delete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Message.ReplyToId is not long replyToId)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please reply to the message you want to delete.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ctx.Client.Cache.Messages.TryGet(replyToId, out var replyMsg))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Could not find the replied message in cache.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyMsg!.AuthorUserId != ctx.Client.Me.Id && !await PermissionHelper.HasPermAsync(ctx.Planet.MyMember, [ChatChannelPermissions.ManageMessages], ctx.Channel))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "I do not have permission to delete other members' messages in this channel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await replyMsg!.DeleteAsync();
|
||||||
|
await ctx.Message.AddReactionAsync("👍");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
SkyBot/Commands/Dev/Planet.cs
Normal file
139
SkyBot/Commands/Dev/Planet.cs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class PlanetCmds : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "planet";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Planet Commands";
|
||||||
|
public string Category => "Dev";
|
||||||
|
public string Usage => "planet <sub>";
|
||||||
|
public string[] SubCommands => ["join", "leave", "list"];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Message.AuthorUserId != Config.OwnerId) return;
|
||||||
|
|
||||||
|
string sub = ctx.Args.Length > 0 ? ctx.Args[0].ToLower() : "";
|
||||||
|
|
||||||
|
switch (sub)
|
||||||
|
{
|
||||||
|
case "join":
|
||||||
|
await HandleJoin(ctx);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "leave":
|
||||||
|
await HandleLeave(ctx);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "list":
|
||||||
|
await HandleList(ctx);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Usage: {Config.Prefix}planet <join|leave|list>");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleJoin(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(ctx.Args.Length > 1 ? ctx.Args[1] : null, out long planetId))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please provide a valid planet ID.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Client.PlanetService.JoinedPlanets.Any(p => p.Id == planetId))
|
||||||
|
{
|
||||||
|
Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId);
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Bot is already a member of {planet.Name} (ID: {planet.Id})");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? inviteCode = ctx.Args.Length > 2 ? ctx.Args[2] : null;
|
||||||
|
|
||||||
|
TaskResult<PlanetMember> joinResult = inviteCode is null
|
||||||
|
? await ctx.Client.PlanetService.JoinPlanetAsync(planetId)
|
||||||
|
: await ctx.Client.PlanetService.JoinPlanetAsync(planetId, inviteCode);
|
||||||
|
|
||||||
|
if (!joinResult.Success)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Failed to join planet: {joinResult.Message}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId);
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Successfully joined planet: {planet.Name} (ID {planet.Id})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleLeave(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(ctx.Args.Length > 1 ? ctx.Args[1] : ctx.Planet.Id.ToString(), out long planetId))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please provide a valid planet ID or no planet ID to leave this planet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Planet planet = await ctx.Client.PlanetService.FetchPlanetAsync(planetId);
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Are you sure you want to leave planet: {planet.Name}? Type `{Config.Prefix}confirm within 30 seconds to confirm.");
|
||||||
|
bool confirmed = await PendingConfirmations.WaitAsync(ctx.Member.UserId, TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
if (!confirmed)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Confirmation timed out.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Leaving planet: {planet.Name}...");
|
||||||
|
await ctx.Client.PlanetService.LeavePlanetAsync(planet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleList(CommandContext ctx)
|
||||||
|
{
|
||||||
|
const int PageSize = 10;
|
||||||
|
|
||||||
|
var planets = ctx.Client.PlanetService.JoinedPlanets.ToList();
|
||||||
|
|
||||||
|
var chunks = planets
|
||||||
|
.Select((p, i) => (p, i))
|
||||||
|
.GroupBy(x => x.i / PageSize)
|
||||||
|
.Select(g => g.Select(x => x.p).ToList())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (chunks.Count == 0)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Bot is not a member of any planets.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new EmbedBuilder();
|
||||||
|
builder.embed.HideChangePageArrows = true;
|
||||||
|
|
||||||
|
for (int chunkIndex = 0; chunkIndex < chunks.Count; chunkIndex++)
|
||||||
|
{
|
||||||
|
int embedPage = chunkIndex;
|
||||||
|
string? footer = chunks.Count > 1 ? $"Page {chunkIndex + 1}/{chunks.Count}" : null;
|
||||||
|
|
||||||
|
builder.AddPage($"Planets ({planets.Count} total)", footer);
|
||||||
|
|
||||||
|
foreach (var planet in chunks[chunkIndex])
|
||||||
|
builder.AddText(planet.Name, $"ID: {planet.Id}");
|
||||||
|
|
||||||
|
if (chunkIndex > 0)
|
||||||
|
builder.AddButton("← Prev").OnClickGoToEmbedPage(embedPage - 1);
|
||||||
|
if (chunkIndex < chunks.Count - 1)
|
||||||
|
builder.AddButton("Next →").OnClickGoToEmbedPage(embedPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, null, builder.embed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
56
SkyBot/Commands/Dev/React.cs
Normal file
56
SkyBot/Commands/Dev/React.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class React : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "react";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Adds a reaction to a replied message.";
|
||||||
|
public string Category => "Dev";
|
||||||
|
public string Usage => "react <emoji> [amount]";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(ctx.Member))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You do not have permission to execute this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Args.Length < 1)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Usage: `{Config.Prefix}react <emoji> [amount]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string emoji = ctx.Args[0];
|
||||||
|
|
||||||
|
int amount = 1;
|
||||||
|
if (ctx.Args.Length >= 2 && !int.TryParse(ctx.Args[1], out amount))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"`{ctx.Args[1]}` is not a valid number. Defaulting to `1`");
|
||||||
|
amount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Message.ReplyToId is not long replyToId)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please reply to the message you want to add the reaction to.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ctx.Client.Cache.Messages.TryGet(replyToId, out var replyMsg))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Could not find the replied message in cache.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < amount; i++)
|
||||||
|
{
|
||||||
|
await replyMsg!.AddReactionAsync(emoji);
|
||||||
|
}
|
||||||
|
await ctx.Message.AddReactionAsync("👍");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
SkyBot/Commands/Fun/8Ball.cs
Normal file
56
SkyBot/Commands/Fun/8Ball.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class EightBall : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "8ball";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Ask the magic 8ball a question.";
|
||||||
|
public string Category => "Fun";
|
||||||
|
public string Usage => "8ball <question>";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
private static readonly string[] Responses =
|
||||||
|
[
|
||||||
|
"It is certain.",
|
||||||
|
"It is decidedly so.",
|
||||||
|
"Without a doubt.",
|
||||||
|
"Yes, definitely.",
|
||||||
|
"You may rely on it.",
|
||||||
|
"As I see it, yes.",
|
||||||
|
"Most likely.",
|
||||||
|
"Outlook good.",
|
||||||
|
"Yes.",
|
||||||
|
"Signs point to yes.",
|
||||||
|
"Reply hazy, try again.",
|
||||||
|
"Ask again later.",
|
||||||
|
"Better not tell you now.",
|
||||||
|
"Cannot predict now.",
|
||||||
|
"Concentrate and ask again.",
|
||||||
|
"Don't count on it.",
|
||||||
|
"My reply is no.",
|
||||||
|
"My sources say no.",
|
||||||
|
"Outlook not so good.",
|
||||||
|
"Very doubtful."
|
||||||
|
];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Args.Length == 0)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please ask a question.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskResult<Message> result = await MessageHelper.ReplyAsync(ctx, $"🎱 Thinking...", reply: true);
|
||||||
|
await ctx.Channel.SendIsTyping();
|
||||||
|
await Task.Delay(2000);
|
||||||
|
string response = Responses[Random.Shared.Next(Responses.Length)];
|
||||||
|
await MessageHelper.EditAsync(ctx.Channel, result.Data, $"🎱 {response}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
SkyBot/Commands/Fun/Echo.cs
Normal file
44
SkyBot/Commands/Fun/Echo.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Echo : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "echo";
|
||||||
|
public string[] Aliases => ["say"];
|
||||||
|
public string Description => "Echos your message as the bot";
|
||||||
|
public string Category => "Fun";
|
||||||
|
public string Usage => "echo <text>";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public static readonly ConcurrentDictionary<long, long> EchoMap = new();
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string reply = string.Join(' ', ctx.Args);
|
||||||
|
if (string.IsNullOrWhiteSpace(reply))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please enter a message to echo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reply.Length > 2048)
|
||||||
|
{
|
||||||
|
reply = reply.Substring(0, 2048);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskResult<Message> echoedMsg = await MessageHelper.ReplyAsync(ctx, reply);
|
||||||
|
|
||||||
|
if (echoedMsg.Success && echoedMsg.Data is not null)
|
||||||
|
{
|
||||||
|
EchoMap[ctx.Message.Id] = echoedMsg.Data.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
SkyBot/Commands/Fun/Mock.cs
Normal file
44
SkyBot/Commands/Fun/Mock.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Mock : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "mock";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Mocks the text a user has sent or entered text";
|
||||||
|
public string Category => "Fun";
|
||||||
|
public string Usage => "mock <text|reply>";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string text;
|
||||||
|
|
||||||
|
if (ctx.Message.ReplyToId.HasValue)
|
||||||
|
{
|
||||||
|
var replyMessage = await ctx.Message.FetchReplyMessageAsync();
|
||||||
|
text = replyMessage?.Content ?? "";
|
||||||
|
} else
|
||||||
|
{
|
||||||
|
if (ctx.Args.Length == 0)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please provide some text to mock or reply to a message.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
text = string.Join(" ", ctx.Args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "No text to mock.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string mocked = new string(text.Select((c, i) => i % 2 == 0 ? char.ToLower(c) : char.ToUpper(c)).ToArray());
|
||||||
|
await MessageHelper.ReplyAsync(ctx, mocked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
SkyBot/Commands/Info/Help.cs
Normal file
158
SkyBot/Commands/Info/Help.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Help : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "help";
|
||||||
|
public string[] Aliases => ["cmds"];
|
||||||
|
public string Description => "Shows all commands by category.";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "help";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
private const int PageSize = 10;
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
var categories = CommandRegistry.Categories
|
||||||
|
.Where(c => c.Key != "template")
|
||||||
|
.Where(c => c.Key != "dev" || PermissionHelper.IsOwner(ctx.Member))
|
||||||
|
.OrderBy(c => c.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var categoryFirstPage = new Dictionary<string, int>();
|
||||||
|
var allChunks = new List<(string CategoryName, List<ICommand> Cmds, int ChunkIndex, int TotalChunks)>();
|
||||||
|
|
||||||
|
int nextPageIndex = 1;
|
||||||
|
foreach (var (categoryName, cmds) in categories)
|
||||||
|
{
|
||||||
|
var ordered = cmds.OrderBy(c => c.Name).ToList();
|
||||||
|
var chunks = ordered
|
||||||
|
.Select((cmd, i) => (cmd, i))
|
||||||
|
.GroupBy(x => x.i / PageSize)
|
||||||
|
.Select(g => g.Select(x => x.cmd).ToList())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
categoryFirstPage[categoryName] = nextPageIndex;
|
||||||
|
for (int c = 0; c < chunks.Count; c++)
|
||||||
|
allChunks.Add((categoryName, chunks[c], c, chunks.Count));
|
||||||
|
|
||||||
|
nextPageIndex += chunks.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
var allCmds = allChunks
|
||||||
|
.SelectMany(chunk => chunk.Cmds)
|
||||||
|
.DistinctBy(cmd => cmd.Name)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var cmdDetailPage = new Dictionary<string, int>();
|
||||||
|
int detailPageIndex = nextPageIndex;
|
||||||
|
foreach (var cmd in allCmds)
|
||||||
|
cmdDetailPage[cmd.Name] = detailPageIndex++;
|
||||||
|
|
||||||
|
var builder = new EmbedBuilder();
|
||||||
|
builder.embed.HideChangePageArrows = true;
|
||||||
|
|
||||||
|
// Home page
|
||||||
|
builder.AddPage("✦ Help Menu", $"Prefix: {Config.Prefix} | <> = required [] = optional");
|
||||||
|
builder.AddRow()
|
||||||
|
.AddText("Select a Category")
|
||||||
|
.WithStyles(EmbedStyles.LabelText)
|
||||||
|
.CloseRow();
|
||||||
|
|
||||||
|
builder.AddRow();
|
||||||
|
foreach (var (categoryName, cmds) in categories)
|
||||||
|
{
|
||||||
|
builder.AddButton($"{categoryName.ToTitleCase()} ({cmds.Count})")
|
||||||
|
.WithStyles(EmbedStyles.CategoryBtn)
|
||||||
|
.OnClickGoToEmbedPage(categoryFirstPage[categoryName]);
|
||||||
|
}
|
||||||
|
builder.CloseRow();
|
||||||
|
|
||||||
|
// Category pages
|
||||||
|
int embedPage = 1;
|
||||||
|
foreach (var (categoryName, cmds, chunkIndex, totalChunks) in allChunks)
|
||||||
|
{
|
||||||
|
string? footer = totalChunks > 1
|
||||||
|
? $"Page {chunkIndex + 1}/{totalChunks} | Prefix: {Config.Prefix} | <> = required [] = optional"
|
||||||
|
: $"Prefix: {Config.Prefix} | <> = required [] = optional";
|
||||||
|
|
||||||
|
builder.AddPage($"✦ {categoryName.ToTitleCase()} Commands", footer);
|
||||||
|
|
||||||
|
foreach (var cmd in cmds)
|
||||||
|
{
|
||||||
|
builder.AddRow()
|
||||||
|
.AddButton(cmd.Name.ToTitleCase())
|
||||||
|
.WithStyles(EmbedStyles.CommandBtn)
|
||||||
|
.OnClickGoToEmbedPage(cmdDetailPage[cmd.Name])
|
||||||
|
.CloseRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddRow()
|
||||||
|
.AddButton("← Back")
|
||||||
|
.WithStyles(EmbedStyles.BackBtn)
|
||||||
|
.OnClickGoToEmbedPage(0);
|
||||||
|
|
||||||
|
if (chunkIndex > 0)
|
||||||
|
{
|
||||||
|
builder.AddButton("← Prev")
|
||||||
|
.WithStyles(EmbedStyles.NavBtn)
|
||||||
|
.OnClickGoToEmbedPage(embedPage - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkIndex < totalChunks - 1)
|
||||||
|
{
|
||||||
|
builder.AddButton("Next →")
|
||||||
|
.WithStyles(EmbedStyles.NavBtn)
|
||||||
|
.OnClickGoToEmbedPage(embedPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.CloseRow();
|
||||||
|
|
||||||
|
embedPage++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command detail pages
|
||||||
|
foreach (var cmd in allCmds)
|
||||||
|
{
|
||||||
|
builder.AddPage($"✦ {cmd.Name.ToTitleCase()}", $"Prefix: {Config.Prefix} | <> = required [] = optional");
|
||||||
|
|
||||||
|
builder.AddRow()
|
||||||
|
.AddText("Description", cmd.Description)
|
||||||
|
.CloseRow();
|
||||||
|
|
||||||
|
if (cmd.Aliases.Length > 0)
|
||||||
|
{
|
||||||
|
builder.AddRow()
|
||||||
|
.AddText("Aliases", string.Join(", ", cmd.Aliases))
|
||||||
|
.CloseRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(cmd.Usage))
|
||||||
|
{
|
||||||
|
builder.AddRow()
|
||||||
|
.AddText("Usage", $"{Config.Prefix}{cmd.Usage}")
|
||||||
|
.CloseRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmd.SubCommands.Length > 0)
|
||||||
|
{
|
||||||
|
builder.AddRow()
|
||||||
|
.AddText("Sub-commands", string.Join(", ", cmd.SubCommands.Select(s => s)))
|
||||||
|
.CloseRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.AddRow()
|
||||||
|
.AddButton("← Back")
|
||||||
|
.WithStyles(EmbedStyles.BackBtn)
|
||||||
|
.OnClickGoToEmbedPage(categoryFirstPage[cmd.Category.ToLower()])
|
||||||
|
.CloseRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, null, builder.embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
SkyBot/Commands/Info/Info.cs
Normal file
107
SkyBot/Commands/Info/Info.cs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Superpower.Model;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles.Basic;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles.Flex;
|
||||||
|
using Valour.Shared.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Info : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "info";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Shows info about a Planet or User";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "info <sub> [user]";
|
||||||
|
public string[] SubCommands => ["planet", "user"];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Args.Length == 0)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Please specify `planet` or `user`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ctx.Args[0].ToLower())
|
||||||
|
{
|
||||||
|
case "user":
|
||||||
|
case "u":
|
||||||
|
await HandleUserInfo(ctx);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "planet":
|
||||||
|
case "p":
|
||||||
|
await HandlePlanetInfo(ctx);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Invalid Option. Use either `planet` or `user`.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUserInfo(CommandContext ctx)
|
||||||
|
{
|
||||||
|
long memberId = ctx.Message.Mentions?.Any() == true
|
||||||
|
? ctx.Message.Mentions.First().TargetId
|
||||||
|
: long.TryParse(ctx.Args.ElementAtOrDefault(1), out var parsed)
|
||||||
|
? parsed
|
||||||
|
: ctx.Member.Id;
|
||||||
|
PlanetMember member = await ctx.Planet.FetchMemberAsync(memberId);
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Could not find member.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var b = new EmbedBuilder()
|
||||||
|
.AddPage($"{member.Name.Trim()}'s Info")
|
||||||
|
.WithTitleStyles(new TextColor(member.PrimaryRole.Color))
|
||||||
|
// .AddMedia(MessageAttachmentType.Image, 64, 64, "image/webp", "avatar.webp", member.GetAvatar(AvatarFormat.Webp64))
|
||||||
|
// .WithStyles(
|
||||||
|
// new Position(right: new Size(Unit.Pixels, 8), top: new Size(Unit.Pixels, 8)),
|
||||||
|
// new Width(new Size(Unit.Pixels, 64)),
|
||||||
|
// new Height(new Size(Unit.Pixels, 64)),
|
||||||
|
// new BorderRadius(new Size(Unit.Percent, 50))
|
||||||
|
// )
|
||||||
|
.AddRow().AddText("Member ID", member.Id.ToString()).WithStyles(new TextColor("#ff9d00")).CloseRow()
|
||||||
|
.AddRow().AddText("User ID", member.UserId.ToString()).WithStyles(new TextColor("#ff9d00")).CloseRow()
|
||||||
|
.AddRow().AddText("Nickname", string.IsNullOrWhiteSpace(member.Nickname) ? "None" : member.Nickname).WithStyles(new TextColor(string.IsNullOrWhiteSpace(member.Nickname) ? "#ffed4a" : member.PrimaryRole.Color)).CloseRow()
|
||||||
|
.AddRow().AddText("Subscription", string.IsNullOrWhiteSpace(member.User.SubscriptionType) ? "None" : member.User.SubscriptionType).WithStyles(new TextColor(member.User.HasStargazer ? member.User.GetStarColor1() : "#ffed4a")).CloseRow()
|
||||||
|
.AddRow().AddText("Status", string.IsNullOrWhiteSpace(member.Status) ? "None" : member.Status).WithStyles(new TextColor("#ffed4a")).CloseRow()
|
||||||
|
.AddRow().AddText("Primary Role", member.PrimaryRole.Name).WithStyles(new TextColor(member.PrimaryRole.Color)).CloseRow()
|
||||||
|
.AddRow().AddText("Roles", string.Join(", ", member.Roles.Select(r => r.Name))).WithStyles(new TextColor(member.Roles[Random.Shared.Next(member.Roles.Count)].Color)).CloseRow()
|
||||||
|
.AddRow().AddText("Account Created", member.User.TimeJoined.ToString()).WithStyles(new TextColor("#979797")).CloseRow();
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, null, b.embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePlanetInfo(CommandContext ctx)
|
||||||
|
{
|
||||||
|
PlanetMember pOwner = await ctx.Planet.FetchMemberByUserAsync(ctx.Planet.OwnerId);
|
||||||
|
EmbedBuilder b = new EmbedBuilder()
|
||||||
|
.AddPage($"{ctx.Planet.Name}'s Info")
|
||||||
|
.AddRow().AddText("Planet ID", ctx.Planet.Id.ToString()).CloseRow()
|
||||||
|
.AddRow().AddText("Planet Description", ctx.Planet.Description).CloseRow()
|
||||||
|
.AddRow().AddText("State", ctx.Planet.Public ? ctx.Planet.Discoverable ? "Public (Discoverable)" : "Public (Not Discoverable)" : "Private").CloseRow()
|
||||||
|
.AddRow().AddText("NSFW", ctx.Planet.Nsfw.ToString()).CloseRow()
|
||||||
|
.AddRow().AddText("Owner Name (ID)", $"{pOwner.Name} ({pOwner.Id})").CloseRow()
|
||||||
|
.AddRow().AddText("Members", ctx.Planet.Members.Count.ToString())
|
||||||
|
.AddText("Channels", ctx.Planet.Channels.Count.ToString())
|
||||||
|
.AddText("Roles", ctx.Planet.Roles.Count.ToString()).CloseRow()
|
||||||
|
;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, null, b.embed);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
SkyBot/Commands/Info/Ping.cs
Normal file
26
SkyBot/Commands/Info/Ping.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Ping : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "ping";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Displays the bots response time.";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "ping";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
DateTime start = DateTime.UtcNow;
|
||||||
|
TaskResult<Message> message = await MessageHelper.ReplyAsync(ctx, "🏓 Pinging...");
|
||||||
|
double elapsed = (DateTime.UtcNow - start).TotalMilliseconds;
|
||||||
|
|
||||||
|
await MessageHelper.EditAsync(ctx.Channel, message.Data, $"🏓 Ping! `{elapsed:F0}ms`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
SkyBot/Commands/Info/Source.cs
Normal file
20
SkyBot/Commands/Info/Source.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Source : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "source";
|
||||||
|
public string[] Aliases => ["src"];
|
||||||
|
public string Description => "";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "source";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"You can find my source code here: {Config.SourceLink}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
SkyBot/Commands/Info/Uptime.cs
Normal file
28
SkyBot/Commands/Info/Uptime.cs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Uptime : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "uptime";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Displays the uptime of the bot";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "uptime";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
TimeSpan uptime = DateTime.UtcNow - Program.StartTime;
|
||||||
|
string formatted = uptime.TotalDays >= 1
|
||||||
|
? $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m {uptime.Seconds}s"
|
||||||
|
: uptime.TotalHours >= 1
|
||||||
|
? $"{uptime.Hours}h {uptime.Minutes}m {uptime.Seconds}s"
|
||||||
|
: uptime.TotalMinutes >= 1
|
||||||
|
? $"{uptime.Minutes}m {uptime.Seconds}s"
|
||||||
|
: $"{uptime.Seconds}s";
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"⏱️ SkyBot Uptime: {formatted}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
SkyBot/Commands/Info/Version.cs
Normal file
44
SkyBot/Commands/Info/Version.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Version : ICommand
|
||||||
|
{
|
||||||
|
private static readonly HttpClient http = new();
|
||||||
|
|
||||||
|
public string Name => "version";
|
||||||
|
public string[] Aliases => ["versions", "ver"];
|
||||||
|
public string Description => "";
|
||||||
|
public string Category => "Info";
|
||||||
|
public string Usage => "version";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string latestSdk = "Unknown";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
NuGetVersions? response = await http.GetFromJsonAsync<NuGetVersions>("https://api.nuget.org/v3-flatcontainer/valour.sdk/index.json");
|
||||||
|
string? raw = response?.Versions?.LastOrDefault();
|
||||||
|
latestSdk = raw is not null ? (raw.Count(c => c == '.') == 2 ? $"{raw}.0": raw) : "Unknown";
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
string msg = @$"Bot Version: {typeof(Version).Assembly.GetName().Version}
|
||||||
|
Valour Version: {ctx.Client.PrimaryNode.GetAsync("api/version").Result.Data}
|
||||||
|
ValourSDK Version: {typeof(Channel).Assembly.GetName().Version} (latest: {latestSdk})";
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NuGetVersions
|
||||||
|
{
|
||||||
|
[JsonPropertyName("versions")]
|
||||||
|
public List<string>? Versions { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
SkyBot/Commands/Mods/Ban.cs
Normal file
53
SkyBot/Commands/Mods/Ban.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared.Authorization;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Ban : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "ban";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Bans a member from the planet.";
|
||||||
|
public string Category => "Mod";
|
||||||
|
public string Usage => "ban <@member> [reason]";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!await PermissionHelper.HasPermAsync(ctx.Member, [PlanetPermissions.Ban]))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"You dont have permission to execute this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await PermissionHelper.HasPermAsync(ctx.Planet.MyMember, [PlanetPermissions.Ban]))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"I don't have permission to ban members.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Message.Mentions?.Any() != true && ctx.Args.Length < 1)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Mention a member or enter their ID to ban them.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long? targetId = ctx.Message.Mentions?.Any() == true ? ctx.Message.Mentions.First().TargetId : long.TryParse(ctx.Args.ElementAtOrDefault(0), out long parsed) ? parsed : null;
|
||||||
|
if (targetId is null) {await MessageHelper.ReplyAsync(ctx, "Could not find user."); return;};
|
||||||
|
|
||||||
|
User victim = await ctx.Client.UserService.FetchUserAsync(targetId.Value);
|
||||||
|
if (victim is null) {await MessageHelper.ReplyAsync(ctx, "Could not find user."); return;};
|
||||||
|
|
||||||
|
DateTime? expires = ctx.Args.Select(MessageHelper.ParseDuration).FirstOrDefault(x => x != null);
|
||||||
|
|
||||||
|
string reason = string.Join(" ", ctx.Args.Skip(1).Where(a => MessageHelper.ParseDuration(a) is null));
|
||||||
|
if (string.IsNullOrWhiteSpace(reason)) reason = "No reason provided";
|
||||||
|
|
||||||
|
await ctx.Planet.BanAsync(victim.Id, reason, expires);
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Banned {victim.NameAndTag}. Reason: `{reason}`. Expires: `{(expires.HasValue ? expires + " UTC" : "Never")}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
SkyBot/Commands/Mods/Kick.cs
Normal file
83
SkyBot/Commands/Mods/Kick.cs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
using System.Drawing;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles.Basic;
|
||||||
|
using Valour.Shared.Authorization;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Kick : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "Kick";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Kicks a member from the planet.";
|
||||||
|
public string Category => "Mod";
|
||||||
|
public string Usage => "kick <@member> [reason]";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!await PermissionHelper.HasPermAsync(ctx.Member, [PlanetPermissions.Kick]))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You don't have permission to execute this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await PermissionHelper.HasPermAsync(ctx.Planet.MyMember, [PlanetPermissions.Kick]))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "I don't have permission to kick members.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.Message.Mentions?.Any() != true && ctx.Args.Length < 1)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Mention a member or enter their ID to kick them.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long? targetId = ctx.Message.Mentions?.Any() == true ? ctx.Message.Mentions.First().TargetId : long.TryParse(ctx.Args.ElementAtOrDefault(0), out long parsed) ? parsed : null;
|
||||||
|
if (targetId is null) {await MessageHelper.ReplyAsync(ctx, "Could not find member."); return;};
|
||||||
|
|
||||||
|
PlanetMember offender = await ctx.Planet.FetchMemberAsync(targetId.Value);
|
||||||
|
if (offender is null) {await MessageHelper.ReplyAsync(ctx, "Could not find member."); return;};
|
||||||
|
|
||||||
|
string reason = string.Join(" ", ctx.Args.Skip(1));
|
||||||
|
if (string.IsNullOrWhiteSpace(reason)) reason = "No reason provided";
|
||||||
|
|
||||||
|
EmbedBuilder p = new EmbedBuilder()
|
||||||
|
.AddPage("Member Kick", DateTime.UtcNow.ToString())
|
||||||
|
.WithTitleStyles(new TextColor("#ff9900"))
|
||||||
|
.WithFooterStyles(new TextColor("#555555"))
|
||||||
|
.AddRow()
|
||||||
|
.AddText("Offender", offender.User.NameAndTag)
|
||||||
|
.WithStyles(new TextColor("#ff9900"))
|
||||||
|
.CloseRow()
|
||||||
|
.AddRow()
|
||||||
|
.AddText("Issuer", ctx.Member.User.NameAndTag)
|
||||||
|
.WithStyles(new TextColor("#ff9900"))
|
||||||
|
.CloseRow()
|
||||||
|
.AddText("Reason", reason)
|
||||||
|
.WithStyles(new TextColor("#ff9900"))
|
||||||
|
.CloseRow();
|
||||||
|
|
||||||
|
EmbedBuilder d = new EmbedBuilder()
|
||||||
|
.AddPage("Kicked from Planet", DateTime.UtcNow.ToString())
|
||||||
|
.WithTitleStyles(new TextColor("#ff9900"))
|
||||||
|
.WithFooterStyles(new TextColor("#555555"))
|
||||||
|
.AddRow()
|
||||||
|
.AddText("Planet (ID)", $"{ctx.Planet.Name} ({ctx.Planet.Id})")
|
||||||
|
.WithStyles(new TextColor("#ff9900"))
|
||||||
|
.CloseRow()
|
||||||
|
.AddRow()
|
||||||
|
.AddText("Reason", reason)
|
||||||
|
.WithStyles(new TextColor("#ff9900"))
|
||||||
|
.CloseRow();
|
||||||
|
|
||||||
|
await offender.User.SendDirectMessageAsync(ctx.Client, null, d.embed);
|
||||||
|
await offender.DeleteAsync();
|
||||||
|
await MessageHelper.ReplyAsync(ctx, null, p.embed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
99
SkyBot/Commands/Mods/Restrict.cs
Normal file
99
SkyBot/Commands/Mods/Restrict.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using SkyBot.Services;
|
||||||
|
using Valour.Shared.Authorization;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Restrict : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "restrict";
|
||||||
|
public string[] Aliases => ["channelrestrict", "cr"];
|
||||||
|
public string Description => "Enable or disable command categories or specific commands in this channel.";
|
||||||
|
public string Category => "Mod";
|
||||||
|
public string Usage => "restrict <disable|enable|list> <category> <all|commandname>";
|
||||||
|
public string[] SubCommands => ["disable", "enable", "list"];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(ctx.Member) && !await PermissionHelper.HasPermAsync(ctx.Member, [ChatChannelPermissions.ManageChannel], ctx.Channel))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You need the **Manage Channel** permission to use this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string sub = ctx.Args.ElementAtOrDefault(0)?.ToLower() ?? "";
|
||||||
|
|
||||||
|
if (sub == "list")
|
||||||
|
{
|
||||||
|
var restrictions = ChannelRestrictionService.GetRestrictions(ctx.Channel.Id);
|
||||||
|
if (restrictions.Count == 0)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "No restrictions set for this channel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = restrictions.Select(k =>
|
||||||
|
k.StartsWith("cat:") ? $"Category: **{k[4..].ToTitleCase()}**" :
|
||||||
|
k.StartsWith("cmd:") ? $"Command: `{k[4..]}`" : k);
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Restrictions in this channel:\n{string.Join("\n", lines)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub is not ("disable" or "enable"))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Usage: `{Config.Prefix}{Usage}`");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? category = ctx.Args.ElementAtOrDefault(1)?.ToLower();
|
||||||
|
string? target = ctx.Args.ElementAtOrDefault(2)?.ToLower();
|
||||||
|
|
||||||
|
if (category is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Please specify a category. Available: {string.Join(", ", CommandRegistry.Categories.Keys)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CommandRegistry.Categories.ContainsKey(category))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Unknown category `{category}`. Available: {string.Join(", ", CommandRegistry.Categories.Keys)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool disable = sub == "disable";
|
||||||
|
|
||||||
|
if (target is null or "all")
|
||||||
|
{
|
||||||
|
string key = ChannelRestrictionService.CategoryKey(category);
|
||||||
|
bool changed = disable
|
||||||
|
? await ChannelRestrictionService.DisableAsync(ctx.Channel.Id, key)
|
||||||
|
: await ChannelRestrictionService.EnableAsync(ctx.Channel.Id, key);
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
await MessageHelper.ReplyAsync(ctx, disable ? $"**{category.ToTitleCase()}** commands are already disabled here." : $"**{category.ToTitleCase()}** commands are already enabled here.");
|
||||||
|
else
|
||||||
|
await MessageHelper.ReplyAsync(ctx, disable ? $"Disabled all **{category.ToTitleCase()}** commands in this channel." : $"Enabled all **{category.ToTitleCase()}** commands in this channel.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!CommandRegistry.Commands.ContainsKey(target))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Unknown command `{target}`.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string key = ChannelRestrictionService.CommandKey(target);
|
||||||
|
bool changed = disable
|
||||||
|
? await ChannelRestrictionService.DisableAsync(ctx.Channel.Id, key)
|
||||||
|
: await ChannelRestrictionService.EnableAsync(ctx.Channel.Id, key);
|
||||||
|
|
||||||
|
if (!changed)
|
||||||
|
await MessageHelper.ReplyAsync(ctx, disable ? $"`{target}` is already disabled here." : $"`{target}` is already enabled here.");
|
||||||
|
else
|
||||||
|
await MessageHelper.ReplyAsync(ctx, disable ? $"Disabled `{target}` in this channel." : $"Enabled `{target}` in this channel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
SkyBot/Commands/RP/Emote.cs
Normal file
163
SkyBot/Commands/RP/Emote.cs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Emote : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "emote";
|
||||||
|
public string[] Aliases => ["e", "action"];
|
||||||
|
public string Description => "RP emote actions.";
|
||||||
|
public string Category => "RP";
|
||||||
|
public string Usage => "emote <action> [@user]";
|
||||||
|
public string[] SubCommands => ["angry", "baka", "bite", "blowkiss", "blush", "bonk", "carry", "clap", "cry", "cuddle", "dance", "facepalm", "happy", "holdhand", "hug", "kiss", "laugh", "lurk", "nom", "nya", "pat", "poke", "pout", "punch", "run", "shocked", "sleep", "smug", "spin", "tableflip", "teehee", "tickle", "wave", "wink", "yawn"];
|
||||||
|
|
||||||
|
private static readonly HttpClient _http = new()
|
||||||
|
{
|
||||||
|
DefaultRequestHeaders = { { "User-Agent", "SkyBot/1.0 (https://skyjoshua.xyz, https://valour.gg)" } }
|
||||||
|
};
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
switch (ctx.Args.ElementAtOrDefault(0)?.ToLower())
|
||||||
|
{
|
||||||
|
case "angry": await SendAction(ctx, "angry", "{0} is angry at {1}! 😠", "{0} is angry! 😠", false); break;
|
||||||
|
case "baka": await SendAction(ctx, "baka", "{0} calls {1} a baka!", "Please mention someone who is a baka!", true); break;
|
||||||
|
case "bite": await SendAction(ctx, "bite", "{0} bites {1}!", "Please mention someone to bite!", true); break;
|
||||||
|
case "blowkiss": await SendAction(ctx, "blowkiss", "{0} blows a kiss to {1}!", "Please mention someone to blow a kiss too!", true); break;
|
||||||
|
case "blush": await SendAction(ctx, "blush", "{0} is blushing at {1}!", "{0} is blushing!", false); break;
|
||||||
|
case "bonk": await SendAction(ctx, "bonk", "{0} bonks {1}!", "Please mention someone to bonk!", true); break;
|
||||||
|
case "carry": await SendAction(ctx, "carry", "{0} carries {1}!", "Please mention someone to carry!", true); break;
|
||||||
|
case "clap": await SendAction(ctx, "clap", "{0} claps for {1}!", "{0} is clapping!", false); break;
|
||||||
|
case "cry": await SendAction(ctx, "cry", "{0} is crying because of {1}!", "{0} is crying!", false); break;
|
||||||
|
case "cuddle": await SendAction(ctx, "cuddle", "{0} cuddles with {1}!", "Please mention someone to cuddle!", true); break;
|
||||||
|
case "dance": await SendAction(ctx, "dance", "{0} dances with {1}!", "{0} is dancing!", false); break;
|
||||||
|
case "facepalm": await SendAction(ctx, "facepalm", "{0} facepalms at {1}!", "{0} facepalms!", false); break;
|
||||||
|
case "happy": await SendAction(ctx, "happy", "{0} is happy with {1}!", "{0} is happy!", false); break;
|
||||||
|
case "holdhand": await SendAction(ctx, "handhold", "{0} holds hands with {1}!", "Please mention someone to hold hands with!", true); break;
|
||||||
|
case "hug": await SendAction(ctx, "hug", "{0} hugs {1}!", "Please mention someone to hug!", true); break;
|
||||||
|
case "kiss": await SendAction(ctx, "kiss", "{0} kisses {1}!", "Please mention someone to kiss!", true); break;
|
||||||
|
case "laugh": await SendAction(ctx, "laugh", "{0} laughs at {1}!", "{0} is laughing!", false); break;
|
||||||
|
case "lurk": await SendAction(ctx, "lurk", "{0} lurks around {1}!", "{0} is lurking!", false); break;
|
||||||
|
case "nom": await SendAction(ctx, "nom", "{0} noms on {1}!", "{0} is nomming!", false); break;
|
||||||
|
case "nya": await SendAction(ctx, "nya", "{0} nyas at {1}!", "{0} nyas!", false); break;
|
||||||
|
case "pat": await SendAction(ctx, "pat", "{0} gives {1} headpats!", "Please mention someone to give heatpats too!", true); break;
|
||||||
|
case "poke": await SendAction(ctx, "poke", "{0} pokes {1}!", "Please mention someone to poke!", true); break;
|
||||||
|
case "pout": await SendAction(ctx, "pout", "{0} pouts at {1}!", "{0} is pouting!", false); break;
|
||||||
|
case "punch": await SendAction(ctx, "punch", "{0} punches {1}!", "Please mention someone to punch!", true); break;
|
||||||
|
case "run": await SendAction(ctx, "run", "{0} runs away from {1}!", "{0} runs away!", false); break;
|
||||||
|
case "shocked": await SendAction(ctx, "shocked", "{0} is shocked by {1}!", "{0} is shocked!", false); break;
|
||||||
|
case "sleep": await SendAction(ctx, "sleep", "{0} falls asleep on {1}!", "{0} is sleeping! zzz...", false); break;
|
||||||
|
case "smug": await SendAction(ctx, "smug", "{0} looks smug at {1}!", "{0} is feeling smug!", false); break;
|
||||||
|
case "spin": await SendAction(ctx, "spin", "{0} spins {1} around!", "{0} is spinning!", false); break;
|
||||||
|
case "tableflip": await SendAction(ctx, "tableflip", "{0} flips the table at {1}! (╯°□°)╯︵ ┻━┻", "{0} flips the table! (╯°□°)╯︵ ┻━┻", false); break;
|
||||||
|
case "teehee": await SendAction(ctx, "teehee", "{0} teehees at {1}!", "{0} teehees~", false); break;
|
||||||
|
case "tickle": await SendAction(ctx, "tickle", "{0} tickles {1}!", "Please mention someone to tickle!", true); break;
|
||||||
|
case "wave": await SendAction(ctx, "wave", "{0} waves at {1}!", "{0} waves!", false); break;
|
||||||
|
case "wink": await SendAction(ctx, "wink", "{0} winks at {1}! ;)", "{0} winks! ;)", false); break;
|
||||||
|
case "yawn": await SendAction(ctx, "yawn", "{0} yawns at {1}!", "{0} is yawning!", false); break;
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Unknown emote. Usage: `{Config.Prefix}emote <action>`\nActions: {string.Join(", ", SubCommands)}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendAction(CommandContext ctx, string apiAction, string withTarget, string withoutTarget, bool requiresTarget = false)
|
||||||
|
{
|
||||||
|
string? target = null;
|
||||||
|
if (ctx.Message.ReplyToId is not null)
|
||||||
|
{
|
||||||
|
var replied = await ctx.Message.FetchReplyMessageAsync();
|
||||||
|
if (replied is not null)
|
||||||
|
{
|
||||||
|
var author = await replied.FetchAuthorAsync();
|
||||||
|
if (author is not null)
|
||||||
|
target = author.Name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target is null && ctx.Args.Length > 1)
|
||||||
|
target = string.Join(" ", ctx.Args[1..]);
|
||||||
|
|
||||||
|
if (requiresTarget && target is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, withoutTarget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.Channel.SendIsTyping();
|
||||||
|
|
||||||
|
string json;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
json = await _http.GetStringAsync($"https://nekos.best/api/v2/{apiAction}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Could not fetch a {apiAction} gif. Try again later.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
string gifUrl = doc.RootElement.GetProperty("results")[0].GetProperty("url").GetString()!;
|
||||||
|
|
||||||
|
byte[] gifBytes;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
gifBytes = await _http.GetByteArrayAsync(gifUrl);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Could not download the {apiAction} gif. Try again later.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int width = 0, height = 0;
|
||||||
|
if (gifBytes.Length >= 10)
|
||||||
|
{
|
||||||
|
width = gifBytes[6] | (gifBytes[7] << 8);
|
||||||
|
height = gifBytes[8] | (gifBytes[9] << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
string cdnUrl;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var form = new MultipartFormDataContent();
|
||||||
|
using var fileContent = new ByteArrayContent(gifBytes);
|
||||||
|
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/gif");
|
||||||
|
form.Add(fileContent, "file", $"{apiAction}.gif");
|
||||||
|
|
||||||
|
var uploadResult = await ctx.Planet.Node.PostMultipartDataWithResponse<string>("upload/image", form);
|
||||||
|
if (!uploadResult.Success)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Could not upload the {apiAction} gif. Try again later.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cdnUrl = uploadResult.Data!;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Could not upload the {apiAction} gif. Try again later.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string sender = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
string text = target is not null
|
||||||
|
? string.Format(withTarget, sender, target)
|
||||||
|
: string.Format(withoutTarget, sender);
|
||||||
|
|
||||||
|
var attachment = new MessageAttachment(MessageAttachmentType.Image)
|
||||||
|
{
|
||||||
|
Location = cdnUrl,
|
||||||
|
MimeType = "image/gif",
|
||||||
|
FileName = $"{apiAction}.gif",
|
||||||
|
Width = width,
|
||||||
|
Height = height
|
||||||
|
};
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, text, attachments: [attachment]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
SkyBot/Commands/RP/Marriage.cs
Normal file
216
SkyBot/Commands/RP/Marriage.cs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using SkyBot.Services;
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Marriage : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "marriage";
|
||||||
|
public string[] Aliases => ["marry"];
|
||||||
|
public string Description => "Marriage system — propose, check status, or divorce.";
|
||||||
|
public string Category => "RP";
|
||||||
|
public string Usage => "marriage <action>";
|
||||||
|
public string[] SubCommands => ["propose", "status", "divorce", "force"];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
switch (ctx.Args.ElementAtOrDefault(0)?.ToLower())
|
||||||
|
{
|
||||||
|
case "propose":
|
||||||
|
await HandlePropose(ctx);
|
||||||
|
break;
|
||||||
|
case "force":
|
||||||
|
await HandleForce(ctx);
|
||||||
|
break;
|
||||||
|
case "divorce":
|
||||||
|
await HandleDivorce(ctx);
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
await HandleStatus(ctx);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Usage: `{Config.Prefix}marriage <propose|status|divorce>`");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandlePropose(CommandContext ctx)
|
||||||
|
{
|
||||||
|
long propserId = ctx.Member.UserId;
|
||||||
|
string sender = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
|
||||||
|
long? targetUserId = null;
|
||||||
|
string? targetMention = null;
|
||||||
|
|
||||||
|
if (ctx.Message.ReplyToId is not null)
|
||||||
|
{
|
||||||
|
var replied = await ctx.Message.FetchReplyMessageAsync();
|
||||||
|
if (replied is null) return;
|
||||||
|
var author = await replied.FetchAuthorAsync();
|
||||||
|
if (author is null) return;
|
||||||
|
targetUserId = author.Id;
|
||||||
|
targetMention = $"«@u-{author.Id}»";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId is null && ctx.Message.Mentions is not null && ctx.Message.Mentions.Any())
|
||||||
|
{
|
||||||
|
long memberId = ctx.Message.Mentions.First().TargetId;
|
||||||
|
var mentioned = await ctx.Planet.FetchMemberAsync(memberId);
|
||||||
|
if (mentioned is null) return;
|
||||||
|
targetUserId = mentioned.UserId;
|
||||||
|
targetMention = $"«@u-{mentioned.UserId}»";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUserId is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Please reply to someone's message or mention them. Usage: `{Config.Prefix}marriage propose <reply|@user>");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = MarriageService.Propose(propserId, targetUserId.Value);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case MarriageService.ProposeResult.SelfPropose:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You can't propose to yourself.");
|
||||||
|
break;
|
||||||
|
case MarriageService.ProposeResult.AlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"You are already married, {sender}. You'd need to `{Config.Prefix}marriage divorce` first.");
|
||||||
|
break;
|
||||||
|
case MarriageService.ProposeResult.TargetAlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"{targetMention} is already married.");
|
||||||
|
break;
|
||||||
|
case MarriageService.ProposeResult.AlreadyProposed:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"You've already proposed to {targetMention}! Waiting for thsir reponse");
|
||||||
|
break;
|
||||||
|
case MarriageService.ProposeResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💍 {sender} gets down on one knee and proposes to {targetMention}\n{targetMention}, type `{Config.Prefix}accept` or `{Config.Prefix}decline`. This proposal expires in 5 minutes.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleDivorce(CommandContext ctx)
|
||||||
|
{
|
||||||
|
long userId = ctx.Member.UserId;
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
|
||||||
|
long? partnerId = MarriageService.GetMarriage(userId)?.SpouseId;
|
||||||
|
if (partnerId is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"You're not married, {name}.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MarriageService.RequestDivorceConfirmation(userId);
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Are you sure you want to divorce «@u-{partnerId}»?\nType `{Config.Prefix}confirm` within 60 seconds to confirm, or `{Config.Prefix}cancel` to cancel.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleStatus(CommandContext ctx)
|
||||||
|
{
|
||||||
|
long userId = ctx.Member.UserId;
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
|
||||||
|
if (ctx.Message.Mentions is not null && ctx.Message.Mentions.Any())
|
||||||
|
{
|
||||||
|
long memberId = ctx.Message.Mentions.First().TargetId;
|
||||||
|
var mentioned = await ctx.Planet.FetchMemberAsync(memberId);
|
||||||
|
if (mentioned is null) return;
|
||||||
|
userId = mentioned.UserId;
|
||||||
|
name = mentioned.Nickname ?? mentioned.User?.Name ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
MarriageModel? marriage = MarriageService.GetMarriage(userId);
|
||||||
|
|
||||||
|
if (marriage is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"{name} is not currently married.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long partnerId = marriage.SpouseId;
|
||||||
|
DateTimeOffset dt = DateTimeOffset.FromUnixTimeMilliseconds(marriage.MarriedAt);
|
||||||
|
string marriedAt = $"{dt.OrdinalDay()} {dt:MMMM yyyy HH:mm} UTC";
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"{name} is married to «@u-{partnerId}»!\nThey got married: `{marriedAt}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleForce(CommandContext ctx)
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(ctx.Member))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You don't have permission to use this command.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string action = ctx.Args.ElementAtOrDefault(1)?.ToLower() ?? "";
|
||||||
|
var mentions = ctx.Message.Mentions ?? [];
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case "marry":
|
||||||
|
{
|
||||||
|
var member1 = await ctx.Planet.FetchMemberAsync(mentions[0].TargetId);
|
||||||
|
var member2 = await ctx.Planet.FetchMemberAsync(mentions[1].TargetId);
|
||||||
|
|
||||||
|
if (member1 is null || member2 is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Could not find one or both of those members.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await MarriageService.ForceMarryAsync(member1.UserId, member2.UserId);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case MarriageService.ForceMarryResult.SameUser:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You can't marry someone to themselves!");
|
||||||
|
break;
|
||||||
|
case MarriageService.ForceMarryResult.User1AlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"«@u-{member1.UserId}» is already married.");
|
||||||
|
break;
|
||||||
|
case MarriageService.ForceMarryResult.User2AlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"«@u-{member2.UserId}» is already married.");
|
||||||
|
break;
|
||||||
|
case MarriageService.ForceMarryResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💒 «@u-{member1.UserId}» and «@u-{member2.UserId}» are now married! 🎉");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "divorce":
|
||||||
|
{
|
||||||
|
if (mentions.Count < 1)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Please mention a user. Usage: `{Config.Prefix}marriage force divorce @user`");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var member = await ctx.Planet.FetchMemberAsync(mentions[0].TargetId);
|
||||||
|
|
||||||
|
if (member is null)
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "Could not find that member.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (result, partnerId) = await MarriageService.ForceDivorceAsync(member.UserId);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case MarriageService.DivorceResult.NotMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"«@u-{member.UserId}» is not married.");
|
||||||
|
break;
|
||||||
|
case MarriageService.DivorceResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💔 «@u-{member.UserId}» and «@u-{partnerId}» have been forcefully divorced.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Usage: `{Config.Prefix}marriage force marry @user1 @user2` or `{Config.Prefix}marriage force divorce @user`");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SkyBot/Commands/Utils/Accept.cs
Normal file
23
SkyBot/Commands/Utils/Accept.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Accept : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "accept";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Accepts a pending action.";
|
||||||
|
public string Category => "Utils";
|
||||||
|
public string Usage => "accept";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
foreach (var handler in PendingActionRegistry.AcceptHandlers)
|
||||||
|
if (await handler(ctx)) return;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You have nothing pending to accept.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SkyBot/Commands/Utils/Cancel.cs
Normal file
23
SkyBot/Commands/Utils/Cancel.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Cancel : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "cancel";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Cancels a pending action.";
|
||||||
|
public string Category => "Utils";
|
||||||
|
public string Usage => "cancel";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
foreach (var handler in PendingActionRegistry.CancelHandlers)
|
||||||
|
if (await handler(ctx)) return;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You have nothing pending to cancel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SkyBot/Commands/Utils/Confirm.cs
Normal file
23
SkyBot/Commands/Utils/Confirm.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Confirm : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "confirm";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Confirms a pending action.";
|
||||||
|
public string Category => "Utils";
|
||||||
|
public string Usage => "confirm";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
foreach (var handler in PendingActionRegistry.ConfirmHandlers)
|
||||||
|
if (await handler(ctx)) return;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You have nothing pending to confirm.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SkyBot/Commands/Utils/Decline.cs
Normal file
23
SkyBot/Commands/Utils/Decline.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Commands
|
||||||
|
{
|
||||||
|
public class Decline : ICommand
|
||||||
|
{
|
||||||
|
public string Name => "decline";
|
||||||
|
public string[] Aliases => [];
|
||||||
|
public string Description => "Declines a pending action.";
|
||||||
|
public string Category => "Utils";
|
||||||
|
public string Usage => "decline";
|
||||||
|
public string[] SubCommands => [];
|
||||||
|
|
||||||
|
public async Task Execute(CommandContext ctx)
|
||||||
|
{
|
||||||
|
foreach (var handler in PendingActionRegistry.DeclineHandlers)
|
||||||
|
if (await handler(ctx)) return;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You have nothing pending to decline.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SkyBot/Config.cs
Normal file
8
SkyBot/Config.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace SkyBot
|
||||||
|
{
|
||||||
|
public static class Config {
|
||||||
|
public static readonly long OwnerId = 15652354820931584;
|
||||||
|
public static readonly string Prefix = "s/";
|
||||||
|
public static readonly string SourceLink = "https://git.skyjoshua.xyz/SkyJoshua/SkyBot";
|
||||||
|
}
|
||||||
|
}
|
||||||
52
SkyBot/Helpers/EmbedStyles.cs
Normal file
52
SkyBot/Helpers/EmbedStyles.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds.Styles.Basic;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class EmbedStyles
|
||||||
|
{
|
||||||
|
private static readonly Size Radius = new(Unit.Pixels, 8);
|
||||||
|
private static readonly Size PadV = new(Unit.Pixels, 2);
|
||||||
|
private static readonly Size PadH = new(Unit.Pixels, 6);
|
||||||
|
private static readonly Size FitContent = new(Unit.FitContent);
|
||||||
|
|
||||||
|
|
||||||
|
public static StyleBase[] LabelText => [
|
||||||
|
new TextColor("#a0a0b8"),
|
||||||
|
new FontWeight(600),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static StyleBase[] CategoryBtn => [
|
||||||
|
new BackgroundColor("#5865F2"),
|
||||||
|
new TextColor("#ffffff"),
|
||||||
|
new BorderRadius(Radius),
|
||||||
|
new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV),
|
||||||
|
new FontWeight(600),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static StyleBase[] CommandBtn => [
|
||||||
|
new BackgroundColor("#2b2d3e"),
|
||||||
|
new TextColor("#e0e0f0"),
|
||||||
|
new BorderRadius(Radius),
|
||||||
|
new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV),
|
||||||
|
new Width(FitContent),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static StyleBase[] NavBtn => [
|
||||||
|
new BackgroundColor("#1e1f2e"),
|
||||||
|
new TextColor("#a0a0b8"),
|
||||||
|
new BorderRadius(Radius),
|
||||||
|
new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV),
|
||||||
|
new Width(FitContent),
|
||||||
|
];
|
||||||
|
|
||||||
|
public static StyleBase[] BackBtn => [
|
||||||
|
new BackgroundColor("#5865F2"),
|
||||||
|
new TextColor("#ffffff"),
|
||||||
|
new BorderRadius(Radius),
|
||||||
|
new Padding(left: PadH, right: PadH, top: PadV, bottom: PadV),
|
||||||
|
new FontWeight(600),
|
||||||
|
new Width(FitContent),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
85
SkyBot/Helpers/MessageHelper.cs
Normal file
85
SkyBot/Helpers/MessageHelper.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class MessageHelper
|
||||||
|
{
|
||||||
|
public static string Mention(this PlanetMember member) => $"«@m-{member.Id}»";
|
||||||
|
public static string Mention(this User user) => $"«@u-{user.Id}»";
|
||||||
|
public static string ToTitleCase(this string str) => CultureInfo.CurrentCulture.TextInfo.ToTitleCase(str);
|
||||||
|
|
||||||
|
public static string OrdinalDay(this DateTimeOffset dt)
|
||||||
|
{
|
||||||
|
string suffix = (dt.Day % 100) switch
|
||||||
|
{
|
||||||
|
11 or 12 or 13 => "th",
|
||||||
|
_ => (dt.Day % 10) switch
|
||||||
|
{
|
||||||
|
1 => "st",
|
||||||
|
2 => "nd",
|
||||||
|
3 => "rd",
|
||||||
|
_ => "th"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return $"{dt.Day}{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<TaskResult<Message>> ReplyAsync(CommandContext ctx, string? content, Embed? embed = null, bool reply = false, IEnumerable<MessageAttachment>? attachments = null)
|
||||||
|
{
|
||||||
|
long? replyToId = reply ? ctx.Message.ReplyToId : ctx.Message.Id;
|
||||||
|
|
||||||
|
Message msg = new(ctx.Client)
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
ChannelId = ctx.Channel.Id,
|
||||||
|
PlanetId = ctx.Planet.Id,
|
||||||
|
AuthorUserId = ctx.Client.Me.Id,
|
||||||
|
AuthorMemberId = ctx.Channel.Planet?.MyMember.Id,
|
||||||
|
ReplyToId = replyToId,
|
||||||
|
Fingerprint = Guid.NewGuid().ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (embed is not null)
|
||||||
|
msg.SetEmbed(embed);
|
||||||
|
|
||||||
|
if (attachments is not null)
|
||||||
|
{
|
||||||
|
msg.Attachments ??= [];
|
||||||
|
msg.Attachments.AddRange(attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ctx.Client.MessageService.SendMessage(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static async Task<TaskResult<Message>> EditAsync(Channel channel, Message message, string content)
|
||||||
|
{
|
||||||
|
message.Content = content;
|
||||||
|
return await channel.Planet.Node.PutAsyncWithResponse<Message>($"api/messages/{message.Id}", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime? ParseDuration(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input)) return null;
|
||||||
|
|
||||||
|
var unit = input[^1];
|
||||||
|
if (!int.TryParse(input[..^1], out int value)) return null;
|
||||||
|
|
||||||
|
return unit switch
|
||||||
|
{
|
||||||
|
'm' => DateTime.UtcNow.AddMinutes(value),
|
||||||
|
'h' => DateTime.UtcNow.AddHours(value),
|
||||||
|
'd' => DateTime.UtcNow.AddDays(value),
|
||||||
|
'w' => DateTime.UtcNow.AddDays(value * 7),
|
||||||
|
'M' => DateTime.UtcNow.AddMonths(value),
|
||||||
|
'y' => DateTime.UtcNow.AddYears(value),
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
97
SkyBot/Helpers/PendingActionRegistry.cs
Normal file
97
SkyBot/Helpers/PendingActionRegistry.cs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
using SkyBot.Models;
|
||||||
|
using SkyBot.Services;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class PendingActionRegistry
|
||||||
|
{
|
||||||
|
public static readonly List<Func<CommandContext, Task<bool>>> ConfirmHandlers =
|
||||||
|
[
|
||||||
|
TryConfirmDivorce,
|
||||||
|
// Register new confirm handlers here
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly List<Func<CommandContext, Task<bool>>> CancelHandlers =
|
||||||
|
[
|
||||||
|
TryCancelDivorce,
|
||||||
|
// Register new cancel handlers here
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly List<Func<CommandContext, Task<bool>>> AcceptHandlers =
|
||||||
|
[
|
||||||
|
TryAcceptProposal,
|
||||||
|
// Register new accept handlers here
|
||||||
|
];
|
||||||
|
|
||||||
|
public static readonly List<Func<CommandContext, Task<bool>>> DeclineHandlers =
|
||||||
|
[
|
||||||
|
TryDeclineProposal,
|
||||||
|
// Register new decline handlers here
|
||||||
|
];
|
||||||
|
|
||||||
|
private static async Task<bool> TryConfirmDivorce(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
var (result, partnerId) = await MarriageService.DivorceAsync(ctx.Member.UserId, confirmed: true);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case MarriageService.DivorceResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💔 {name} and «@u-{partnerId}» have divorced.");
|
||||||
|
return true;
|
||||||
|
case MarriageService.DivorceResult.ConfirmationExpired:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Your divorce confirmation expired. Run `{Config.Prefix}marriage divorce` again to start over.");
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> TryCancelDivorce(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
|
||||||
|
if (!MarriageService.CancelDivorce(ctx.Member.UserId))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Divorce cancelled, {name}.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> TryAcceptProposal(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
var (result, proposerId) = await MarriageService.AcceptAsync(ctx.Member.UserId);
|
||||||
|
|
||||||
|
switch (result)
|
||||||
|
{
|
||||||
|
case MarriageService.AcceptResult.Ok:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💒 {name} has accepted «@u-{proposerId}»'s proposal! They are now married! 🎉");
|
||||||
|
return true;
|
||||||
|
case MarriageService.AcceptResult.Expired:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"The proposal from «@u-{proposerId}» has expired.");
|
||||||
|
return true;
|
||||||
|
case MarriageService.AcceptResult.ProposerAlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"«@u-{proposerId}» is already married!");
|
||||||
|
return true;
|
||||||
|
case MarriageService.AcceptResult.AcceptorAlreadyMarried:
|
||||||
|
await MessageHelper.ReplyAsync(ctx, "You are already married!");
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> TryDeclineProposal(CommandContext ctx)
|
||||||
|
{
|
||||||
|
string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown";
|
||||||
|
var (result, proposerId) = MarriageService.Decline(ctx.Member.UserId);
|
||||||
|
|
||||||
|
if (result == MarriageService.DeclineResult.NoPendingProposal)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"💔 {name} has declined «@u-{proposerId}»'s proposal.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
SkyBot/Helpers/PendingConfirmations.cs
Normal file
29
SkyBot/Helpers/PendingConfirmations.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class PendingConfirmations
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<long, TaskCompletionSource<bool>> pending = new();
|
||||||
|
public static bool IsPending(long userId) => pending.ContainsKey(userId);
|
||||||
|
|
||||||
|
public static Task<bool> WaitAsync(long userId, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<bool>();
|
||||||
|
pending[userId] = tcs;
|
||||||
|
_ = Task.Delay(timeout).ContinueWith(_ => tcs.TrySetResult(false));
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryComplete(long userId, bool confirmed)
|
||||||
|
{
|
||||||
|
if (pending.TryRemove(userId, out var tcs))
|
||||||
|
{
|
||||||
|
return tcs.TrySetResult(confirmed);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
42
SkyBot/Helpers/PermissionHelper.cs
Normal file
42
SkyBot/Helpers/PermissionHelper.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared.Authorization;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class PermissionHelper
|
||||||
|
{
|
||||||
|
public static async Task<bool> HasPermAsync(PlanetMember member, Permission[] permissions, Channel? channel = null, bool requireAll = false)
|
||||||
|
{
|
||||||
|
if (member is null) return false;
|
||||||
|
if (member.HasPermission(PlanetPermissions.FullControl)) return true;
|
||||||
|
if (member.Roles.Any(r => r.IsAdmin)) return true;
|
||||||
|
|
||||||
|
var results = new List<bool>();
|
||||||
|
|
||||||
|
foreach (var perm in permissions)
|
||||||
|
{
|
||||||
|
bool result = perm switch
|
||||||
|
{
|
||||||
|
PlanetPermission p => member.HasPermission(p),
|
||||||
|
ChatChannelPermission p => channel is not null
|
||||||
|
? await channel.HasPermissionAsync(member, p)
|
||||||
|
: throw new ArgumentNullException(nameof(channel), $"Channel is required for ChatChannelPermission '{p.Name}'"),
|
||||||
|
VoiceChannelPermission p => channel is not null
|
||||||
|
? await channel.HasPermissionAsync(member, p)
|
||||||
|
: throw new ArgumentNullException(nameof(channel), $"Channel is required for VoiceChannelPermission '{p.Name}'"),
|
||||||
|
_ => throw new ArgumentException($"Unsupported permission type: {perm.GetType().Name}")
|
||||||
|
};
|
||||||
|
|
||||||
|
results.Add(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireAll ? results.All(r => r) : results.Any(r => r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsOwner(PlanetMember member)
|
||||||
|
{
|
||||||
|
if (member is null) return false;
|
||||||
|
return member.UserId == Config.OwnerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
SkyBot/Helpers/PlanetHelper.cs
Normal file
33
SkyBot/Helpers/PlanetHelper.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class PlanetHelper
|
||||||
|
{
|
||||||
|
public static Task<TaskResult<PlanetBan>> BanAsync(this Planet planet, long targetUserId, string reason, DateTime? expires = null)
|
||||||
|
{
|
||||||
|
var ban = new PlanetBan(planet.Client)
|
||||||
|
{
|
||||||
|
PlanetId = planet.Id,
|
||||||
|
IssuerId = planet.Client.Me.Id,
|
||||||
|
TargetId = targetUserId,
|
||||||
|
Reason = reason,
|
||||||
|
TimeCreated = DateTime.UtcNow,
|
||||||
|
TimeExpires = expires
|
||||||
|
};
|
||||||
|
return ban.CreateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<PlanetBan?> FindBanAsync(this Planet planet, long targetUserId)
|
||||||
|
{
|
||||||
|
var engine = planet.GetBanQueryEngine();
|
||||||
|
await foreach (var ban in engine)
|
||||||
|
{
|
||||||
|
if (ban.TargetId == targetUserId)
|
||||||
|
return ban;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
SkyBot/Helpers/UserHelper.cs
Normal file
29
SkyBot/Helpers/UserHelper.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Sdk.Models.Messages.Embeds;
|
||||||
|
using Valour.Shared;
|
||||||
|
|
||||||
|
namespace SkyBot.Helpers
|
||||||
|
{
|
||||||
|
public static class UserHelper
|
||||||
|
{
|
||||||
|
public static async Task<TaskResult<Message>> SendDirectMessageAsync(this User user, ValourClient client, string? content, Embed? embed = null)
|
||||||
|
{
|
||||||
|
var channelResult = await client.PrimaryNode.GetJsonAsync<Channel>($"api/channels/direct/byUser/{user.Id}");
|
||||||
|
if (!channelResult.Success)
|
||||||
|
return new TaskResult<Message>(false, "Could not open DM channel.");
|
||||||
|
|
||||||
|
var msg = new Message(client)
|
||||||
|
{
|
||||||
|
Content = content,
|
||||||
|
ChannelId = channelResult.Data.Id,
|
||||||
|
AuthorUserId = client.Me.Id,
|
||||||
|
Fingerprint = Guid.NewGuid().ToString()
|
||||||
|
};
|
||||||
|
msg.SetEmbed(embed);
|
||||||
|
|
||||||
|
return await client.MessageService.SendMessage(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
SkyBot/Models/CommandContext.cs
Normal file
17
SkyBot/Models/CommandContext.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Models
|
||||||
|
{
|
||||||
|
public class CommandContext
|
||||||
|
{
|
||||||
|
public required ValourClient Client { get; set; }
|
||||||
|
public required ConcurrentDictionary<long, Channel> ChannelCache { get; set; }
|
||||||
|
public required PlanetMember Member { get; set; }
|
||||||
|
public required Message Message { get; set; }
|
||||||
|
public required Planet Planet { get; set; }
|
||||||
|
public required Channel Channel { get; set; }
|
||||||
|
public required string[] Args { get; set; }
|
||||||
|
};
|
||||||
|
};
|
||||||
13
SkyBot/Models/ICommand.cs
Normal file
13
SkyBot/Models/ICommand.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace SkyBot.Models
|
||||||
|
{
|
||||||
|
public interface ICommand
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
string[] Aliases { get; }
|
||||||
|
string Description { get; }
|
||||||
|
string Category { get; }
|
||||||
|
string Usage { get; }
|
||||||
|
string[] SubCommands { get; }
|
||||||
|
Task Execute(CommandContext ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
SkyBot/Models/MarriageModel.cs
Normal file
5
SkyBot/Models/MarriageModel.cs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
public class MarriageModel
|
||||||
|
{
|
||||||
|
public long SpouseId { get; set; }
|
||||||
|
public long MarriedAt {get; set; }
|
||||||
|
}
|
||||||
71
SkyBot/Services/BlacklistService.cs
Normal file
71
SkyBot/Services/BlacklistService.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class BlacklistService
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<long, bool> blacklist = new();
|
||||||
|
public static async Task InitialiseAsync()
|
||||||
|
{
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT UserId FROM Blacklist";
|
||||||
|
using SqliteDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while ( await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
long userId = (long)reader["UserId"];
|
||||||
|
blacklist[userId] = true;
|
||||||
|
}
|
||||||
|
Console.WriteLine($"BlacklistService initialised. Loaded {blacklist.Count} blacklisted users");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool GetBlacklisted(long userId)
|
||||||
|
{
|
||||||
|
return blacklist.TryGetValue(userId, out var blacklisted) ? blacklisted : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BlacklistResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
AlreadyBlacklisted
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<BlacklistResult> Blacklist(long userId)
|
||||||
|
{
|
||||||
|
if (GetBlacklisted(userId)) return BlacklistResult.AlreadyBlacklisted;
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT OR IGNORE INTO Blacklist (UserId, Bool) VALUES ($u, $b)";
|
||||||
|
cmd.Parameters.AddWithValue("$u", userId);
|
||||||
|
cmd.Parameters.AddWithValue("$b", true);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
blacklist[userId] = true;
|
||||||
|
|
||||||
|
return BlacklistResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum UnBlacklistResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
NotBlacklisted
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<UnBlacklistResult> UnBlacklist(long userId)
|
||||||
|
{
|
||||||
|
if (!GetBlacklisted(userId)) return UnBlacklistResult.NotBlacklisted;
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM Blacklist WHERE UserId = $u";
|
||||||
|
cmd.Parameters.AddWithValue("$u", userId);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
blacklist.TryRemove(userId, out _);
|
||||||
|
|
||||||
|
return UnBlacklistResult.Ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
SkyBot/Services/BotService.cs
Normal file
32
SkyBot/Services/BotService.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using DotNetEnv;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class BotService
|
||||||
|
{
|
||||||
|
public static async Task InitialiseBotAsync(
|
||||||
|
ValourClient client,
|
||||||
|
ConcurrentDictionary<long, Channel> channelCache,
|
||||||
|
ConcurrentDictionary<long, bool> initialisedPlanets
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Env.Load();
|
||||||
|
var token = Environment.GetEnvironmentVariable("TOKEN");
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) {Console.WriteLine($"TOKEN not set in .env"); return;}
|
||||||
|
|
||||||
|
var loginResult = await client.InitializeUser(token);
|
||||||
|
if (!loginResult.Success) {Console.WriteLine($"Login Failed: {loginResult.Message}"); return;}
|
||||||
|
Console.WriteLine($"Logged in as {client.Me.NameAndTag} (ID: {client.Me.Id})");
|
||||||
|
|
||||||
|
await PlanetService.InitialisePlanetsAsync(client, channelCache, initialisedPlanets);
|
||||||
|
client.PlanetService.JoinedPlanetsUpdated += async () => { await PlanetService.InitialisePlanetsAsync(client, channelCache, initialisedPlanets); };
|
||||||
|
|
||||||
|
client.MessageService.MessageReceived += async message => { await MessageService.Create(client, channelCache, message); };
|
||||||
|
client.MessageService.MessageDeleted += async message => { await MessageService.Delete(client, message); };
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
SkyBot/Services/ChannelRestrictionService.cs
Normal file
72
SkyBot/Services/ChannelRestrictionService.cs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class ChannelRestrictionService
|
||||||
|
{
|
||||||
|
// channelId -> set of disabled keys ("cat:rp" or "cmd:emote")
|
||||||
|
private static readonly ConcurrentDictionary<long, HashSet<string>> _restrictions = new();
|
||||||
|
|
||||||
|
public static async Task InitialiseAsync()
|
||||||
|
{
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT ChannelId, DisabledKey FROM ChannelRestrictions";
|
||||||
|
using SqliteDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
long channelId = (long)reader["ChannelId"];
|
||||||
|
string key = (string)reader["DisabledKey"];
|
||||||
|
_restrictions.GetOrAdd(channelId, _ => new HashSet<string>()).Add(key);
|
||||||
|
}
|
||||||
|
Console.WriteLine($"ChannelRestrictionService initialised. Loaded {_restrictions.Values.Sum(s => s.Count)} restrictions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsRestricted(long channelId, Models.ICommand command)
|
||||||
|
{
|
||||||
|
if (!_restrictions.TryGetValue(channelId, out var keys)) return false;
|
||||||
|
return keys.Contains(CategoryKey(command.Category)) || keys.Contains(CommandKey(command.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> DisableAsync(long channelId, string key)
|
||||||
|
{
|
||||||
|
var set = _restrictions.GetOrAdd(channelId, _ => new HashSet<string>());
|
||||||
|
lock (set)
|
||||||
|
{
|
||||||
|
if (!set.Add(key)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT OR IGNORE INTO ChannelRestrictions (ChannelId, DisabledKey) VALUES ($c, $k)";
|
||||||
|
cmd.Parameters.AddWithValue("$c", channelId);
|
||||||
|
cmd.Parameters.AddWithValue("$k", key);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<bool> EnableAsync(long channelId, string key)
|
||||||
|
{
|
||||||
|
if (!_restrictions.TryGetValue(channelId, out var set)) return false;
|
||||||
|
lock (set)
|
||||||
|
{
|
||||||
|
if (!set.Remove(key)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM ChannelRestrictions WHERE ChannelId = $c AND DisabledKey = $k";
|
||||||
|
cmd.Parameters.AddWithValue("$c", channelId);
|
||||||
|
cmd.Parameters.AddWithValue("$k", key);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HashSet<string> GetRestrictions(long channelId) =>
|
||||||
|
_restrictions.TryGetValue(channelId, out var set) ? set : new HashSet<string>();
|
||||||
|
|
||||||
|
public static string CategoryKey(string category) => $"cat:{category.ToLower()}";
|
||||||
|
public static string CommandKey(string command) => $"cmd:{command.ToLower()}";
|
||||||
|
}
|
||||||
|
}
|
||||||
47
SkyBot/Services/ChannelService.cs
Normal file
47
SkyBot/Services/ChannelService.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
using Valour.Shared.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class ChannelService
|
||||||
|
{
|
||||||
|
public static async Task InitialiseChannelsAsync(
|
||||||
|
ConcurrentDictionary<long, Channel> channelCache,
|
||||||
|
Planet planet
|
||||||
|
)
|
||||||
|
{
|
||||||
|
foreach (var channel in planet.Channels)
|
||||||
|
{
|
||||||
|
channelCache[channel.Id] = channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
foreach (var channel in planet.Channels.Where(c => c.ChannelType == ChannelTypeEnum.PlanetChat))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await channel.OpenWithResult("SkyBot");
|
||||||
|
Console.WriteLine($"Realtime opened for: {planet.Name} (ID: {planet.Id}) -> {channel.Name} (ID: {channel.Id})");
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error opening realtime for {channel.Id}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"All channels opened for {planet.Name}");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task InitialiseChannelAsync(
|
||||||
|
ConcurrentDictionary<long, Channel> channelCache,
|
||||||
|
Channel channel
|
||||||
|
)
|
||||||
|
{
|
||||||
|
channelCache[channel.Id] = channel;
|
||||||
|
await channel.OpenWithResult("SkyBot");
|
||||||
|
Console.WriteLine($"Realtime opened for: {channel.Planet.Name} (ID: {channel.Planet.Id}) -> {channel.Name} (ID: {channel.Id})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
SkyBot/Services/DatabaseService.cs
Normal file
60
SkyBot/Services/DatabaseService.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class DatabaseService
|
||||||
|
{
|
||||||
|
private const string ConnectionString = "Data Source=database.db";
|
||||||
|
|
||||||
|
public static SqliteConnection GetConnection()
|
||||||
|
{
|
||||||
|
SqliteConnection connection = new SqliteConnection(ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task InitialiseAsync()
|
||||||
|
{
|
||||||
|
using SqliteConnection connection = GetConnection();
|
||||||
|
|
||||||
|
using (SqliteCommand cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS Marriages (
|
||||||
|
UserId1 INTEGER NOT NULL,
|
||||||
|
UserId2 INTEGER NOT NULL,
|
||||||
|
MarriedAt INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (UserID1, UserId2)
|
||||||
|
);
|
||||||
|
";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (SqliteCommand cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS Blacklist (
|
||||||
|
UserId INTEGER NOT NULL,
|
||||||
|
Bool BOOLEAN NOT NULL,
|
||||||
|
PRIMARY KEY (UserId, Bool)
|
||||||
|
);
|
||||||
|
";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (SqliteCommand cmd = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS ChannelRestrictions (
|
||||||
|
ChannelId INTEGER NOT NULL,
|
||||||
|
DisabledKey TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (ChannelId, DisabledKey)
|
||||||
|
);
|
||||||
|
";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("Database initialised.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
SkyBot/Services/MarriageService.cs
Normal file
219
SkyBot/Services/MarriageService.cs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class MarriageService
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<long, MarriageModel> marriages = new();
|
||||||
|
private static readonly ConcurrentDictionary<long, (long ProposerId, DateTime ExpiresAt)> pendingProposals = new();
|
||||||
|
private static readonly TimeSpan ProposalTimeout = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
|
public static async Task InitialiseAsync()
|
||||||
|
{
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT UserId1, UserId2, MarriedAt FROM Marriages";
|
||||||
|
using SqliteDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
long u1 = (long)reader["UserId1"];
|
||||||
|
long u2 = (long)reader["UserId2"];
|
||||||
|
long marriedAt = (long)reader["MarriedAt"];
|
||||||
|
marriages[u1] = new MarriageModel { SpouseId = u2, MarriedAt = marriedAt };
|
||||||
|
marriages[u2] = new MarriageModel { SpouseId = u1, MarriedAt = marriedAt };
|
||||||
|
}
|
||||||
|
Console.WriteLine($"MarriageService initialised. Loaded {marriages.Count / 2} marriages");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MarriageModel? GetMarriage(long userId)
|
||||||
|
{
|
||||||
|
return marriages.TryGetValue(userId, out var marriage) ? marriage : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ProposeResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
SelfPropose,
|
||||||
|
AlreadyMarried,
|
||||||
|
TargetAlreadyMarried,
|
||||||
|
AlreadyProposed
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProposeResult Propose(long proposerId, long targetId)
|
||||||
|
{
|
||||||
|
if (proposerId == targetId) return ProposeResult.SelfPropose;
|
||||||
|
if (marriages.ContainsKey(proposerId)) return ProposeResult.AlreadyMarried;
|
||||||
|
if (marriages.ContainsKey(targetId)) return ProposeResult.TargetAlreadyMarried;
|
||||||
|
|
||||||
|
if (pendingProposals.TryGetValue(targetId, out var existing) && existing.ProposerId == proposerId && existing.ExpiresAt > DateTime.UtcNow) return ProposeResult.AlreadyProposed;
|
||||||
|
|
||||||
|
pendingProposals[targetId] = (proposerId, DateTime.UtcNow.Add(ProposalTimeout));
|
||||||
|
return ProposeResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AcceptResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
NoPendingProposal,
|
||||||
|
Expired,
|
||||||
|
ProposerAlreadyMarried,
|
||||||
|
AcceptorAlreadyMarried
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(AcceptResult Result, long ProposerId)> AcceptAsync(long acceptorId)
|
||||||
|
{
|
||||||
|
if (!pendingProposals.TryRemove(acceptorId, out var proposal))
|
||||||
|
{
|
||||||
|
return (AcceptResult.NoPendingProposal, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposal.ExpiresAt <= DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
return (AcceptResult.Expired, proposal.ProposerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marriages.ContainsKey(proposal.ProposerId))
|
||||||
|
{
|
||||||
|
return (AcceptResult.ProposerAlreadyMarried, proposal.ProposerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marriages.ContainsKey(acceptorId))
|
||||||
|
{
|
||||||
|
return (AcceptResult.AcceptorAlreadyMarried, proposal.ProposerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
long u1 = Math.Min(acceptorId, proposal.ProposerId);
|
||||||
|
long u2 = Math.Max(acceptorId, proposal.ProposerId);
|
||||||
|
long marriedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT OR IGNORE INTO Marriages (UserId1, UserId2, MarriedAt) VALUES ($u1, $u2, $at)";
|
||||||
|
cmd.Parameters.AddWithValue("$u1", u1);
|
||||||
|
cmd.Parameters.AddWithValue("$u2", u2);
|
||||||
|
cmd.Parameters.AddWithValue("$at", marriedAt);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
marriages[acceptorId] = new MarriageModel { SpouseId = proposal.ProposerId, MarriedAt = marriedAt};
|
||||||
|
marriages[proposal.ProposerId] = new MarriageModel { SpouseId = acceptorId, MarriedAt = marriedAt};
|
||||||
|
|
||||||
|
return (AcceptResult.Ok, proposal.ProposerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeclineResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
NoPendingProposal
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (DeclineResult Result, long ProposerId) Decline(long acceptorId)
|
||||||
|
{
|
||||||
|
if (!pendingProposals.TryRemove(acceptorId, out var proposal))
|
||||||
|
{
|
||||||
|
return (DeclineResult.NoPendingProposal, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (DeclineResult.Ok, proposal.ProposerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ForceMarryResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
SameUser,
|
||||||
|
User1AlreadyMarried,
|
||||||
|
User2AlreadyMarried
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<ForceMarryResult> ForceMarryAsync(long userId1, long userId2)
|
||||||
|
{
|
||||||
|
if (userId1 == userId2) return ForceMarryResult.SameUser;
|
||||||
|
if (marriages.ContainsKey(userId1)) return ForceMarryResult.User1AlreadyMarried;
|
||||||
|
if (marriages.ContainsKey(userId2)) return ForceMarryResult.User2AlreadyMarried;
|
||||||
|
|
||||||
|
long u1 = Math.Min(userId1, userId2);
|
||||||
|
long u2 = Math.Max(userId1, userId2);
|
||||||
|
long marriedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT OR IGNORE INTO Marriages (UserId1, UserId2, MarriedAt) VALUES ($u1, $u2, $at)";
|
||||||
|
cmd.Parameters.AddWithValue("$u1", u1);
|
||||||
|
cmd.Parameters.AddWithValue("$u2", u2);
|
||||||
|
cmd.Parameters.AddWithValue("$at", marriedAt);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
marriages[userId1] = new MarriageModel { SpouseId = userId2, MarriedAt = marriedAt};
|
||||||
|
marriages[userId2] = new MarriageModel { SpouseId = userId1, MarriedAt = marriedAt};
|
||||||
|
|
||||||
|
return ForceMarryResult.Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly ConcurrentDictionary<long, DateTime> pendingDivorces = new();
|
||||||
|
private static readonly TimeSpan DivorceConfirmTimeout = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
public static bool RequestDivorceConfirmation(long userId)
|
||||||
|
{
|
||||||
|
if (!marriages.ContainsKey(userId)) return false;
|
||||||
|
pendingDivorces[userId] = DateTime.UtcNow.Add(DivorceConfirmTimeout);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool CancelDivorce(long userId)
|
||||||
|
{
|
||||||
|
return pendingDivorces.TryRemove(userId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DivorceResult
|
||||||
|
{
|
||||||
|
Ok,
|
||||||
|
NotMarried,
|
||||||
|
NoConfirmation,
|
||||||
|
ConfirmationExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(DivorceResult Result, long PartnerId)> DivorceAsync(long userId, bool confirmed = false)
|
||||||
|
{
|
||||||
|
if (!marriages.ContainsKey(userId)) return (DivorceResult.NotMarried, 0);
|
||||||
|
if (!confirmed) return (DivorceResult.NoConfirmation, 0);
|
||||||
|
if (!pendingDivorces.TryRemove(userId, out var expiresAt)) return (DivorceResult.NoConfirmation, 0);
|
||||||
|
if (expiresAt <= DateTime.UtcNow) return (DivorceResult.ConfirmationExpired, 0);
|
||||||
|
if (!marriages.TryRemove(userId, out MarriageModel? marriage)) return (DivorceResult.NotMarried, 0);
|
||||||
|
|
||||||
|
marriages.TryRemove(marriage.SpouseId, out _);
|
||||||
|
|
||||||
|
long u1 = Math.Min(userId, marriage.SpouseId);
|
||||||
|
long u2 = Math.Max(userId, marriage.SpouseId);
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM Marriages WHERE UserId1 = $u1 AND UserId2 = $u2";
|
||||||
|
cmd.Parameters.AddWithValue("$u1", u1);
|
||||||
|
cmd.Parameters.AddWithValue("$u2", u2);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
return (DivorceResult.Ok, marriage.SpouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(DivorceResult Result, long PartnerId)> ForceDivorceAsync(long userId)
|
||||||
|
{
|
||||||
|
if (!marriages.TryRemove(userId, out MarriageModel? marriage)) return (DivorceResult.NotMarried, 0);
|
||||||
|
|
||||||
|
marriages.TryRemove(marriage.SpouseId, out _);
|
||||||
|
pendingDivorces.TryRemove(userId, out _);
|
||||||
|
|
||||||
|
long u1 = Math.Min(userId, marriage.SpouseId);
|
||||||
|
long u2 = Math.Max(userId, marriage.SpouseId);
|
||||||
|
|
||||||
|
using SqliteConnection connection = DatabaseService.GetConnection();
|
||||||
|
using SqliteCommand cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM Marriages WHERE UserId1 = $u1 AND UserId2 = $u2";
|
||||||
|
cmd.Parameters.AddWithValue("$u1", u1);
|
||||||
|
cmd.Parameters.AddWithValue("$u2", u2);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
return (DivorceResult.Ok, marriage.SpouseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
94
SkyBot/Services/MessageService.cs
Normal file
94
SkyBot/Services/MessageService.cs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using SkyBot.Commands;
|
||||||
|
using SkyBot.Helpers;
|
||||||
|
using SkyBot.Models;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class MessageService
|
||||||
|
{
|
||||||
|
private static readonly ConcurrentDictionary<long, DateTime> _cooldowns = new();
|
||||||
|
private static readonly TimeSpan _cooldown = TimeSpan.FromSeconds(2);
|
||||||
|
public static async Task Create(
|
||||||
|
ValourClient client,
|
||||||
|
ConcurrentDictionary<long, Channel> channelCache,
|
||||||
|
Message message
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (message.AuthorUserId == client.Me.Id) return;
|
||||||
|
|
||||||
|
string prefix = Config.Prefix;
|
||||||
|
string content = message.Content;
|
||||||
|
|
||||||
|
if (!content.ToLower().StartsWith(prefix)) return;
|
||||||
|
if (!channelCache.TryGetValue(message.ChannelId, out var channel)) return;
|
||||||
|
if (string.IsNullOrWhiteSpace(content)) return;
|
||||||
|
|
||||||
|
if (BlacklistService.GetBlacklisted(message.AuthorUserId)) return;
|
||||||
|
|
||||||
|
string[] parts = content.Substring(prefix.Length).Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
if (parts.Length == 0) return;
|
||||||
|
|
||||||
|
string command = parts[0].ToLower();
|
||||||
|
string[] args = parts[1..];
|
||||||
|
|
||||||
|
PlanetMember member = await message.FetchAuthorMemberAsync();
|
||||||
|
|
||||||
|
CommandContext ctx = new CommandContext
|
||||||
|
{
|
||||||
|
Client = client,
|
||||||
|
ChannelCache = channelCache,
|
||||||
|
Member = member,
|
||||||
|
Message = message,
|
||||||
|
Planet = message.Planet,
|
||||||
|
Channel = channel,
|
||||||
|
Args = args
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
if (PendingConfirmations.IsPending(member.UserId))
|
||||||
|
{
|
||||||
|
PendingConfirmations.TryComplete(member.UserId, content.Trim().ToLower() == $"{prefix}confirm");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_cooldowns.TryGetValue(member.Id, out var lastUsed) && DateTime.UtcNow - lastUsed < _cooldown) return;
|
||||||
|
|
||||||
|
_cooldowns[member.Id] = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (CommandRegistry.Commands.TryGetValue(command, out var handler))
|
||||||
|
{
|
||||||
|
if (!PermissionHelper.IsOwner(member) && ChannelRestrictionService.IsRestricted(message.ChannelId, handler))
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"`{command}` is disabled in this channel.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handler.Execute(ctx);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await MessageHelper.ReplyAsync(ctx, $"Unknown command `{command}`.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public static async Task Delete(
|
||||||
|
ValourClient client,
|
||||||
|
Message message
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (Echo.EchoMap.TryRemove(message.Id, out var echoId))
|
||||||
|
{
|
||||||
|
if (client.Cache.Messages.TryGet(echoId, out var echoMsg))
|
||||||
|
{
|
||||||
|
await echoMsg!.DeleteAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
SkyBot/Services/PlanetService.cs
Normal file
37
SkyBot/Services/PlanetService.cs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.ModelLogic;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot.Services
|
||||||
|
{
|
||||||
|
public static class PlanetService
|
||||||
|
{
|
||||||
|
public static async Task InitialisePlanetsAsync(
|
||||||
|
ValourClient client,
|
||||||
|
ConcurrentDictionary<long, Channel> channelCache,
|
||||||
|
ConcurrentDictionary<long, bool> initialisedPlanets
|
||||||
|
)
|
||||||
|
{
|
||||||
|
foreach (var planet in client.PlanetService.JoinedPlanets.Where(p => !initialisedPlanets.ContainsKey(p.Id)))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Initialising Planet: {planet.Name}");
|
||||||
|
|
||||||
|
await planet.EnsureReadyAsync();
|
||||||
|
await planet.FetchInitialDataAsync();
|
||||||
|
await ChannelService.InitialiseChannelsAsync(channelCache, planet);
|
||||||
|
|
||||||
|
planet.Channels.Changed += async channelEvent =>
|
||||||
|
{
|
||||||
|
if (channelEvent is ModelAddedEvent<Channel> addedEvent)
|
||||||
|
{
|
||||||
|
await ChannelService.InitialiseChannelAsync(channelCache, addedEvent.Model);
|
||||||
|
}
|
||||||
|
await ChannelService.InitialiseChannelsAsync(channelCache, planet);
|
||||||
|
};
|
||||||
|
|
||||||
|
initialisedPlanets.TryAdd(planet.Id, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
36
SkyBot/SkyBot.cs
Normal file
36
SkyBot/SkyBot.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using SkyBot.Services;
|
||||||
|
using Valour.Sdk.Client;
|
||||||
|
using Valour.Sdk.Models;
|
||||||
|
|
||||||
|
namespace SkyBot
|
||||||
|
{
|
||||||
|
public class Program
|
||||||
|
{
|
||||||
|
private static readonly ValourClient client = new("https://api.valour.gg/");
|
||||||
|
private static readonly ConcurrentDictionary<long, Channel> channelCache = new();
|
||||||
|
private static readonly ConcurrentDictionary<long, bool> initialisedPlanets = new();
|
||||||
|
public static DateTime StartTime;
|
||||||
|
|
||||||
|
public static async Task Main()
|
||||||
|
{
|
||||||
|
client.SetupHttpClient();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow;
|
||||||
|
await DatabaseService.InitialiseAsync();
|
||||||
|
await MarriageService.InitialiseAsync();
|
||||||
|
await BlacklistService.InitialiseAsync();
|
||||||
|
await ChannelRestrictionService.InitialiseAsync();
|
||||||
|
await BotService.InitialiseBotAsync(client, channelCache, initialisedPlanets);
|
||||||
|
|
||||||
|
|
||||||
|
Console.WriteLine("Ready and Listening...");
|
||||||
|
await Task.Delay(Timeout.Infinite);
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Fatal Error: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,13 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>0.3.5.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
<PackageReference Include="DotNetEnv" Version="3.2.0" />
|
||||||
<PackageReference Include="Valour.Sdk" Version="0.5.19" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
|
<PackageReference Include="Valour.Sdk" Version="0.5.31" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
138
utils.cs
138
utils.cs
@@ -1,138 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using Valour.Sdk.Models;
|
|
||||||
using Valour.Sdk.Client;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace SkyBot
|
|
||||||
{
|
|
||||||
public static class Utils
|
|
||||||
{
|
|
||||||
|
|
||||||
private static readonly HttpClient _http = new HttpClient();
|
|
||||||
private static long _valourUserCount;
|
|
||||||
public static long ValourUserCount => _valourUserCount;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static bool IsSingleEmoji(string input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
input = input.Trim();
|
|
||||||
|
|
||||||
var enumerator = StringInfo.GetTextElementEnumerator(input);
|
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
while (enumerator.MoveNext())
|
|
||||||
count++;
|
|
||||||
|
|
||||||
return count == 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool ContainsAny(string input, params string[] values)
|
|
||||||
{
|
|
||||||
var lower = input.ToLower();
|
|
||||||
|
|
||||||
foreach (var value in values)
|
|
||||||
{
|
|
||||||
if (lower.Contains(value.ToLower()))
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task SendReplyAsync(Dictionary<long, Channel> channelCache, long channel, string reply)
|
|
||||||
{
|
|
||||||
if (channelCache.TryGetValue(channel, out var chan))
|
|
||||||
{
|
|
||||||
await chan.SendMessageAsync(reply);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Channel {channel} was not found in the cache.");
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task UpdateValourUserCountAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await _http.GetStringAsync("https://api.valour.gg/api/users/count");
|
|
||||||
|
|
||||||
_valourUserCount = JsonSerializer.Deserialize<long>(response);
|
|
||||||
|
|
||||||
Console.WriteLine($"Valour user count updated: {_valourUserCount}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Failed to update Valour user count: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void StartValourUserUpdater()
|
|
||||||
{
|
|
||||||
var timer = new System.Timers.Timer(300_000);
|
|
||||||
timer.Elapsed += async (_, _) => await UpdateValourUserCountAsync();
|
|
||||||
timer.AutoReset = true;
|
|
||||||
timer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task InitializePlanetsAsync(ValourClient client, Dictionary<long, Channel> channelCache, HashSet<long> initializedPlanets)
|
|
||||||
{
|
|
||||||
foreach (var planet in client.PlanetService.JoinedPlanets)
|
|
||||||
{
|
|
||||||
if (initializedPlanets.Contains(planet.Id))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
Console.WriteLine($"Initializing Planet: {planet.Name}");
|
|
||||||
|
|
||||||
await planet.EnsureReadyAsync();
|
|
||||||
await planet.FetchInitialDataAsync();
|
|
||||||
|
|
||||||
foreach (var channel in planet.Channels)
|
|
||||||
{
|
|
||||||
channelCache[channel.Id] = channel;
|
|
||||||
|
|
||||||
if (channel.ChannelType == Valour.Shared.Models.ChannelTypeEnum.PlanetChat)
|
|
||||||
{
|
|
||||||
await channel.OpenWithResult("SkyBot");
|
|
||||||
Console.WriteLine($"Realtime opened for: {planet.Name} -> {channel.Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializedPlanets.Add(planet.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ReactionInterceptor : TextWriter
|
|
||||||
{
|
|
||||||
private readonly TextWriter _original;
|
|
||||||
public bool DetectedAlreadyExists { get; private set; }
|
|
||||||
public override Encoding Encoding => _original.Encoding;
|
|
||||||
|
|
||||||
public ReactionInterceptor(TextWriter original)
|
|
||||||
{
|
|
||||||
_original = original;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Reset() => DetectedAlreadyExists = false;
|
|
||||||
|
|
||||||
public override void WriteLine(string value)
|
|
||||||
{
|
|
||||||
if (value?.Contains("Reaction already exists") == true)
|
|
||||||
DetectedAlreadyExists = true;
|
|
||||||
_original.WriteLine(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(string value)
|
|
||||||
{
|
|
||||||
if (value?.Contains("Reaction already exists") == true)
|
|
||||||
DetectedAlreadyExists = true;
|
|
||||||
_original.Write(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user