Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/Commands/PortalCommandGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ namespace WindowsAppCommunity.Discord.ServerCompanion;
public class PortalCommandGroup(IInteractionContext interactionContext, IFeedbackService feedbackService, IDiscordRestInteractionAPI interactionAPI, IDiscordRestChannelAPI channelApi, IDiscordRestGuildAPI guildApi, ICommandContext context) : Remora.Commands.Groups.CommandGroup
{
[Command("portal")]
[Description("Create linked portals between two channels for easy cross-navigation")]
[SuppressInteractionResponse(true)]
public async Task<IResult> PortalAsync(IChannel destChannel)
public async Task<IResult> PortalAsync([Description("Destination channel to link")] IChannel destinationChannel)
{
try
{
Expand Down Expand Up @@ -63,7 +64,7 @@ await interactionAPI.CreateInteractionResponseAsync(

var guildId = sourceChannelResult.Entity.GuildID.Value;

var destinationChannelId = destChannel.ID;
var destinationChannelId = destinationChannel.ID;

if (sourceChannelId == destinationChannelId)
{
Expand Down
393 changes: 393 additions & 0 deletions src/Commands/SpamFilterCommandGroup.cs

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions src/Extensions/ImageHashExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Text;

namespace WindowsAppCommunity.Discord.ServerCompanion.Extensions;

/// <summary>
/// Extension methods for perceptual image hashing.
/// </summary>
public static class ImageHashExtensions
{
/// <summary>
/// Computes a perceptual hash of an image from a stream.
/// Uses 8x8 resize → grayscale → average → bits → hex hash algorithm.
/// </summary>
/// <param name="imageStream">The stream containing the image data.</param>
/// <returns>Hex string representation of the perceptual hash, or null if image invalid/corrupt.</returns>
public static string? ComputePerceptualHash(this Stream imageStream)
{
try
{
using var image = Image.Load<Rgba32>(imageStream);

// Resize to 8x8 pixels
image.Mutate(x => x.Resize(8, 8));

// Convert to grayscale
image.Mutate(x => x.Grayscale());

// Calculate mean pixel value
var pixelValues = new List<byte>();
for (int y = 0; y < 8; y++)
{
for (int x = 0; x < 8; x++)
{
var pixel = image[x, y];
// Grayscale: R=G=B, so we can use any channel
pixelValues.Add(pixel.R);
}
}

var mean = pixelValues.Average(b => (double)b);

// Generate hash: 1 if pixel > mean, 0 otherwise
var bits = new StringBuilder();
foreach (var value in pixelValues)
{
bits.Append(value > mean ? '1' : '0');
}

// Convert 64 bits to hex string (8 bytes)
var hashBytes = new byte[8];
for (int i = 0; i < 8; i++)
{
var byteString = bits.ToString(i * 8, 8);
hashBytes[i] = Convert.ToByte(byteString, 2);
}

return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
catch
{
// Return null for corrupt/invalid images
return null;
}
}
}
33 changes: 33 additions & 0 deletions src/Extensions/MessageHashExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Security.Cryptography;
using System.Text;

namespace WindowsAppCommunity.Discord.ServerCompanion.Extensions;

/// <summary>
/// Extension methods for hashing message text content.
/// </summary>
public static class MessageHashExtensions
{
/// <summary>
/// Normalizes text content for consistent hashing (lowercase, trim whitespace).
/// </summary>
/// <param name="content">The text content to normalize.</param>
/// <returns>Normalized text content.</returns>
public static string NormalizeText(this string content)
{
return content.Trim().ToLowerInvariant();
}

/// <summary>
/// Generates a SHA256 hash of the normalized text content.
/// </summary>
/// <param name="content">The text content to hash.</param>
/// <returns>Hex string representation of the hash.</returns>
public static string ComputeTextHash(this string content)
{
var normalized = content.NormalizeText();
var bytes = Encoding.UTF8.GetBytes(normalized);
var hashBytes = SHA256.HashData(bytes);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
}
18 changes: 15 additions & 3 deletions src/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using OwlCore.Diagnostics;
Expand All @@ -16,6 +16,8 @@
using WindowsAppCommunity.Discord.ServerCompanion.Autocomplete;
using WindowsAppCommunity.Discord.ServerCompanion.Commands;
using WindowsAppCommunity.Discord.ServerCompanion.Interactivity;
using WindowsAppCommunity.Discord.ServerCompanion.Responders;
using WindowsAppCommunity.Discord.ServerCompanion.Settings;

// Cancellation setup
var cancellationSource = new CancellationTokenSource();
Expand Down Expand Up @@ -45,7 +47,9 @@
false;
#endif

var env = isDebug ? "dev" : "prod";
// Allow forcing production mode via --prod flag
var forceProd = args.Contains("--prod");
var env = (isDebug && !forceProd) ? "dev" : "prod";

var configProvider = new ConfigurationBuilder()
.AddUserSecrets<Program>()
Expand All @@ -65,18 +69,26 @@
var appData = new SystemFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
var serverCompanionData = (SystemFolder)await appData.CreateFolderAsync("WindowsAppCommunity.Discord.ServerCompanion", overwrite: false, cancelTok);

// Initialize rate limiter settings
var rateLimitFolder = (SystemFolder)await serverCompanionData.CreateFolderAsync("RateLimitSettings", overwrite: false, cancelTok);
var rateLimitSettings = new RateLimitSettings(rateLimitFolder, SystemTextSettingsSerializer.Singleton);
await rateLimitSettings.LoadAsync(cancelTok);

// Service setup and init
var services = new ServiceCollection()
.AddSingleton(config)
.AddSingleton(rateLimitSettings)
.AddDiscordGateway(_ => botToken)
.AddDiscordCommands(enableSlash: true)
.AddInteractivity()
.AddInteractionGroup<MyInteractions>()
.AddCommands()
.AddCommandTree()
.WithCommandGroup<PortalCommandGroup>()
.WithCommandGroup<SpamFilterCommandGroup>()
.Finish()
.AddResponder<PingPongResponder>()
.AddResponder<CrossChannelRateLimitResponder>()
.Configure<DiscordGatewayClientOptions>(g => g.Intents |= GatewayIntents.MessageContents)
.AddAutocompleteProvider<SampleAutoCompleteProvider>()
.BuildServiceProvider();
Expand Down Expand Up @@ -111,4 +123,4 @@
break;
}

Console.WriteLine("Shutting down");
Console.WriteLine("Shutting down");
18 changes: 18 additions & 0 deletions src/RateLimitSerializerContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
using WindowsAppCommunity.Discord.ServerCompanion.Settings;

namespace WindowsAppCommunity.Discord.ServerCompanion;

/// <summary>
/// JSON serialization context for rate limiter settings.
/// </summary>
[JsonSerializable(typeof(Dictionary<string, MessageBucket>))]
[JsonSerializable(typeof(HashSet<ulong>))]
[JsonSerializable(typeof(MessageBucket))]
[JsonSerializable(typeof(MessageMetadata))]
[JsonSerializable(typeof(List<MessageMetadata>))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(DateTimeOffset))]
public partial class RateLimitSerializerContext : JsonSerializerContext
{
}
127 changes: 127 additions & 0 deletions src/RateLimitSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using OwlCore.ComponentModel;
using OwlCore.Storage;
using Remora.Rest.Core;

namespace WindowsAppCommunity.Discord.ServerCompanion.Settings;

/// <summary>
/// Settings class for cross-channel spam rate limiter configuration and runtime tracking.
/// </summary>
public class RateLimitSettings : SettingsBase
{
/// <summary>
/// Initializes a new instance of the <see cref="RateLimitSettings"/> class.
/// </summary>
/// <param name="folder">The folder to store settings in.</param>
/// <param name="settingSerializer">The serializer to use for settings.</param>
public RateLimitSettings(IModifiableFolder folder, IAsyncSerializer<Stream> settingSerializer)
: base(folder, settingSerializer)
{
}

/// <summary>
/// Gets or sets the runtime tracking dictionary mapping content hashes to message buckets.
/// </summary>
public Dictionary<string, MessageBucket> MessageBuckets
{
get => GetSetting(() => new Dictionary<string, MessageBucket>());
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the number of duplicate messages required to trigger rate limit action.
/// </summary>
public int DuplicateThreshold
{
get => GetSetting(() => 2);
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the time window in minutes for tracking duplicates (TTL with debounce).
/// </summary>
public int TimeWindowMinutes
{
get => GetSetting(() => 30);
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the duration in minutes to mute users who exceed the threshold.
/// </summary>
public int MuteDurationMinutes
{
get => GetSetting(() => 60);
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the set of channel IDs where spam detection is disabled.
/// </summary>
public HashSet<ulong> ExemptChannelIds
{
get => GetSetting(() => new HashSet<ulong>());
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the set of role IDs that can crosspost freely.
/// </summary>
public HashSet<ulong> ExemptRoleIds
{
get => GetSetting(() => new HashSet<ulong>());
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the set of user IDs that can crosspost freely.
/// </summary>
public HashSet<ulong> ExemptUserIds
{
get => GetSetting(() => new HashSet<ulong>());
set => SetSetting(value);
}

/// <summary>
/// Gets or sets the set of channel IDs where staff notifications are sent.
/// </summary>
public HashSet<ulong> NotificationChannelIds
{
get => GetSetting(() => new HashSet<ulong>());
set => SetSetting(value);
}

/// <summary>
/// Cleans up expired message buckets based on the current time window.
/// </summary>
public void CleanupExpiredBuckets()
{
var buckets = MessageBuckets;
var cutoff = DateTimeOffset.UtcNow.AddMinutes(-TimeWindowMinutes);

var expiredHashes = buckets
.Where(kvp => kvp.Value.LastUpdated < cutoff)
.Select(kvp => kvp.Key)
.ToList();

foreach (var hash in expiredHashes)
{
buckets.Remove(hash);
}

if (expiredHashes.Count > 0)
{
MessageBuckets = buckets;
}
}
}

/// <summary>
/// Represents a bucket of duplicate messages for a specific content hash.
/// </summary>
public record MessageBucket(List<MessageMetadata> Messages, DateTimeOffset LastUpdated);

/// <summary>
/// Represents metadata for a tracked message.
/// </summary>
public record MessageMetadata(Snowflake ChannelId, Snowflake MessageId, Snowflake UserId, DateTimeOffset Timestamp);
Loading