1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ bin/
|
||||
obj/
|
||||
Reactor.sln
|
||||
.env
|
||||
reactor.db
|
||||
|
||||
53
Commands/AddCommand.cs
Normal file
53
Commands/AddCommand.cs
Normal 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
67
Commands/CreateCommand.cs
Normal 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
47
Commands/DeleteCommand.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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))
|
||||
{
|
||||
await channel.SendMessageAsync($"{memberPing}\n{helpMessage}");
|
||||
}
|
||||
if (channelCache.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
await channel.SendMessageAsync($"{memberPing}\n{helpMessage}");
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Commands/RemoveCommand.cs
Normal file
50
Commands/RemoveCommand.cs
Normal 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
14
Commands/SourceCommand.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
Models/ReactionRoleModels.cs
Normal file
13
Models/ReactionRoleModels.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
135
Program.cs
135
Program.cs
@@ -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
53
Reactor.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
97
Services/BotService.cs
Normal 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...");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Services/DatabaseService.cs
Normal file
40
Services/DatabaseService.cs
Normal 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
161
Services/MessageService.cs
Normal 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
63
Services/PlanetService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
265
Services/ReactionRoleService.cs
Normal file
265
Services/ReactionRoleService.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
82
utils.cs
82
utils.cs
@@ -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();
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user