diff --git a/MiniUtils/Classes/Emojis.cs b/MiniUtils/Classes/Emojis.cs
new file mode 100644
index 0000000..aef8904
--- /dev/null
+++ b/MiniUtils/Classes/Emojis.cs
@@ -0,0 +1,17 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace MiniUtils.Classes;
+
+[SuppressMessage("ReSharper", "UnusedMember.Local")]
+public class Emojis {
+ // Useful page: https://www.compart.com/en/unicode
+
+ public const string ThumbsUp = "\ud83d\udc4d\ufe0e";
+ public const string Recycle = "\u267b\ufe0e";
+ public const string Bullseye = "\u25ce\ufe0e";
+ public const string RightArrowWithTail = "\u21a3\ufe0e";
+ public const string Prohibited = "\ud83d\udec7\ufe0e";
+ public const string Wastebasket = "\ud83d\uddd1\ufe0e";
+ public const string Hourglass = "\u231b\ufe0e";
+ public const string Checkmark = "\u2705\ufe0e";
+}
\ No newline at end of file
diff --git a/MiniUtils/Classes/IgnoreListEventContentWithDisabled.cs b/MiniUtils/Classes/IgnoreListEventContentWithDisabled.cs
new file mode 100644
index 0000000..7aaeb0a
--- /dev/null
+++ b/MiniUtils/Classes/IgnoreListEventContentWithDisabled.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+using LibMatrix.EventTypes.Spec;
+
+namespace MiniUtils.Classes;
+
+public class IgnoredUserListEventContentWithDisabled : IgnoredUserListEventContent {
+ [JsonPropertyName("gay.rory.MiniUtils.disabled_ignored_users")]
+ public Dictionary<string, IgnoredUserContent> DisabledIgnoredUsers { get; set; } = new();
+}
\ No newline at end of file
diff --git a/MiniUtils/Commands/ExperimentalFeaturesCommand.cs b/MiniUtils/Commands/ExperimentalFeaturesCommand.cs
new file mode 100644
index 0000000..de5d035
--- /dev/null
+++ b/MiniUtils/Commands/ExperimentalFeaturesCommand.cs
@@ -0,0 +1,68 @@
+using System.Globalization;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Extensions;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MiniUtils.Utilities;
+
+namespace SynapseDataMiner.Commands;
+
+public class ExperimentalFeaturesCommand(MscInfoProvider mscInfoProvider) : ICommand {
+ public string Name { get; } = "experimental";
+ public string[]? Aliases { get; } = [];
+ public string Description { get; } = "List experimental synapse features";
+ public bool Unlisted { get; } = false;
+
+ private static readonly Regex ExperimentalGetMultilineRegex = new("""experimental\.get\(\s*"(?<key>.+?)"(,\s*(?<defaultValue>.+?))?\s*\)""",
+ RegexOptions.Compiled | RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture);
+
+ private static readonly Regex MscNumberRegex = new(@"msc(?<id>\d+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ public async Task Invoke(CommandContext ctx) {
+ var hc = new MatrixHttpClient();
+ var resp = await hc.GetAsync("https://raw.githubusercontent.com/element-hq/synapse/develop/synapse/config/experimental.py");
+ var data = await resp.Content.ReadAsStringAsync();
+
+ var msb = new MessageBuilder("m.notice");
+ List<SynapseFeature> features = [];
+
+ foreach (Match match in ExperimentalGetMultilineRegex.Matches(data)) {
+ var mscMatch = MscNumberRegex.Match(match.Groups["key"].Value);
+ features.Add(new() {
+ ConfigKey = match.Groups["key"].Value,
+ DefaultValue = match.Groups["defaultValue"]?.Value,
+ MscInfo = mscMatch.Success ? await mscInfoProvider.GetMscInfo(int.Parse(mscMatch.Groups["id"].Value)) : null
+ });
+ }
+
+ msb.WithTable(tb => {
+ tb.WithTitle("Available features", 2);
+ tb.WithRow(rb => {
+ rb.WithCell("Feature flag");
+ rb.WithCell("Description");
+ });
+
+ foreach (var feature in features.OrderBy(x => x.ConfigKey)) {
+ tb.WithRow(rb => {
+ rb.WithCell($"{feature.ConfigKey}<br/>Default: {feature.DefaultValue}");
+ rb.WithCell(feature.MscInfo is not null ? feature.MscInfo.ToHtml() : "No MSC info found");
+ });
+ }
+ });
+
+ await ctx.Room.SendMessageEventAsync(msb.Build());
+ Console.WriteLine(msb.Build().FormattedBody);
+ }
+
+ private class SynapseFeature {
+ public string ConfigKey { get; set; }
+ public string? DefaultValue { get; set; }
+ public MscInfoProvider.MscInfo? MscInfo { get; set; }
+ }
+
+
+}
\ No newline at end of file
diff --git a/MiniUtils/Commands/IgnoreCommand.cs b/MiniUtils/Commands/IgnoreCommand.cs
new file mode 100644
index 0000000..4b3fe86
--- /dev/null
+++ b/MiniUtils/Commands/IgnoreCommand.cs
@@ -0,0 +1,62 @@
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MiniUtils.Classes;
+using MiniUtils.Services;
+
+namespace MiniUtils.Commands;
+
+public class IgnoreCommand(IgnoreListManager ignoreListManager) : ICommand {
+ public string Name => "ignore";
+
+ public string[]? Aliases => ["ignorelist"];
+
+ public string Description => "Manage ignore list";
+
+ public bool Unlisted => false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var ignoreList = await ctx.Homeserver.GetAccountDataOrNullAsync<IgnoredUserListEventContentWithDisabled>(IgnoredUserListEventContent.EventId) ?? new();
+ if (ctx.Args.Length == 0)
+ await Summarize(ctx, ignoreList);
+ else if (ctx.Args is ["disable", "all"] or ["disableall"] or ["disall"]) {
+ var count = await ignoreListManager.DisableAll();
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.Recycle} {count}");
+ }
+ else if (ctx.Args is ["enable", "all"] or ["enableall"] or ["enall"]) {
+ var count = await ignoreListManager.EnableAll();
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.Bullseye} {count}");
+ }
+ else if (ctx.Args is ["disable", "joined"] or ["disablejoined"]) {
+ var count = await ignoreListManager.MoveList(false, (await ctx.Room.GetMembersListAsync("join")).Select(x => x.StateKey!));
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ else if (ctx.Args is ["enable", "joined"] or ["enablejoined"]) {
+ var count = await ignoreListManager.MoveList(true, (await ctx.Room.GetMembersListAsync("join")).Select(x => x.StateKey!));
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ else if (ctx.Args is ["disable", "local"] or ["disablelocal"] or ["disable", "room"] or ["disableroom"]) {
+ var count = await ignoreListManager.MoveList(false, (await ctx.Room.GetMembersListAsync()).Select(x => x.StateKey!));
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ else if (ctx.Args is ["enable", "local"] or ["enablelocal"] or ["enable", "room"] or ["enableroom"]) {
+ var count = await ignoreListManager.MoveList(true, (await ctx.Room.GetMembersListAsync()).Select(x => x.StateKey!));
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ else if (ctx.Args is ["disable", .. var itemsToDisable]) {
+ var count = await ignoreListManager.MoveList(false, itemsToDisable);
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ else if (ctx.Args is ["enable", .. var itemsToEnable]) {
+ var count = await ignoreListManager.MoveList(true, itemsToEnable);
+ await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.RightArrowWithTail} {count}");
+ }
+ }
+
+ private async Task Summarize(CommandContext ctx, IgnoredUserListEventContentWithDisabled ignoreList) {
+ var msb = new MessageBuilder()
+ .WithBody($"Ignored users: {ignoreList.IgnoredUsers.Count}").WithNewline()
+ .WithBody($"Disabled ignores: {ignoreList.DisabledIgnoredUsers.Count}").WithNewline();
+ await ctx.Room.SendMessageEventAsync(msb.Build());
+ }
+ }
\ No newline at end of file
diff --git a/MiniUtils/Commands/MscCommand.cs b/MiniUtils/Commands/MscCommand.cs
new file mode 100644
index 0000000..62f1bd7
--- /dev/null
+++ b/MiniUtils/Commands/MscCommand.cs
@@ -0,0 +1,24 @@
+using LibMatrix.Extensions;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MiniUtils.Utilities;
+
+namespace MiniUtils.Commands;
+
+public class MscCommand(MscInfoProvider mscInfoProvider) : ICommand {
+ public string Name { get; } = "msc";
+ public string[]? Aliases { get; } = [];
+ public string Description { get; } = "Get MSC info";
+ public bool Unlisted { get; } = false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var msb = new MessageBuilder("m.notice");
+ var id = int.Parse(ctx.Args[0]);
+ var mscInfo = await mscInfoProvider.GetMscInfo(id);
+
+ msb.WithBody(mscInfo?.ToHtml() ?? "No info found!");
+
+ await ctx.Reply(msb.Build());
+ Console.WriteLine(msb.Build().FormattedBody);
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/Commands/RedactCommand.cs b/MiniUtils/Commands/RedactCommand.cs
new file mode 100644
index 0000000..cba06c9
--- /dev/null
+++ b/MiniUtils/Commands/RedactCommand.cs
@@ -0,0 +1,80 @@
+using System.Collections.Frozen;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.RoomTypes;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MiniUtils.Classes;
+using MiniUtils.Services;
+
+namespace MiniUtils.Commands;
+
+public class RedactCommand(IgnoreListManager ignoreListManager) : ICommand {
+ public string Name => "redact";
+
+ public string[]? Aliases => [];
+
+ public string Description => "Redact all user's events";
+
+ public bool Unlisted => false;
+ private const string ThumbsUp = "\ud83d\udc4d\ufe0e";
+ private const string Recycle = "\u267b\ufe0e";
+ private const string Bullseye = "\u25ce\ufe0e";
+ private const string RightArrowWithTail = "\u21a3\ufe0e";
+
+ public async Task Invoke(CommandContext ctx) {
+ if (ctx.Args is ["banned"])
+ await RedactUsers(ctx, await ctx.Room.GetMemberIdsListAsync("ban"));
+ else if (ctx.Args is [.. var senders]) {
+ var sendersSet = senders.ToFrozenSet();
+ await RedactUsers(ctx, sendersSet);
+ }
+ }
+
+ private async Task RedactUsers(CommandContext ctx, FrozenSet<string> senders) {
+ var filter = new SyncFilter.EventFilter(senders: senders.ToList(), notTypes: ["m.room.redaction"]);
+ await ignoreListManager.MoveList(false, senders);
+ var count = 0;
+ List<Task> tasks = [];
+ await foreach (var resp in ctx.Room.GetManyMessagesAsync(filter: filter.ToJson(false, ignoreNull: true), chunkSize: 1000)) {
+ foreach (var chunk in resp.Chunk.Chunk(49)) {
+ foreach (var evt in chunk) {
+ if (!senders.Contains(evt.Sender!)) continue;
+ if (evt is { StateKey: not null, Type: not RoomMemberEventContent.EventId }) continue;
+ if (evt is { RawContent: null or { Count: 0 } }) continue;
+ tasks.Add(RedactEvent(ctx.Room, evt.EventId!));
+ count++;
+ }
+
+ if (tasks.Count > 0) {
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder()
+ .WithBody(
+ $"[{Emojis.Hourglass}] {Emojis.Recycle} {count} ({Emojis.Checkmark} {tasks.Count(t => t.IsCompletedSuccessfully)} {Emojis.Prohibited} {tasks.Count(t => t.IsFaulted)} {Emojis.Hourglass} {tasks.Count(t => t.Status == TaskStatus.Running)})")
+ .Build());
+ // await Task.WhenAll(tasks);
+ }
+ }
+ }
+
+ await Task.WhenAll(tasks);
+
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder().WithBody($"{Emojis.Recycle} {count}").Build());
+ // await ctx.Room.SendReactionAsync(ctx.MessageEvent.EventId!, $"{Emojis.Recycle} {count}");
+ }
+
+ private async Task RedactEvent(GenericRoom room, string eventId) {
+ bool success;
+ do {
+ try {
+ await room.RedactEventAsync(eventId);
+ success = true;
+ }
+ catch (Exception e) {
+ success = false;
+ Console.WriteLine($"Failed to redact event {eventId}: {e}");
+ }
+ } while (!success);
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/Commands/SpamCommand.cs b/MiniUtils/Commands/SpamCommand.cs
new file mode 100644
index 0000000..9f475eb
--- /dev/null
+++ b/MiniUtils/Commands/SpamCommand.cs
@@ -0,0 +1,44 @@
+using System.Collections.Frozen;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.EventTypes.Spec.State.RoomInfo;
+using LibMatrix.Filters;
+using LibMatrix.Helpers;
+using LibMatrix.RoomTypes;
+using LibMatrix.Utilities.Bot.Interfaces;
+using MiniUtils.Classes;
+using MiniUtils.Services;
+
+namespace MiniUtils.Commands;
+
+public class SpamCommand(IgnoreListManager ignoreListManager) : ICommand {
+ public string Name => "spam";
+
+ public string[]? Aliases => [];
+
+ public string Description => "Redact all user's events";
+
+ public bool Unlisted => false;
+
+ public async Task Invoke(CommandContext ctx) {
+ var tasks = Enumerable.Range(0, 10000)
+ .Select(i => SendMessage(ctx.Room, i.ToString()))
+ .ToList();
+ await Task.WhenAll(tasks);
+ await ctx.Room.SendMessageEventAsync(new MessageBuilder().WithBody($"{Emojis.Recycle}").Build());
+ }
+
+ private async Task SendMessage(GenericRoom room, string content) {
+ bool success;
+ do {
+ try {
+ await room.SendMessageEventAsync(new MessageBuilder().WithBody(content).Build());
+ success = true;
+ }
+ catch (Exception e) {
+ success = false;
+ Console.WriteLine($"Failed to send event {content}: {e}");
+ }
+ } while (!success);
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/MiniUtils.csproj b/MiniUtils/MiniUtils.csproj
new file mode 100644
index 0000000..f7af751
--- /dev/null
+++ b/MiniUtils/MiniUtils.csproj
@@ -0,0 +1,24 @@
+<Project Sdk="Microsoft.NET.Sdk.Worker">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)' == 'Release'">
+ <Optimize>true</Optimize>
+<!-- <RunAOTCompilation>true</RunAOTCompilation>-->
+<!-- <PublishTrimmed>true</PublishTrimmed>-->
+ <PublishSingleFile>true</PublishSingleFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.2"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj" />
+ <ProjectReference Include="..\MiniUtils.Core\MiniUtils.Core.csproj" />
+ </ItemGroup>
+</Project>
diff --git a/MiniUtils/MiniUtilsConfiguration.cs b/MiniUtils/MiniUtilsConfiguration.cs
new file mode 100644
index 0000000..292c12e
--- /dev/null
+++ b/MiniUtils/MiniUtilsConfiguration.cs
@@ -0,0 +1,6 @@
+namespace MiniUtils;
+
+public class MiniUtilsConfiguration {
+ public MiniUtilsConfiguration(IConfiguration config) => config.GetRequiredSection("MiniUtils").Bind(this);
+ public string? GithubToken { get; set; }
+}
\ No newline at end of file
diff --git a/MiniUtils/MiniUtilsWorker.cs b/MiniUtils/MiniUtilsWorker.cs
new file mode 100644
index 0000000..38d5aef
--- /dev/null
+++ b/MiniUtils/MiniUtilsWorker.cs
@@ -0,0 +1,13 @@
+using LibMatrix.Homeservers;
+
+namespace MiniUtils;
+
+public class MiniUtilsWorker(ILogger<MiniUtilsWorker> logger, AuthenticatedHomeserverGeneric homeserver) : IHostedService {
+ public async Task StartAsync(CancellationToken cancellationToken) {
+ logger.LogError("Logged in as {mxid}", homeserver.UserId);
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken) {
+
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/Program.cs b/MiniUtils/Program.cs
new file mode 100644
index 0000000..37308ea
--- /dev/null
+++ b/MiniUtils/Program.cs
@@ -0,0 +1,29 @@
+using LibMatrix.Extensions;
+using LibMatrix.Services;
+using LibMatrix.Utilities.Bot;
+using MiniUtils;
+using MiniUtils.Core;
+using MiniUtils.Services;
+using MiniUtils.Utilities;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+builder.Services.AddSingleton<MiniUtilsConfiguration>();
+builder.Services.AddSingleton<MscInfoProvider>();
+builder.Services.AddSingleton<IgnoreListManager>();
+builder.Services.AddRoryLibMatrixServices()
+ .AddMatrixBot()
+ // .WithInviteHandler<RoomInviteHandler>();
+ .AddCommandHandler()
+ .DiscoverAllCommands();
+
+// builder.Services.AddHostedService<PolicyListFetcher>();
+// builder.Services.AddHostedService<PolicyExecutor>();
+builder.Services.AddHostedService<MiniUtilsWorker>();
+
+// builder.Services.AddSingleton<PolicyStore>();
+
+// MatrixHttpClient.LogRequests = false;
+
+var host = builder.Build();
+host.Run();
\ No newline at end of file
diff --git a/MiniUtils/Properties/launchSettings.json b/MiniUtils/Properties/launchSettings.json
new file mode 100644
index 0000000..a98ac4d
--- /dev/null
+++ b/MiniUtils/Properties/launchSettings.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+// "Development": {
+// "commandName": "Project",
+// "dotnetRunMessages": true,
+// "environmentVariables": {
+// "DOTNET_ENVIRONMENT": "Development"
+// }
+// },
+ "Local": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "environmentVariables": {
+ "DOTNET_ENVIRONMENT": "Local"
+ }
+ }
+ }
+}
diff --git a/MiniUtils/Services/IgnoreListManager.cs b/MiniUtils/Services/IgnoreListManager.cs
new file mode 100644
index 0000000..c42dd02
--- /dev/null
+++ b/MiniUtils/Services/IgnoreListManager.cs
@@ -0,0 +1,67 @@
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Homeservers;
+using MiniUtils.Classes;
+
+namespace MiniUtils.Services;
+
+public class IgnoreListManager(AuthenticatedHomeserverGeneric homeserver) {
+ private static readonly SemaphoreSlim Lock = new(1, 1);
+
+ public async Task<int> DisableAll() {
+ await Lock.WaitAsync();
+ var ignoreList = await homeserver.GetAccountDataOrNullAsync<IgnoredUserListEventContentWithDisabled>(IgnoredUserListEventContent.EventId) ?? new();
+ if (ignoreList.IgnoredUsers.Count == 0) {
+ Lock.Release();
+ return 0;
+ }
+
+ foreach (var ignore in ignoreList.IgnoredUsers) {
+ ignoreList.DisabledIgnoredUsers.Add(ignore.Key, ignore.Value);
+ }
+
+ int count = ignoreList.DisabledIgnoredUsers.Count;
+
+ ignoreList.IgnoredUsers.Clear();
+ await homeserver.SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoreList);
+ Lock.Release();
+ return count;
+ }
+
+ public async Task<int> EnableAll() {
+ await Lock.WaitAsync();
+ var ignoreList = await homeserver.GetAccountDataOrNullAsync<IgnoredUserListEventContentWithDisabled>(IgnoredUserListEventContent.EventId) ?? new();
+ if (ignoreList.DisabledIgnoredUsers.Count == 0) {
+ Lock.Release();
+ return 0;
+ }
+
+ foreach (var ignore in ignoreList.DisabledIgnoredUsers) {
+ ignoreList.IgnoredUsers.Add(ignore.Key, ignore.Value);
+ }
+
+ var count = ignoreList.IgnoredUsers.Count;
+ ignoreList.DisabledIgnoredUsers.Clear();
+ await homeserver.SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoreList);
+ Lock.Release();
+ return count;
+ }
+
+ public async Task<int> MoveList(bool enable, IEnumerable<string> items) {
+ await Lock.WaitAsync();
+ var ignoreList = await homeserver.GetAccountDataOrNullAsync<IgnoredUserListEventContentWithDisabled>(IgnoredUserListEventContent.EventId) ?? new();
+ var fromDict = enable ? ignoreList.DisabledIgnoredUsers : ignoreList.IgnoredUsers;
+ var toDict = enable ? ignoreList.IgnoredUsers : ignoreList.DisabledIgnoredUsers;
+ var moved = 0;
+ foreach (var item in items) {
+ if (fromDict.Remove(item, out var value)) {
+ toDict.Add(item, value);
+ moved++;
+ }
+ }
+
+ if (moved > 0)
+ await homeserver.SetAccountDataAsync(IgnoredUserListEventContent.EventId, ignoreList);
+ Lock.Release();
+ return moved;
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/Utilities/MscInfoProvider.cs b/MiniUtils/Utilities/MscInfoProvider.cs
new file mode 100644
index 0000000..74788dc
--- /dev/null
+++ b/MiniUtils/Utilities/MscInfoProvider.cs
@@ -0,0 +1,91 @@
+using System.Globalization;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json.Nodes;
+using LibMatrix.Extensions;
+
+namespace MiniUtils.Utilities;
+
+public class MscInfoProvider(MiniUtilsConfiguration config) {
+ private static Dictionary<int, MscInfo> _mscCache = new();
+ private static Dictionary<int, DateTime> _mscCacheExpiry = new();
+ private static long RatelimitRemaining = 60;
+ private static DateTime RatelimitReset = DateTime.UtcNow;
+ public async Task<MscInfo?> GetMscInfo(int id) {
+ var hc = new MatrixHttpClient() {
+ DefaultRequestHeaders = {
+ { "User-Agent", "SynapseDataMiner" }
+ }
+ };
+
+ if(!string.IsNullOrWhiteSpace(config.GithubToken))
+ hc.DefaultRequestHeaders.Add("Authorization", $"Bearer {config.GithubToken}");
+
+ var response = await hc.GetAsync($"https://api.github.com/repos/matrix-org/matrix-spec-proposals/pulls/{id}");
+ if(response.Headers.Contains("X-RateLimit-Remaining")) {
+ RatelimitRemaining = long.Parse(response.Headers.GetValues("X-RateLimit-Remaining").First());
+ }
+
+ if (response.Headers.Contains("X-RateLimit-Reset")) {
+ var reset = long.Parse(response.Headers.GetValues("X-RateLimit-Reset").First());
+ RatelimitReset = DateTimeOffset.FromUnixTimeSeconds(reset).UtcDateTime;
+ }
+
+ if (!response.IsSuccessStatusCode) {
+ return _mscCache.GetValueOrDefault(id);
+ }
+
+ var data = await response.Content.ReadFromJsonAsync<JsonObject>();
+ var info = new MscInfo() {
+ Id = id,
+ Author = data?["user"]?["login"]?.ToString(),
+ Title = data?["title"]?.ToString(),
+ Url = data?["html_url"]?.ToString(),
+ State = data?["state"]?.ToString(),
+ Labels = data?["labels"]?.AsArray().Select(x => new MscInfo.LabelInfo() {
+ Name = x["name"]?.ToString(),
+ Color = x["color"]?.ToString()
+ }).ToList() ?? []
+ };
+ _mscCache[id] = info;
+ _mscCacheExpiry[id] = DateTime.UtcNow.AddMinutes(60);
+ return info;
+ }
+
+ public class MscInfo {
+ public int Id { get; set; }
+ public string Title { get; set; }
+ public string State { get; set; }
+ public string Author { get; set; }
+ public string Url { get; set; }
+ public List<LabelInfo> Labels { get; set; }
+
+ public class LabelInfo {
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+
+ public string ToHtml() {
+ var sb = new StringBuilder();
+ sb.Append($"<a href=\"{Url}\">{Title}</a> by {Author} ({State})<br/>");
+
+ foreach (var label in Labels) {
+ sb.Append($"<font color=\"#{label.Color}\">" +
+ $"<span data-mx-bg-color=\"#{label.Color}\" data-mx-color=\"{GetContrastingForegroundColor(label.Color)}\">{label.Name}</span>" +
+ $"</font> \n");
+ }
+
+ return sb + "\n";
+ }
+
+ private static string GetContrastingForegroundColor(string backgroundColor) {
+ var color = backgroundColor.Replace("#", "");
+ var r = int.Parse(color.Substring(0, 2), NumberStyles.HexNumber);
+ var g = int.Parse(color.Substring(2, 2), NumberStyles.HexNumber);
+ var b = int.Parse(color.Substring(4, 2), NumberStyles.HexNumber);
+ // var brightness = Math.Sqrt(0.299 * r * r + 0.587 * g * g + 0.114 * b * b);
+ var brightness = Math.Sqrt(0.299 * r * r + 0.587 * g * g + 0.114 * b * b);
+ return brightness < 130 ? "#ffffff" : "#000000";
+ }
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/appsettings.Development.json b/MiniUtils/appsettings.Development.json
new file mode 100644
index 0000000..4cfb975
--- /dev/null
+++ b/MiniUtils/appsettings.Development.json
@@ -0,0 +1,53 @@
+{
+ // Don't touch this unless you know what you're doing:
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ },
+ "LibMatrixBot": {
+ // Homeserver to connect to.
+ // Note: Homeserver resolution is applied here, but a direct base URL can be used.
+ "Homeserver": "rory.gay",
+
+ // Absolute path to the file containing the access token
+ "AccessTokenPath": "/home/Rory/matrix_access_token",
+ "InviteHandler": {
+ "SyncConfiguration": {
+ // How long to wait until the sync request times out
+ // "Timeout": 300000,
+
+ // Minimum sync interval, useful if you want to have less traffic than a normal client.
+ "MinimumSyncTime": "00:00:10.000",
+
+ // What presence value to set
+ // Defaults to "online" if null or not set
+ // "Presence": "online",
+
+ // Filter to apply to the sync request. Useful if you want custom data to be sent.
+ // "Filter": { },
+
+ // Whether to initial sync on startup - very useful in development, or just to be sure.
+ "InitialSyncOnStartup": true
+ }
+ }
+ },
+ "AntiDmSpam": {
+ // Whether invites should be logged to a room.
+ "LogRoom": "!GrLSwdAkdrvfMrRYKR:rory.gay",
+ "LogInviteDataAsFile": true,
+ // Whether to report users and rooms when an invite is blocked.
+ "ReportBlockedInvites": true,
+ // WARNING: If you're a room moderator, this will cause your client to not receive events from ignored users!
+ "IgnoreBannedUsers": true,
+ // Policy lists to follow
+ "PolicyLists": [
+ {
+ "Name": "Community Moderation Effort",
+ "RoomId": "!fTjMjIzNKEsFlUIiru:neko.dev",
+ "Vias": [ "rory.gay" ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/MiniUtils/appsettings.json b/MiniUtils/appsettings.json
new file mode 100644
index 0000000..b2dcdb6
--- /dev/null
+++ b/MiniUtils/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.Hosting.Lifetime": "Information"
+ }
+ }
+}
diff --git a/MiniUtils/crossplat-build.sh b/MiniUtils/crossplat-build.sh
new file mode 100755
index 0000000..a325f72
--- /dev/null
+++ b/MiniUtils/crossplat-build.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+for arch in {x64,arm64}
+do
+ for platform in {osx,win,linux}
+ do
+ set -x
+ dotnet publish -c Release -r ${platform}-${arch} --nologo --property AssemblyName="$(basename $PWD)-${platform}-${arch}" -o bin/release
+ set +x
+ done
+done
+wait
|