diff --git a/PRIVACY.md b/PRIVACY.md index 24ccb2a..3942ccc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -2,61 +2,53 @@ + -

Privacy Policy

-

Effective Date: February 26, 2026

-

This Privacy Policy describes how the bot (“the Bot”) collects, uses, and stores information when used within a server.

+

Effective Date: March 16, 2026

+

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


-

1. Information Collected

-

The Bot collects and stores only the minimum data necessary to provide its intended functionality.

- -

Information Stored:

+

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

+

Information Temporarily Held in Memory:

    -
  1. Message IDs
  2. -
  3. Channel IDs
  4. -
  5. Server (“Planet”) IDs
  6. -
  7. Planet Configuration data associated with those channels
  8. +
  9. Channel IDs (for routing messages and commands)
  10. +
  11. Planet IDs (for planet-specific operations)
  12. +
  13. Member IDs (for moderation commands)
- -

Information Not Stored:

+

Information Never Stored:

  1. Message content
  2. -
  3. User-generated message content
  4. -
  5. Direct Messages (“DMs”)
  6. +
  7. Direct Messages ("DMs")
  8. Personal account information (including usernames, email addresses, or other personally identifiable information)
  9. +
  10. Any data that persists beyond the Bot's current session

-

2. Purpose of Data Collection

-

Stored information is used exclusively to:

+

Temporarily held information is used exclusively to:

    -
  1. Maintain server-specific configuration settings
  2. -
  3. Associate Planets with designated channels
  4. -
  5. Enable and maintain core bot functionality
  6. +
  7. Route commands to the correct channels and planets
  8. +
  9. Enable moderation commands such as ban, unban, and kick
  10. +
  11. Enable core bot functionality during the current session
-

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

+

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


-

3. Data Storage and Security

-

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.

-

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

+

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

+

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


-

4. Data Retention

-

Configuration data is retained only while the Bot remains active within a server.

-

If the Bot is removed from a server, associated configuration data may be deleted within a reasonable timeframe.

+

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


- -

5. Future Changes to Logging or Data Practices

+

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.


- -

6. Contact Information

+

7. Contact Information

For privacy-related inquiries, requests, or concerns, please contact:

-

Email: contact@skyjoshua.xyz

- +

Email: contact@skyjoshua.xyz

- + \ No newline at end of file diff --git a/README.md b/README.md index a8ab730..604ce59 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,91 @@ -

SkyBot

-

-SkyBot is a Valour.gg bot. +SkyBot is a Valour.gg bot built with .NET 10.

-

Features

-

Data & Privacy

-

SkyBot stores only the minimum data required for operation:

- - +

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

SkyBot does not store:

-

Full privacy policy:
https://github.com/SkyJoshua/SkyBot/blob/main/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:
https://github.com/SkyJoshua/SkyBot/blob/main/LICENSE

-

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

+

Installation

-Fork this Repository +

Fork this repository, then:

git clone https://github.com/YOUR_USERNAME/SkyBot.git
-cd SkyBot
+cd SkyBot/SkyBot
 dotnet restore
 
-

All required NuGet packages will be installed automatically using the provided SkyBot.csproj file.

-

Configuration

-

Before running the bot, create a .env file in the root directory of the project with the following content:

- +

Create a .env file in the root directory of the project with your bot token:

TOKEN=your-bot-token-here
-PREFIX=your-prefix-here
 
- +

Then open Config.cs and update the following values:

+
public static readonly long OwnerId = your-owner-id-here;
+public static readonly string Prefix = "your-prefix-here";
+public static readonly string SourceLink = "your-source-link-here";
+
- -

-Sensitive data such as bot tokens should never be committed to the repository. -Use environment variables or secure configuration methods. -

- +

Never commit your .env file to the repository. Ensure it is listed in your .gitignore.

Running the Bot

- -
dotnet run
-
- +
dotnet run

Contributing

Contributions are welcome. By submitting a contribution, you agree that your contributions will be licensed under AGPL-3.0.

-
  1. Fork the repository
  2. Create a feature branch
  3. Submit a pull request
