diff --git a/.gitignore b/.gitignore index 932fb31..3d31385 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ bin/ obj/ Reactor.sln -.env \ No newline at end of file +.env +reactor.db diff --git a/Commands/AddCommand.cs b/Commands/AddCommand.cs new file mode 100644 index 0000000..9217062 --- /dev/null +++ b/Commands/AddCommand.cs @@ -0,0 +1,53 @@ +using Reactor.Services; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Commands +{ + public static class AddCommand + { + public static async Task Execute( + ValourClient client, + Dictionary channelCache, + long channelId, + long messageId, + string emoji, + long roleId) + { + //Check if the current channel is in the cache (should never happen but you never know!) + if (!channelCache.TryGetValue(channelId, out var channel)) + { + Console.WriteLine($"Channel {channelId} not found in cache."); + return; + } + + //Check if the message id is a valid reaction message + if (!ReactionRoleService.Messages.TryGetValue(messageId, out var reactionMsg)) + { + await channel.SendMessageAsync($"Message ID {messageId} is not tracked as a reaction message."); + return; + } + + //Fetch recent messages + var recentMessages = await channel.GetLastMessagesAsync(50); + + //Try and find the message inside those recent messages + var message = recentMessages.FirstOrDefault(m => m.Id == messageId); + if (message == null) + { + await channel.SendMessageAsync("Could not find the message in the last 50 messages."); + return; + } + + //Add the emoji to the message + await message.AddReactionAsync(emoji); + + //Add reaction-role mapping to DB and Cache + await ReactionRoleService.AddReactionAsync(messageId, emoji, roleId); + + ReactionRoleService.SubscribeToMessageReactions(client, channelCache, message); + + await channel.SendMessageAsync($"Added reaction {emoji} -> role {roleId} for message {messageId}"); + } + } +} \ No newline at end of file diff --git a/Commands/CreateCommand.cs b/Commands/CreateCommand.cs new file mode 100644 index 0000000..8d6c0fa --- /dev/null +++ b/Commands/CreateCommand.cs @@ -0,0 +1,67 @@ +using Microsoft.Data.Sqlite; +using Reactor.Services; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Commands +{ + public static class CreateCommand + { + //Sends a new Reaction Role Message and Stores it + public static async Task Execute( + ValourClient client, + Dictionary channelCache, + long channelId, + string content, + long planetId, + int deleteDelaySeconds = 5) + { + if (!channelCache.TryGetValue(channelId, out var channel)) + { + Console.WriteLine($"Channel {channelId} not found in cache."); + return; + } + + //Send the Message + var result = await channel.SendMessageAsync(content); + if (!result.Success || result.Data == null) + { + Console.WriteLine("Failed to send message."); + return; + } + + var sentMessage = result.Data; + await channel.SendMessageAsync($"This Reaction Message has the ID of: {sentMessage.Id}"); + + //Insert into DB + using var connection = new SqliteConnection("Data Source=reactor.db"); + await connection.OpenAsync(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO ReactionMessages (PlanetId, ChannelId, MessageId, DeleteDelaySeconds) + VALUES (@planetId, @channelId, @messageId, @delay); + SELECT last_insert_rowid(); + "; + cmd.Parameters.AddWithValue("@planetId", planetId); + cmd.Parameters.AddWithValue("@channelId", channelId); + cmd.Parameters.AddWithValue("@messageId", sentMessage.Id); + cmd.Parameters.AddWithValue("@delay", deleteDelaySeconds); + + var insertedId = (long)await cmd.ExecuteScalarAsync(); + + //Add to memory + ReactionRoleService.Messages[sentMessage.Id] = new Models.ReactionMessage + { + Id = insertedId, + PlanetId = planetId, + ChannelId = channelId, + MessageId = sentMessage.Id, + DeleteDelaySeconds = deleteDelaySeconds, + Reactions = new Dictionary() + }; + + Console.WriteLine($"Created reaction message {sentMessage.Id} in channel {channelId}"); + } + } +} \ No newline at end of file diff --git a/Commands/DeleteCommand.cs b/Commands/DeleteCommand.cs new file mode 100644 index 0000000..571c73f --- /dev/null +++ b/Commands/DeleteCommand.cs @@ -0,0 +1,47 @@ +using Reactor.Services; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Commands +{ + public static class DeleteCommand + { + public static async Task Execute( + ValourClient client, + Dictionary channelCache, + long channelId, + long messageId) + { + //Check if channel in cache + if (!channelCache.TryGetValue(channelId, out var channel)) + { + Console.WriteLine($"Channel {channelId} not found in cache."); + return; + } + + //Check if message is actually a reaction message + if (!ReactionRoleService.Messages.TryGetValue(messageId, out var reactionMsg)) + { + await channel.SendMessageAsync($"Message ID {messageId} is not tracked as a reaction message."); + return; + } + + //Delete the actual message + var recentMessages = await channel.GetLastMessagesAsync(50); + var message = recentMessages.FirstOrDefault(m => m.Id == messageId); + if (message != null) + { + await message.DeleteAsync(); + } else + { + Console.WriteLine($"Message {messageId} not found in recent messages, skipping deletion of message."); + } + + //Remove from cache and database + await ReactionRoleService.RemoveMessageAsync(messageId); + ReactionRoleService.ResetSubscription(messageId); + + await channel.SendMessageAsync($"Deleted reaction message {messageId} and all its role mappings."); + } + } +} \ No newline at end of file diff --git a/Commands/HelpCommand.cs b/Commands/HelpCommand.cs index 493d519..8c78981 100644 --- a/Commands/HelpCommand.cs +++ b/Commands/HelpCommand.cs @@ -2,16 +2,22 @@ using Valour.Sdk.Models; namespace Reactor.Commands; -public static class HelpComamnd +public static class HelpCommand { public static async Task Execute(Dictionary channelCache, long channelId, String prefix, string memberPing) { string helpMessage = $@"**Reactor Commands**: - - `{prefix}help` - Shows this list."; + - `{prefix}help` - Shows this list. + - `{prefix}source` - Shows my source code! + - `{prefix}create` - Creates the Reaction Message. + - `{prefix}delete` - Deletes a Reaction Message. + - `{prefix}add` - Adds a Reaction Role to a Valid Message. + - `{prefix}remove` - Removes a Reaction Role from a Valid Message. + "; - if (channelCache.TryGetValue(channelId, out var channel)) - { - await channel.SendMessageAsync($"{memberPing}\n{helpMessage}"); - } + if (channelCache.TryGetValue(channelId, out var channel)) + { + await channel.SendMessageAsync($"{memberPing}\n{helpMessage}"); + } } } \ No newline at end of file diff --git a/Commands/RemoveCommand.cs b/Commands/RemoveCommand.cs new file mode 100644 index 0000000..7272f21 --- /dev/null +++ b/Commands/RemoveCommand.cs @@ -0,0 +1,50 @@ +using Reactor.Services; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Commands +{ + public static class RemoveCommand + { + public static async Task Execute( + ValourClient client, + Dictionary channelCache, + long channelId, + long messageId, + string emoji) + { + //Check if channel in cache + if (!channelCache.TryGetValue(channelId, out var channel)) + { + Console.WriteLine($"Channel {channelId} not found in cache."); + return; + } + + //Check if message is actually a reaction message + if (!ReactionRoleService.Messages.TryGetValue(messageId, out var reactionMsg)) + { + await channel.SendMessageAsync($"Message ID {messageId} is not tracked as a reaction message."); + return; + } + + //Check if the emoji is actually a valid reaction on the message + if (!reactionMsg.Reactions.ContainsKey(emoji)) + { + await channel.SendMessageAsync($"Emoji {emoji} is not mapped to any role on message {messageId}."); + return; + } + + //Fetch the message and remove the reaction + var recentMessages = await channel.GetLastMessagesAsync(50); + var message = recentMessages.FirstOrDefault(m => m.Id == messageId); + if (message != null) + { + await message.RemoveReactionAsync(emoji); + } + + await ReactionRoleService.RemoveReactionAsync(messageId, emoji); + + await channel.SendMessageAsync($"Removed reaction {emoji} from message {messageId}."); + } + } +} \ No newline at end of file diff --git a/Commands/SourceCommand.cs b/Commands/SourceCommand.cs new file mode 100644 index 0000000..ee3847c --- /dev/null +++ b/Commands/SourceCommand.cs @@ -0,0 +1,14 @@ +using Valour.Sdk.Models; + +namespace Reactor.Commands; + +public static class SourceCommand +{ + public static async Task Execute(Dictionary channelCache, long channelId, string memberPing) + { + if (channelCache.TryGetValue(channelId, out var channel)) + { + await channel.SendMessageAsync($"{memberPing} You can see my source code here: https://github.com/SkyJoshua/Reactor"); + } + } +} \ No newline at end of file diff --git a/Models/ReactionRoleModels.cs b/Models/ReactionRoleModels.cs new file mode 100644 index 0000000..6aa9f5a --- /dev/null +++ b/Models/ReactionRoleModels.cs @@ -0,0 +1,13 @@ +namespace Reactor.Models +{ + public class ReactionMessage + { + public long Id { get; set; } + public long PlanetId { get; set; } + public long ChannelId { get; set; } + public long MessageId { get; set; } + public int DeleteDelaySeconds { get; set; } = 5; + + public Dictionary Reactions { get; set; } = new(); + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs deleted file mode 100644 index 39a91d6..0000000 --- a/Program.cs +++ /dev/null @@ -1,135 +0,0 @@ -using Valour.Sdk.Client; -using Valour.Sdk.Models; -using DotNetEnv; -using Valour.Shared.Models; -using Reactor.Commands; - -namespace Reactor -{ - public class Reactor - { - private ValourClient _client; - private Dictionary _channelCache = new(); - private HashSet _initializedPlanets = new(); - private string _prefix = "r."; - - public Reactor(string token) - { - Env.Load(); - _client = new ValourClient("https://api.valour.gg/"); - _client.SetupHttpClient(); - InitializeBotAsync(token).GetAwaiter().GetResult(); - } - - //Initialize the bot. - private async Task InitializeBotAsync(string token) - { - if (string.IsNullOrWhiteSpace(token)) - { - Console.WriteLine("TOKEN 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 InitializePlanetsAsync(); - - _client.PlanetService.JoinedPlanetsUpdated += async () => - { - await InitializePlanetsAsync(); - }; - - _client.MessageService.MessageReceived += async (msg) => await HandleMessageAsync(msg); - - Console.WriteLine("Bot ready and listening..."); - } - - //Initalize the planets. - private async Task InitializePlanetsAsync() - { - 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 == ChannelTypeEnum.PlanetChat) - { - await channel.OpenWithResult("Reactor"); - Console.WriteLine($"Realtime opened for: {planet.Name} -> {channel.Name}"); - } - } - - _initializedPlanets.Add(planet.Id); - } - } - - //Message handler. - private async Task HandleMessageAsync(Message message) - { - if (message.AuthorUserId == _client.Me.Id) return; - - string content = message.Content ?? ""; - if (string.IsNullOrWhiteSpace(content)) return; - if (!content.StartsWith(_prefix)) return; - - long channelId = message.ChannelId; - - var member = await message.FetchAuthorMemberAsync(); - string memberPing = member != null ? $"«@m-{member.Id}»" : ""; - - string withoutPrefix = content.Substring(_prefix.Length); - - var parts = withoutPrefix.Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 0) return; - - string command = parts[0].ToLower(); - string[] args = parts[1..]; - - switch (command) - { - case "help": - await HelpComamnd.Execute(_channelCache, channelId, _prefix, memberPing); - break; - } - } - - } - - - //Because it required a main or something idk I hate C# :) - public class Program - { - public static async Task Main(string[] args) - { - Env.Load(); - - var token = Environment.GetEnvironmentVariable("TOKEN"); - - if (string.IsNullOrWhiteSpace(token)) - { - Console.WriteLine("TOKEN not set."); - return; - } - - var bot = new Reactor(token); - - await Task.Delay(Timeout.Infinite); - } - } -} \ No newline at end of file diff --git a/Reactor.cs b/Reactor.cs new file mode 100644 index 0000000..1048304 --- /dev/null +++ b/Reactor.cs @@ -0,0 +1,53 @@ +using Valour.Sdk.Client; +using Valour.Sdk.Models; +using DotNetEnv; +using Reactor.Services; + +namespace Reactor +{ + public class Reactor + { + private readonly ValourClient _client; + private readonly Dictionary _channelCache = new(); + private readonly HashSet _initializedPlanets = new(); + private readonly string _prefix = "r."; + + public Reactor() + { + _client = new ValourClient("https://api.valour.gg/"); + _client.SetupHttpClient(); + } + + public async Task StartAsync(string token) + { + await BotService.InitializeBotAsync( + token, + _client, + _channelCache, + _initializedPlanets, + _prefix + ); + } + } + + public class Program + { + public static async Task Main(string[] args) + { + Env.Load(); + + var token = Environment.GetEnvironmentVariable("TOKEN"); + + if (string.IsNullOrWhiteSpace(token)) + { + Console.WriteLine("TOKEN not set."); + return; + } + + var bot = new Reactor(); + await bot.StartAsync(token); + + await Task.Delay(Timeout.Infinite); + } + } +} \ No newline at end of file diff --git a/Reactor.csproj b/Reactor.csproj index f7b91fe..0209f13 100644 --- a/Reactor.csproj +++ b/Reactor.csproj @@ -9,6 +9,7 @@ + diff --git a/Services/BotService.cs b/Services/BotService.cs new file mode 100644 index 0000000..5478b6b --- /dev/null +++ b/Services/BotService.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Services +{ + public static class BotService + { + public static async Task InitializeBotAsync( + string token, + ValourClient client, + Dictionary channelCache, + HashSet initializedPlanets, + string prefix) + { + //Check token is valid + if (string.IsNullOrWhiteSpace(token)) + { + Console.WriteLine("TOKEN not set."); + return; + } + + //Login to the bot + 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})"); + + //Initialize the Database + await DatabaseService.InitializeAsync(); + await ReactionRoleService.LoadAllAsync(client); + Console.WriteLine($"Loaded {ReactionRoleService.Messages.Count} reaction messages into memory."); + + //Initialize the Planets + await PlanetService.InitializePlanetsAsync(client, channelCache, initializedPlanets); + client.PlanetService.JoinedPlanetsUpdated += async () => + { + await PlanetService.InitializePlanetsAsync(client, channelCache, initializedPlanets); + }; + + //Fucking pain in my ass is what this is, i dont even wanna comment on it + foreach (var reactionMessage in ReactionRoleService.Messages.Values.ToList()) + { + try + { + if(!channelCache.TryGetValue(reactionMessage.ChannelId, out var channel)) + { + Console.WriteLine($"Channel {reactionMessage.ChannelId} not found, pruning message {reactionMessage.MessageId}."); + await ReactionRoleService.RemoveMessageAsync(reactionMessage.MessageId); + continue; + } + + var messages = await channel.GetMessagesAsync(reactionMessage.MessageId + 1, 50); + Console.WriteLine($"Fetched {messages?.Count ?? 0} messages from channel {reactionMessage.ChannelId}"); + var match = messages?.FirstOrDefault(m => m.Id == reactionMessage.MessageId); + + if (match == null) + { + Console.WriteLine($"Message {reactionMessage.MessageId} not found, pruning."); + await ReactionRoleService.RemoveMessageAsync(reactionMessage.MessageId); + continue; + } + + ReactionRoleService.SubscribeToMessageReactions(client, channelCache, match); + Console.WriteLine($"Subscribed to reactions for message {reactionMessage.MessageId}"); + } catch (Exception ex) + { + Console.WriteLine($"Error setting up message {reactionMessage.MessageId}: {ex.Message}, pruning."); + await ReactionRoleService.RemoveMessageAsync(reactionMessage.MessageId); + } + } + + client.MessageService.MessageReceived += async (message) => + { + await MessageService.HandleMessageAsync(client, channelCache, message, prefix); + }; + + client.MessageService.MessageDeleted += async (message) => + { + if (ReactionRoleService.Messages.ContainsKey(message.Id)) + { + await ReactionRoleService.RemoveMessageAsync(message.Id); + ReactionRoleService.ResetSubscription(message.Id); + Console.WriteLine($"Reaction message {message.Id} was deleted, removed from DB and cache."); + } + }; + + + + //Bot is active and ready + Console.WriteLine("Bot ready and listening..."); + } + } +} \ No newline at end of file diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs new file mode 100644 index 0000000..76eee89 --- /dev/null +++ b/Services/DatabaseService.cs @@ -0,0 +1,40 @@ +using Microsoft.Data.Sqlite; + +namespace Reactor.Services +{ + public static class DatabaseService + { + private static string _connectionString = "Data Source=reactor.db"; + + public static async Task InitializeAsync() + { + //Connection frfr + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + //ReactionMessages Table + var cmd1 = connection.CreateCommand(); + cmd1.CommandText = + "CREATE TABLE IF NOT EXISTS ReactionMessages (" + + "Id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "PlanetId INTEGER NOT NULL, " + + "ChannelId INTEGER NOT NULL, " + + "MessageId INTEGER NOT NULL UNIQUE, " + + "DeleteDelaySeconds INTEGER NOT NULL DEFAULT 5" + + ")"; + await cmd1.ExecuteNonQueryAsync(); + + //ReactionRoles table + var cmd2 = connection.CreateCommand(); + cmd2.CommandText = + "CREATE TABLE IF NOT EXISTS ReactionRoles (" + + "Id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "ReactionMessageId INTEGER NOT NULL, " + + "Emoji TEXT NOT NULL, " + + "RoleId INTEGER NOT NULL, " + + "FOREIGN KEY (ReactionMessageId) REFERENCES ReactionMessages(Id) ON DELETE CASCADE" + + ")"; + await cmd2.ExecuteNonQueryAsync(); + } + } +} \ No newline at end of file diff --git a/Services/MessageService.cs b/Services/MessageService.cs new file mode 100644 index 0000000..36ba40e --- /dev/null +++ b/Services/MessageService.cs @@ -0,0 +1,161 @@ +using Reactor.Commands; +using Valour.Sdk.Client; +using Valour.Sdk.Models; +using Valour.Shared.Authorization; + +namespace Reactor.Services +{ + public static class MessageService + { + public static async Task HandleMessageAsync( + ValourClient client, + Dictionary channelCache, + Message message, + string prefix) + { + //Bot cant reply to its self hahahahahaha loser! + if (message.AuthorUserId == client.Me.Id) return; + + string content = message.Content ?? ""; + if (string.IsNullOrWhiteSpace(content)) return; + if (!content.StartsWith(prefix)) return; + + long channelId = message.ChannelId; + + var member = await message.FetchAuthorMemberAsync(); + string memberPing = member != null ? $"«@m-{member.Id}»" : ""; + + bool hasPermission = await HasPermissionAsync(member, channelCache[channelId]); + + string withoutPrefix = content.Substring(prefix.Length); + + var parts = withoutPrefix.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return; + + string command = parts[0].ToLower(); + string[] args = parts[1..]; + + //Commands.. duh.. + switch (command) + { + case "help": + await HelpCommand.Execute(channelCache, channelId, prefix, memberPing); + break; + + case "source": + await SourceCommand.Execute(channelCache, channelId, memberPing); + break; + + case "create": + + if (!hasPermission) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} You need Manage Roles or Full Control to use this command."); + return; + } + + if (parts.Length < 2) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Usage: {prefix}create "); + return; + } + + if (message.PlanetId == null) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Could not detect planet ID for this message. Please contact me if you are seeing this."); + return; + } + + var messageText = string.Join(' ', parts[1..]); + await CreateCommand.Execute(client, channelCache, channelId, messageText, message.PlanetId.Value); + break; + + case "delete": + + if (!hasPermission) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} You need Manage Roles or Full Control to use this command."); + return; + } + + if (parts.Length < 2) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Usage: {prefix}delete "); + return; + } + + if (!long.TryParse(parts[1], out var deleteMsgId)) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Invalid message ID."); + return; + } + + await DeleteCommand.Execute(client, channelCache, channelId, deleteMsgId); + break; + + case "add": + + if (!hasPermission) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} You need Manage Roles or Full Control to use this command."); + return; + } + + if (parts.Length < 4) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Usage: {prefix}add "); + return; + } + + if (!long.TryParse(parts[1], out var msgId)) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Invalid message ID."); + return; + } + + var emoji = parts[2]; + + if (!long.TryParse(parts[3], out var roleId)) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Invalid role ID."); + return; + } + + await AddCommand.Execute(client, channelCache, channelId, msgId, emoji, roleId); + break; + + case "remove": + + if (!hasPermission) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} You need Manage Roles or Full Control to use this command."); + return; + } + + if (parts.Length < 3) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Usage: {prefix}remove "); + return; + } + + if (!long.TryParse(parts[1], out var removeMsgId)) + { + await channelCache[channelId].SendMessageAsync($"{memberPing} Invalid message ID."); + return; + } + + var removeEmoji = parts[2]; + await RemoveCommand.Execute(client, channelCache, channelId, removeMsgId, removeEmoji); + break; + } + } + + private static async Task HasPermissionAsync(PlanetMember member, Channel channel) + { + if (member == null) return false; + + return member.HasPermission(PlanetPermissions.FullControl) || + member.HasPermission(PlanetPermissions.ManageRoles); + } + } +} \ No newline at end of file diff --git a/Services/PlanetService.cs b/Services/PlanetService.cs new file mode 100644 index 0000000..360d589 --- /dev/null +++ b/Services/PlanetService.cs @@ -0,0 +1,63 @@ +using Valour.Sdk.Client; +using Valour.Sdk.ModelLogic; +using Valour.Sdk.Models; +using Valour.Shared.Models; + +namespace Reactor.Services +{ + public static class PlanetService + { + public static async Task InitializePlanetsAsync( + ValourClient client, + Dictionary channelCache, + HashSet 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 == ChannelTypeEnum.PlanetChat) + { + await channel.OpenWithResult("Reactor"); + Console.WriteLine($"Realtime opened for: {planet.Name} -> {channel.Name}"); + } + } + + Action> channelChangedHandler = (evt) => + { + _ = Task.Run(async () => + { + foreach (var channel in planet.Channels) + { + if (channelCache.ContainsKey(channel.Id)) + continue; + + channelCache[channel.Id] = channel; + + if (channel.ChannelType == ChannelTypeEnum.PlanetChat) + { + await channel.OpenWithResult("Reactor"); + Console.WriteLine($"New channel detected: {planet.Name} -> {channel.Name}"); + } + } + }); + }; + + planet.Channels.Changed += channelChangedHandler; + + initializedPlanets.Add(planet.Id); + } + } + } +} \ No newline at end of file diff --git a/Services/ReactionRoleService.cs b/Services/ReactionRoleService.cs new file mode 100644 index 0000000..8a2784d --- /dev/null +++ b/Services/ReactionRoleService.cs @@ -0,0 +1,265 @@ +using Microsoft.Data.Sqlite; +using Reactor.Models; +using Valour.Sdk.Client; +using Valour.Sdk.Models; + +namespace Reactor.Services +{ + public static class ReactionRoleService + { + private static readonly string _connectionString = "Data source=reactor.db"; + + //Memory Cache + public static Dictionary Messages { get; private set; } = new(); + + //Load all messages and reaction role mappings + public static async Task LoadAllAsync(ValourClient client) + { + Messages.Clear(); + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + //Load messages + var cmdMsg = connection.CreateCommand(); + cmdMsg.CommandText = "SELECT Id, PlanetId, ChannelId, MessageId, DeleteDelaySeconds FROM ReactionMessages"; + using var readerMsg = await cmdMsg.ExecuteReaderAsync(); + var tempMessages = new Dictionary(); + + while (await readerMsg.ReadAsync()) + { + var msg = new ReactionMessage + { + Id = readerMsg.GetInt64(0), + PlanetId = readerMsg.GetInt64(1), + ChannelId = readerMsg.GetInt64(2), + MessageId = readerMsg.GetInt64(3), + DeleteDelaySeconds = readerMsg.GetInt32(4), + Reactions = new Dictionary() + }; + tempMessages[msg.Id] = msg; + } + + //Load reaction role mappings + var cmdRoles = connection.CreateCommand(); + cmdRoles.CommandText = "SELECT ReactionMessageId, Emoji, RoleId FROM ReactionRoles"; + using var readerRoles = await cmdRoles.ExecuteReaderAsync(); + while (await readerRoles.ReadAsync()) + { + var msgId = readerRoles.GetInt64(0); + var emoji = readerRoles.GetString(1); + var roleId = readerRoles.GetInt64(2); + + if (tempMessages.ContainsKey(msgId)) + { + tempMessages[msgId].Reactions[emoji] = roleId; + } + } + + //Build lookup by MessageId + Messages = tempMessages.Values.ToDictionary(m => m.MessageId, m => m); + } + + public static async Task AddReactionAsync(long messageId, string emoji, long roleId) + { + if (!Messages.TryGetValue(messageId, out var msg)) + return; + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT INTO ReactionRoles (ReactionMessageId, Emoji, RoleId) VALUES (@msgId, @emoji, @roleId)"; + cmd.Parameters.AddWithValue("@msgId", msg.Id); + cmd.Parameters.AddWithValue("@emoji", emoji); + cmd.Parameters.AddWithValue("@roleId", roleId); + + await cmd.ExecuteNonQueryAsync(); + + //Update Cache + msg.Reactions[emoji] = roleId; + } + + public static async Task RemoveReactionAsync(long messageId, string emoji) + { + if (!Messages.TryGetValue(messageId, out var msg)) + return; + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM ReactionRoles WHERE ReactionMessageId = @msgId AND Emoji = @emoji"; + cmd.Parameters.AddWithValue("@msgId", msg.Id); + cmd.Parameters.AddWithValue("@emoji", emoji); + await cmd.ExecuteNonQueryAsync(); + + msg.Reactions.Remove(emoji); + Console.WriteLine($"Removed reaction {emoji} from message {messageId}."); + } + + private static readonly HashSet _subscribedMessages = new(); + + public static void SubscribeToMessageReactions( + ValourClient client, + Dictionary channelCache, + Message syncedMessage) + { + if (!_subscribedMessages.Add(syncedMessage.Id)) + { + Console.WriteLine($"Already subscribed to message {syncedMessage.Id}, skipping."); + return; + } + + Action addhandler = (reaction) => + { + _ = Task.Run(async () => + { + try + { + Console.WriteLine($"Reaction added: {reaction.Emoji} by {reaction.AuthorUserId}"); + await HandleReactionAddedAsync(client, channelCache, syncedMessage, reaction); + } + catch (Exception ex) + { + Console.WriteLine($"Error in addhandler: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + }); + }; + + Action removehandler = (reaction) => + { + _ = Task.Run(async () => + { + try + { + Console.WriteLine($"Reaction removed: {reaction.Emoji} by {reaction.AuthorUserId}"); + await HandleReactionRemovedAsync(client, channelCache, syncedMessage, reaction); + } + catch (Exception ex) + { + Console.WriteLine($"Error in removehandler: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + } + }); + }; + + syncedMessage.ReactionAdded += addhandler; + syncedMessage.ReactionRemoved += removehandler; + } + + public static void ResetSubscription(long messageId) + { + _subscribedMessages.Remove(messageId); + } + + public static async Task HandleReactionAddedAsync( + ValourClient client, + Dictionary channelCache, + Message message, + MessageReaction reaction) + { + if (!Messages.TryGetValue(message.Id, out var cachedMsg)) + return; + + if (!channelCache.TryGetValue(cachedMsg.ChannelId, out var channel)) + return; + + if (!cachedMsg.Reactions.TryGetValue(reaction.Emoji, out var roleId)) + return; + + var role = channel.Planet.Roles.FirstOrDefault(r => r.Id == roleId); + string roleName = role != null ? role.Name : $"Role {roleId}"; + + var member = await channel.Planet.FetchMemberByUserAsync(reaction.AuthorUserId); + if (member == null) return; + + // Check if member already has the role + if (member.Roles.Any(r => r.Id == roleId)) + { + Console.WriteLine($"User {reaction.AuthorUserId} already has role {roleId}, skipping."); + return; + } + + await member.AddRoleAsync(roleId); + + var confirm = await channel.SendMessageAsync($"«@m-{member.Id}» has been added to the role {roleName}"); + if (confirm.Success && confirm.Data != null) + { + await Task.Delay(cachedMsg.DeleteDelaySeconds * 1000); + if (client.Cache.Messages.TryGet(confirm.Data.Id, out var cachedConfirm)) + { + await cachedConfirm.DeleteAsync(); + } else + { + Console.WriteLine($"Could not find confirmation message {confirm.Data.Id} in cache."); + } + } + } + + public static async Task HandleReactionRemovedAsync( + ValourClient client, + Dictionary channelCache, + Message message, + MessageReaction reaction) + { + if (!Messages.TryGetValue(message.Id, out var cachedMsg)) + return; + + if (!channelCache.TryGetValue(cachedMsg.ChannelId, out var channel)) + return; + + if (!cachedMsg.Reactions.TryGetValue(reaction.Emoji, out var roleId)) + return; + + var role = channel.Planet.Roles.FirstOrDefault(r => r.Id == roleId); + string roleName = role != null ? role.Name : $"Role {roleId}"; + + var member = await channel.Planet.FetchMemberByUserAsync(reaction.AuthorUserId); + if (member == null) return; + + // Check if member actually has the role before removing + if (!member.Roles.Any(r => r.Id == roleId)) + { + Console.WriteLine($"User {reaction.AuthorUserId} does not have role {roleId}, skipping."); + return; + } + + await member.RemoveRoleAsync(roleId); + + var confirm = await channel.SendMessageAsync($"«@m-{member.Id}» has been removed from the role {roleName}"); + if (confirm.Success && confirm.Data != null) + { + await Task.Delay(cachedMsg.DeleteDelaySeconds * 1000); + if (client.Cache.Messages.TryGet(confirm.Data.Id, out var cachedConfirm)) + { + await cachedConfirm.DeleteAsync(); + } else + { + Console.WriteLine($"Could not find confirmation message {confirm.Data.Id} in cache."); + } + } + } + + public static async Task RemoveMessageAsync(long messageId) + { + if (!Messages.TryGetValue(messageId, out var msg)) return; + + using var connection = new SqliteConnection(_connectionString); + await connection.OpenAsync(); + + var cmd = connection.CreateCommand(); + cmd.CommandText = @" + DELETE FROM ReactionRoles WHERE ReactionMessageId = @id; + DELETE FROM ReactionMessages WHERE MessageId = @messageId; + "; + cmd.Parameters.AddWithValue("@id", msg.Id); + cmd.Parameters.AddWithValue("@messageId", messageId); + await cmd.ExecuteNonQueryAsync(); + + Messages.Remove(messageId); + Console.WriteLine($"Removed stale reaction message {messageId} from DB and memory."); + } + } +} \ No newline at end of file diff --git a/utils.cs b/utils.cs deleted file mode 100644 index 8ff18a9..0000000 --- a/utils.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Globalization; -using System.Text.Json; -using Valour.Sdk.Models; - -namespace Reactor -{ - 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 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(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(); - } - }; -}; \ No newline at end of file