Merge pull request #1 from SkyJoshua/Dev

Dev
This commit is contained in:
SkyJoshua
2026-03-11 01:24:57 +00:00
committed by GitHub
17 changed files with 938 additions and 224 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ bin/
obj/
Reactor.sln
.env
reactor.db

53
Commands/AddCommand.cs Normal file
View File

@@ -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<long, Channel> 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}");
}
}
}

67
Commands/CreateCommand.cs Normal file
View File

@@ -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<long, Channel> 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<string, long>()
};
Console.WriteLine($"Created reaction message {sentMessage.Id} in channel {channelId}");
}
}
}

47
Commands/DeleteCommand.cs Normal file
View File

@@ -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<long, Channel> 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.");
}
}
}

View File

@@ -2,12 +2,18 @@ using Valour.Sdk.Models;
namespace Reactor.Commands;
public static class HelpComamnd
public static class HelpCommand
{
public static async Task Execute(Dictionary<long, Channel> 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))
{

50
Commands/RemoveCommand.cs Normal file
View File

@@ -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<long, Channel> 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}.");
}
}
}

14
Commands/SourceCommand.cs Normal file
View File

@@ -0,0 +1,14 @@
using Valour.Sdk.Models;
namespace Reactor.Commands;
public static class SourceCommand
{
public static async Task Execute(Dictionary<long, Channel> 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");
}
}
}

View File

@@ -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<string, long> Reactions { get; set; } = new();
}
}

View File

@@ -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<long, Channel> _channelCache = new();
private HashSet<long> _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);
}
}
}

53
Reactor.cs Normal file
View File

@@ -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<long, Channel> _channelCache = new();
private readonly HashSet<long> _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);
}
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.3" />
<PackageReference Include="System.Data.SQLite" Version="2.0.2" />
<PackageReference Include="Valour.Sdk" Version="0.5.19" />
</ItemGroup>

97
Services/BotService.cs Normal file
View File

@@ -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<long, Channel> channelCache,
HashSet<long> 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...");
}
}
}

View File

@@ -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();
}
}
}

161
Services/MessageService.cs Normal file
View File

@@ -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<long, Channel> 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 <default message text>");
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 <messageId>");
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 <messageId> <emoji> <roleId>");
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 <messageId> <emoji>");
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<bool> HasPermissionAsync(PlanetMember member, Channel channel)
{
if (member == null) return false;
return member.HasPermission(PlanetPermissions.FullControl) ||
member.HasPermission(PlanetPermissions.ManageRoles);
}
}
}

63
Services/PlanetService.cs Normal file
View File

@@ -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<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 == ChannelTypeEnum.PlanetChat)
{
await channel.OpenWithResult("Reactor");
Console.WriteLine($"Realtime opened for: {planet.Name} -> {channel.Name}");
}
}
Action<IModelEvent<Channel>> 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);
}
}
}
}

View File

@@ -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<long, ReactionMessage> 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<long, ReactionMessage>();
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<string, long>()
};
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<long> _subscribedMessages = new();
public static void SubscribeToMessageReactions(
ValourClient client,
Dictionary<long, Channel> channelCache,
Message syncedMessage)
{
if (!_subscribedMessages.Add(syncedMessage.Id))
{
Console.WriteLine($"Already subscribed to message {syncedMessage.Id}, skipping.");
return;
}
Action<MessageReaction> 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<MessageReaction> 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<long, Channel> 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<long, Channel> 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.");
}
}
}

View File

@@ -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<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();
}
};
};