- - + \ No newline at end of file diff --git a/SkyBot/Commands/Dev/Test.cs b/SkyBot/Commands/Dev/Edit.cs similarity index 96% rename from SkyBot/Commands/Dev/Test.cs rename to SkyBot/Commands/Dev/Edit.cs index 85a9239..02afe7c 100644 --- a/SkyBot/Commands/Dev/Test.cs +++ b/SkyBot/Commands/Dev/Edit.cs @@ -7,7 +7,7 @@ using Valour.Shared; namespace SkyBot.Commands { - public class Test : ICommand + public class Edit : ICommand { public string Name => "edit"; public string[] Aliases => []; @@ -29,6 +29,7 @@ namespace SkyBot.Commands if(!PermissionHelper.IsOwner(member)) { await MessageHelper.ReplyAsync(ctx, channel, "This is a Dev only command."); + return; } if (message.ReplyToId == null) diff --git a/SkyBot/Commands/Mod/SetWelcome.cs b/SkyBot/Commands/Mod/SetWelcome.cs new file mode 100644 index 0000000..c36b2ee --- /dev/null +++ b/SkyBot/Commands/Mod/SetWelcome.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using SkyBot.Helpers; +using SkyBot.Models; +using SkyBot.Services; +using Valour.Sdk.Models; +using Valour.Shared.Authorization; +using Valour.Shared.Models; + +namespace SkyBot.Commands +{ + public class SetWelcome : ICommand + { + public string Name => "setwelcome"; + public string[] Aliases => []; + public string Description => "Sets the welcome channel, message or active."; + public string Section => "Mod"; + public string Usage => "set channelCache = ctx.ChannelCache; + long channelId = ctx.ChannelId; + Message message = ctx.Message; + PlanetMember member = ctx.Member; + Planet planet = ctx.Planet; + string[] args = ctx.Args; + + if (!channelCache.TryGetValue(channelId, out var channel)) return; + + if (!PermissionHelper.HasPerm(member, [PlanetPermissions.Manage]) && !PermissionHelper.IsOwner(member)) + { + await MessageHelper.ReplyAsync(ctx, channel, "You don't have permission to use this command."); + return; + } + + if (args.Length == 0) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please specify `channel` or `message`."); + return; + } + + switch (args[0].ToLower()) + { + case "channel": + case "c": + long targetChannelId; + if (message.Mentions != null && message.Mentions.Any(m => m.Type == MentionType.Channel)) {targetChannelId = message.Mentions.First(m => m.Type == MentionType.Channel).TargetId;} + else if (args.Length > 1 && long.TryParse(args[1], out long parsedChannelId)) {targetChannelId = parsedChannelId;} + else {targetChannelId = channelId;} + + if (!channelCache.ContainsKey(targetChannelId)) {await MessageHelper.ReplyAsync(ctx, channel, "Could not find that channel."); return;} + + await WelcomeService.SetWelcomeChannel(planet.Id, targetChannelId); + await MessageHelper.ReplyAsync(ctx, channel, $"Welcome channel set to «@c-{targetChannelId}»."); + break; + + case "message": + case "m": + if (args.Length < 2) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a message. Valid variables: {username} {nickname} {fulluser} {mention} {id}"); + return; + } + string msg = string.Join(" ", args[1..]); + await WelcomeService.SetWelcomeMessage( planet.Id, msg); + await MessageHelper.ReplyAsync(ctx, channel, $"Welcome message set to: `{msg}`"); + break; + + case "active": + case "a": + if (args.Length < 2) + { + await MessageHelper.ReplyAsync(ctx, channel, "Please provide a value. Use `true`, `false`, or `toggle`."); + return; + } + string value = args[1].ToLower(); + if (value != "toggle" && value != "true" && value != "false") + { + await MessageHelper.ReplyAsync(ctx, channel, "Invalid value. Use `true`, `false`, `toggle`"); + return; + } + + if (value == "toggle") + { + var toggle = await WelcomeService.SetActive(planet.Id); + await MessageHelper.ReplyAsync(ctx, channel, toggle.Value ? "Welcome messages enabled." : "Welcome messages disabled."); + return; + } + + bool.TryParse(value, out var active); + + await WelcomeService.SetActive(planet.Id, active); + await MessageHelper.ReplyAsync(ctx, channel, active ? "Welcome messages enabled." : "Welcome messages disabled."); + break; + + default: + await MessageHelper.ReplyAsync(ctx, channel, "Invalid option. Use `channel`, `message` or `active`."); + break; + } + + } + } +} \ No newline at end of file diff --git a/SkyBot/Models/DatabaseHelper.cs b/SkyBot/Models/DatabaseHelper.cs new file mode 100644 index 0000000..32864ba --- /dev/null +++ b/SkyBot/Models/DatabaseHelper.cs @@ -0,0 +1,32 @@ +using Microsoft.Data.Sqlite; + +namespace SkyBot.Helpers +{ + public static class DatabaseHelper + { + 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 InitializeAsync() + { + using SqliteConnection connection = GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS WelcomeConfigs ( + PlanetId INTEGER PRIMARY KEY, + ChannelId INTEGER NOT NULL DEFAULT 0, + Message TEXT NOT NULL DEFAULT 'Welcome to the planet, {username}!', + Active INTEGER NOT NULL DEFAULT 0 + ); + "; + await cmd.ExecuteNonQueryAsync(); + Console.WriteLine("Database initialized."); + } + } +} \ No newline at end of file diff --git a/SkyBot/Models/WelcomeConfig.cs b/SkyBot/Models/WelcomeConfig.cs new file mode 100644 index 0000000..b31cb78 --- /dev/null +++ b/SkyBot/Models/WelcomeConfig.cs @@ -0,0 +1,7 @@ +public class WelcomeConfig +{ + public long PlanetId { get; set; } + public long ChannelId { get; set; } + public string Message { get; set; } = "Welcome to the planet, {username}!"; + public bool Active { get; set; } = false; +} \ No newline at end of file diff --git a/SkyBot/Services/Messages/Create.cs b/SkyBot/Services/Messages/Create.cs index fe549cc..4fed14d 100644 --- a/SkyBot/Services/Messages/Create.cs +++ b/SkyBot/Services/Messages/Create.cs @@ -5,10 +5,14 @@ using SkyBot.Models; using Valour.Sdk.Client; using Valour.Sdk.Models; + + namespace SkyBot.Services.Messages { public static class Create { + private static readonly ConcurrentDictionary _cooldowns = new(); + private static readonly TimeSpan _cooldown = TimeSpan.FromSeconds(2); public static async Task MessageAsync( ValourClient client, ConcurrentDictionary channelCache, @@ -40,6 +44,11 @@ namespace SkyBot.Services.Messages Client = client }; + if (_cooldowns.TryGetValue(message.AuthorUserId, out var lastUsed) && DateTime.UtcNow - lastUsed < _cooldown) + return; + + _cooldowns[message.AuthorUserId] = DateTime.UtcNow; + if (CommandRegistry.Commands.TryGetValue(command, out var handler)) { await handler.Execute(ctx); diff --git a/SkyBot/Services/PlanetService.cs b/SkyBot/Services/PlanetService.cs index 911fccf..74c3b7d 100644 --- a/SkyBot/Services/PlanetService.cs +++ b/SkyBot/Services/PlanetService.cs @@ -1,14 +1,13 @@ using System.Collections.Concurrent; -using SkyBot.Services; using Valour.Sdk.Client; +using Valour.Sdk.ModelLogic; using Valour.Sdk.Models; -using Valour.Sdk.Models.Messages.Embeds; - namespace SkyBot.Services { public static class PlanetService { + private static readonly DateTime _startTime = DateTime.UtcNow; public static async Task InitializePlanetsAsync( ValourClient client, ConcurrentDictionary channelCache, @@ -19,15 +18,27 @@ namespace SkyBot.Services .Select(async planet => { Console.WriteLine($"Initializing Planet: {planet.Name}"); + await planet.EnsureReadyAsync(); await planet.FetchInitialDataAsync(); await ChannelService.InitializeChannelsAsync(channelCache, planet); - planet.Channels.Changed += async (channelEvent) => { + planet.Channels.Changed += async _ => + { await ChannelService.InitializeChannelsAsync(channelCache, planet); }; - }); + planet.Members.Changed += async memberEvent => + { + if ((DateTime.UtcNow - _startTime).TotalSeconds < 10) return; + if (memberEvent is ModelAddedEvent addedEvent) + { + await WelcomeService.OnMemberJoin(addedEvent.Model, channelCache); + } + }; + + initializedPlanets.TryAdd(planet.Id, true); + }); await Task.WhenAll(tasks); } } diff --git a/SkyBot/Services/WelcomeService.cs b/SkyBot/Services/WelcomeService.cs new file mode 100644 index 0000000..db8df7e --- /dev/null +++ b/SkyBot/Services/WelcomeService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Microsoft.Data.Sqlite; +using SkyBot.Helpers; +using Valour.Sdk.Models; + +namespace SkyBot.Services +{ + public static class WelcomeService + { + private static readonly ConcurrentDictionary _cache = new(); + + public static async Task InitializeAsync() + { + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM WelcomeConfigs"; + using SqliteDataReader reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + var config = new WelcomeConfig + { + PlanetId = (long)reader["PlanetId"], + ChannelId = (long)reader["ChannelId"], + Message = (string)reader["Message"], + Active = (long)reader["Active"] == 1 + }; + _cache[config.PlanetId] = config; + } + Console.WriteLine("WelcomeService initialized."); + Console.WriteLine($"Loaded {_cache.Count} welcome configs from database."); + } + + public static async Task OnMemberJoin(PlanetMember member, ConcurrentDictionary channelCache) + { + if (!_cache.TryGetValue(member.PlanetId, out var config)) { Console.WriteLine("No config found"); return; } + if (!config.Active) { Console.WriteLine("Not active"); return; } + + Channel? channel = null; + + if (config.ChannelId != 0 && channelCache.TryGetValue(config.ChannelId, out var configChannel)) + { + channel = configChannel; + } + else + { + channel = channelCache.Values.FirstOrDefault(c => c.PlanetId == member.PlanetId && c.IsDefault); + } + + if (channel == null) { Console.WriteLine("No channel found"); return; } + + + string message = config.Message + .Replace("{username}", member.Name) + .Replace("{fulluser}", member.User.NameAndTag) + .Replace("{nickname}", string.IsNullOrWhiteSpace(member.Nickname) ? member.Name : member.Nickname) + .Replace("{mention}", MessageHelper.Mention(member)) + .Replace("{id}", $"{member.Id}"); + + await channel.SendMessageAsync(message); + } + + public static async Task SetWelcomeChannel(long planetId, long channelId) + { + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO WelcomeConfigs (PlanetId, ChannelId) VALUES ($planetId, $channelId) + ON CONFLICT(PlanetId) DO UPDATE SET ChannelId = $channelId; + "; + cmd.Parameters.AddWithValue("$planetId", planetId); + cmd.Parameters.AddWithValue("$channelId", channelId); + await cmd.ExecuteNonQueryAsync(); + + if (_cache.TryGetValue(planetId, out var config)) + { + config.ChannelId = channelId; + } + else + { + _cache[planetId] = new WelcomeConfig{PlanetId = planetId, ChannelId = channelId}; + } + } + + public static async Task SetWelcomeMessage(long planetId, string message) + { + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO WelcomeConfigs (PlanetId, Message) VALUES ($planetId, $message) + ON CONFLICT(PlanetId) DO UPDATE SET Message = $message; + "; + cmd.Parameters.AddWithValue("$planetId", planetId); + cmd.Parameters.AddWithValue("$message", message); + await cmd.ExecuteNonQueryAsync(); + + if (_cache.TryGetValue(planetId, out var config)) + { + config.Message = message; + } + else + { + _cache[planetId] = new WelcomeConfig{PlanetId = planetId, Message = message}; + } + } + + public static async Task SetActive(long planetId, bool active) + { + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO WelcomeConfigs (PlanetId, Active) VALUES ($planetId, $active) + ON CONFLICT(PlanetId) DO UPDATE SET Active = $active; + "; + cmd.Parameters.AddWithValue("$planetId", planetId); + cmd.Parameters.AddWithValue("$active", active ? 1 : 0); + await cmd.ExecuteNonQueryAsync(); + + if (_cache.TryGetValue(planetId, out var config)) + { + config.Active = active; + } + else + { + _cache[planetId] = new WelcomeConfig{PlanetId = planetId, Active = active}; + } + } + + public static async Task SetActive(long planetId) + { + if (!_cache.TryGetValue(planetId, out var config)) return null; + + bool newActive = !config.Active; + await SetActive(planetId, newActive); + return newActive; + } + } +} \ No newline at end of file diff --git a/SkyBot/SkyBot.cs b/SkyBot/SkyBot.cs index 0d9138e..58fd5b7 100644 --- a/SkyBot/SkyBot.cs +++ b/SkyBot/SkyBot.cs @@ -2,6 +2,7 @@ using Valour.Sdk.Client; using Valour.Sdk.Models; using SkyBot.Services; using System.Collections.Concurrent; +using SkyBot.Helpers; namespace SkyBot { @@ -21,6 +22,8 @@ namespace SkyBot public async Task StartAsync() { StartTime = DateTime.UtcNow; + await DatabaseHelper.InitializeAsync(); + await WelcomeService.InitializeAsync(); await BotService.InitializeBotAsync(_client, _channelCache, _initializedPlanets); } } @@ -34,7 +37,6 @@ namespace SkyBot try { await new SkyBot().StartAsync(); - Console.WriteLine("Ready and listening..."); await Task.Delay(Timeout.Infinite); diff --git a/SkyBot/SkyBot.csproj b/SkyBot/SkyBot.csproj index eb93c8e..734b5d3 100644 --- a/SkyBot/SkyBot.csproj +++ b/SkyBot/SkyBot.csproj @@ -5,11 +5,12 @@ net10.0 enable enable - 0.2.0.0 + 0.2.1.0 +