Merge remote-tracking branch 'upstream/main' into rust-command-parser

This commit is contained in:
dusk 2025-08-09 17:38:44 +03:00
commit f721b850d4
No known key found for this signature in database
183 changed files with 5121 additions and 1909 deletions

View file

@ -9,35 +9,35 @@ namespace PluralKit.Bot;
public class CommandMessageService
{
private readonly RedisService _redis;
private readonly ModelRepository _repo;
private readonly ILogger _logger;
private static readonly TimeSpan CommandMessageRetention = TimeSpan.FromHours(24);
public CommandMessageService(RedisService redis, IClock clock, ILogger logger)
public CommandMessageService(RedisService redis, ModelRepository repo, IClock clock, ILogger logger)
{
_redis = redis;
_repo = repo;
_logger = logger.ForContext<CommandMessageService>();
}
public async Task RegisterMessage(ulong messageId, ulong guildId, ulong channelId, ulong authorId)
{
if (_redis.Connection == null) return;
_logger.Debug(
"Registering command response {MessageId} from author {AuthorId} in {ChannelId}",
messageId, authorId, channelId
);
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}-{guildId}", expiry: CommandMessageRetention);
}
public async Task<CommandMessage?> GetCommandMessage(ulong messageId)
{
var repoMsg = await _repo.GetCommandMessage(messageId);
if (repoMsg != null)
return new CommandMessage(repoMsg.Sender, repoMsg.Channel, repoMsg.Guild);
var str = await _redis.Connection.GetDatabase().StringGetAsync(messageId.ToString());
if (str.HasValue)
{
var split = ((string)str).Split("-");
return new CommandMessage(ulong.Parse(split[0]), ulong.Parse(split[1]), ulong.Parse(split[2]));
}
str = await _redis.Connection.GetDatabase().StringGetAsync("command_message:" + messageId.ToString());
if (str.HasValue)
{
var split = ((string)str).Split("-");
return new CommandMessage(ulong.Parse(split[0]), ulong.Parse(split[1]), ulong.Parse(split[2]));
}
return null;
}
}

View file

@ -420,6 +420,24 @@ public class EmbedService
return eb.Build();
}
public async Task<Embed> CreateCommandMessageInfoEmbed(Core.CommandMessage msg, bool showContent)
{
var content = "*(command message deleted or inaccessible)*";
if (showContent)
{
var discordMessage = await _rest.GetMessageOrNull(msg.Channel, msg.OriginalMid);
if (discordMessage != null)
content = discordMessage.Content;
}
return new EmbedBuilder()
.Title("Command response message")
.Description(content)
.Field(new("Original message", $"https://discord.com/channels/{msg.Guild}/{msg.Channel}/{msg.OriginalMid}", true))
.Field(new("Sent by", $"<@{msg.Sender}>", true))
.Build();
}
public Task<Embed> CreateFrontPercentEmbed(FrontBreakdown breakdown, PKSystem system, PKGroup group,
DateTimeZone tz, LookupContext ctx, string embedTitle,
bool ignoreNoFronters, bool showFlat)

View file

@ -0,0 +1,146 @@
using System.Text;
using System.Text.Json;
using Serilog;
using WatsonWebserver.Lite;
using WatsonWebserver.Core;
using Myriad.Gateway;
using Myriad.Serialization;
namespace PluralKit.Bot;
public class HttpListenerService
{
private readonly ILogger _logger;
private readonly RuntimeConfigService _runtimeConfig;
private readonly Bot _bot;
public HttpListenerService(ILogger logger, RuntimeConfigService runtimeConfig, Bot bot)
{
_logger = logger.ForContext<HttpListenerService>();
_runtimeConfig = runtimeConfig;
_bot = bot;
}
public void Start(string host)
{
var hosts = new[] { host };
if (host == "allv4v6")
{
hosts = new[] { "[::]", "0.0.0.0" };
}
foreach (var h in hosts)
{
var server = new WebserverLite(new WebserverSettings(h, 5002), DefaultRoute);
server.Routes.PreAuthentication.Static.Add(WatsonWebserver.Core.HttpMethod.GET, "/runtime_config", RuntimeConfigGet);
server.Routes.PreAuthentication.Parameter.Add(WatsonWebserver.Core.HttpMethod.POST, "/runtime_config/{key}", RuntimeConfigSet);
server.Routes.PreAuthentication.Parameter.Add(WatsonWebserver.Core.HttpMethod.DELETE, "/runtime_config/{key}", RuntimeConfigDelete);
server.Routes.PreAuthentication.Parameter.Add(WatsonWebserver.Core.HttpMethod.POST, "/events/{shard_id}", GatewayEvent);
server.Start();
}
}
private async Task DefaultRoute(HttpContextBase ctx)
=> await ctx.Response.Send("hellorld");
private async Task RuntimeConfigGet(HttpContextBase ctx)
{
var config = _runtimeConfig.GetAll();
ctx.Response.Headers.Add("content-type", "application/json");
await ctx.Response.Send(JsonSerializer.Serialize(config));
}
private async Task RuntimeConfigSet(HttpContextBase ctx)
{
var key = ctx.Request.Url.Parameters["key"];
var value = ReadStream(ctx.Request.Data, ctx.Request.ContentLength);
await _runtimeConfig.Set(key, value);
await RuntimeConfigGet(ctx);
}
private async Task RuntimeConfigDelete(HttpContextBase ctx)
{
var key = ctx.Request.Url.Parameters["key"];
await _runtimeConfig.Delete(key);
await RuntimeConfigGet(ctx);
}
private JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad();
private async Task GatewayEvent(HttpContextBase ctx)
{
var shardIdString = ctx.Request.Url.Parameters["shard_id"];
if (!int.TryParse(shardIdString, out var shardId)) return;
var packet = JsonSerializer.Deserialize<GatewayPacket>(ReadStream(ctx.Request.Data, ctx.Request.ContentLength), _jsonSerializerOptions);
var evt = DeserializeEvent(shardId, packet.EventType!, (JsonElement)packet.Payload!);
if (evt != null)
{
await _bot.OnEventReceivedInner(shardId, evt);
}
await ctx.Response.Send("a");
}
private IGatewayEvent? DeserializeEvent(int shardId, string eventType, JsonElement payload)
{
if (!IGatewayEvent.EventTypes.TryGetValue(eventType, out var clrType))
{
_logger.Debug("Shard {ShardId}: Received unknown event type {EventType}", shardId, eventType);
return null;
}
try
{
_logger.Verbose("Shard {ShardId}: Deserializing {EventType} to {ClrType}", shardId, eventType,
clrType);
return JsonSerializer.Deserialize(payload.GetRawText(), clrType, _jsonSerializerOptions)
as IGatewayEvent;
}
catch (JsonException e)
{
_logger.Error(e, "Shard {ShardId}: Error deserializing event {EventType} to {ClrType}", shardId,
eventType, clrType);
return null;
}
}
//temporary re-implementation of the ReadStream function found in WatsonWebserver.Lite, but with handling for closed connections
//https://github.com/dotnet/WatsonWebserver/issues/171
private static string ReadStream(Stream input, long contentLength)
{
if (input == null) throw new ArgumentNullException(nameof(input));
if (!input.CanRead) throw new InvalidOperationException("Input stream is not readable");
if (contentLength < 1) return "";
byte[] buffer = new byte[65536];
long bytesRemaining = contentLength;
using (MemoryStream ms = new MemoryStream())
{
int read;
while (bytesRemaining > 0)
{
read = input.Read(buffer, 0, buffer.Length);
if (read > 0)
{
ms.Write(buffer, 0, read);
bytesRemaining -= read;
}
else
{
throw new IOException("Connection closed before reading end of stream.");
}
}
if (ms.Length < 1) return null;
var str = Encoding.Default.GetString(ms.ToArray());
return str;
}
}
}

View file

@ -1,5 +1,7 @@
using System.Collections.Concurrent;
using Myriad.Cache;
using NodaTime;
using Serilog;
@ -16,9 +18,12 @@ public class InteractionDispatchService: IDisposable
private readonly ConcurrentDictionary<Guid, RegisteredInteraction> _handlers = new();
private readonly ILogger _logger;
public InteractionDispatchService(IClock clock, ILogger logger)
private readonly IDiscordCache _cache;
public InteractionDispatchService(IClock clock, ILogger logger, IDiscordCache cache)
{
_clock = clock;
_cache = cache;
_logger = logger.ForContext<InteractionDispatchService>();
_cleanupWorker = CleanupLoop(_cts.Token);
@ -50,9 +55,15 @@ public class InteractionDispatchService: IDisposable
_handlers.TryRemove(customIdGuid, out _);
}
public string Register(Func<InteractionContext, Task> callback, Duration? expiry = null)
public string Register(int shardId, Func<InteractionContext, Task> callback, Duration? expiry = null)
{
var key = Guid.NewGuid();
// if http_cache, return RegisterRemote
// not awaited here, it's probably fine
if (_cache is HttpDiscordCache)
(_cache as HttpDiscordCache).AwaitInteraction(shardId, key.ToString(), expiry);
var handler = new RegisteredInteraction
{
Callback = callback,

View file

@ -1,6 +1,7 @@
#nullable enable
using System.Collections.Concurrent;
using Myriad.Cache;
using Myriad.Types;
namespace PluralKit.Bot;
@ -9,9 +10,18 @@ public class LastMessageCacheService
{
private readonly IDictionary<ulong, CacheEntry> _cache = new ConcurrentDictionary<ulong, CacheEntry>();
private readonly IDiscordCache _maybeHttp;
public LastMessageCacheService(IDiscordCache cache)
{
_maybeHttp = cache;
}
public void AddMessage(Message msg)
{
var previous = GetLastMessage(msg.ChannelId);
if (_maybeHttp is HttpDiscordCache) return;
var previous = _GetLastMessage(msg.ChannelId);
var current = ToCachedMessage(msg);
_cache[msg.ChannelId] = new CacheEntry(current, previous?.Current);
}
@ -19,12 +29,26 @@ public class LastMessageCacheService
private CachedMessage ToCachedMessage(Message msg) =>
new(msg.Id, msg.ReferencedMessage.Value?.Id, msg.Author.Username);
public CacheEntry? GetLastMessage(ulong channel) =>
_cache.TryGetValue(channel, out var message) ? message : null;
public async Task<CacheEntry?> GetLastMessage(ulong guild, ulong channel)
{
if (_maybeHttp is HttpDiscordCache)
return await (_maybeHttp as HttpDiscordCache).GetLastMessage<CacheEntry>(guild, channel);
return _cache.TryGetValue(channel, out var message) ? message : null;
}
public CacheEntry? _GetLastMessage(ulong channel)
{
if (_maybeHttp is HttpDiscordCache) return null;
return _cache.TryGetValue(channel, out var message) ? message : null;
}
public void HandleMessageDeletion(ulong channel, ulong message)
{
var storedMessage = GetLastMessage(channel);
if (_maybeHttp is HttpDiscordCache) return;
var storedMessage = _GetLastMessage(channel);
if (storedMessage == null)
return;
@ -39,7 +63,9 @@ public class LastMessageCacheService
public void HandleMessageDeletion(ulong channel, List<ulong> messages)
{
var storedMessage = GetLastMessage(channel);
if (_maybeHttp is HttpDiscordCache) return;
var storedMessage = _GetLastMessage(channel);
if (storedMessage == null)
return;

View file

@ -45,6 +45,7 @@ public class LoggerCleanService
private static readonly Regex _AnnabelleRegex = new("```\n(\\d{17,19})\n```");
private static readonly Regex _AnnabelleRegexFuzzy = new("\\<t:(\\d+)\\> A message from \\*\\*[\\w.]{2,32}\\*\\* \\(`(\\d{17,19})`\\) was deleted in <#\\d{17,19}>");
private static readonly Regex _koiraRegex = new("ID:\\*\\* (\\d{17,19})");
private static readonly Regex _zeppelinRegex = new("🗑 Message \\(`(\\d{17,19})`\\)");
private static readonly Regex _VortexRegex =
new("`\\[(\\d\\d:\\d\\d:\\d\\d)\\]` .* \\(ID:(\\d{17,19})\\).* <#\\d{17,19}>:");
@ -83,7 +84,8 @@ public class LoggerCleanService
new LoggerBot("Dozer", 356535250932858885, ExtractDozer),
new LoggerBot("Skyra", 266624760782258186, ExtractSkyra),
new LoggerBot("Annabelle", 231241068383961088, ExtractAnnabelle, fuzzyExtractFunc: ExtractAnnabelleFuzzy),
new LoggerBot("Koira", 1247013404569239624, ExtractKoira)
new LoggerBot("Koira", 1247013404569239624, ExtractKoira),
new LoggerBot("Zeppelin", 473868086773153793, ExtractZeppelin) // webhook
}.ToDictionary(b => b.Id);
private static Dictionary<ulong, LoggerBot> _botsByApplicationId
@ -441,6 +443,23 @@ public class LoggerCleanService
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static ulong? ExtractZeppelin(Message msg)
{
// zeppelin uses a non-embed format by default but can be configured to use a customizable embed
// if it's an embed, assume the footer contains the message ID
var embed = msg.Embeds?.FirstOrDefault();
if (embed == null)
{
var match = _zeppelinRegex.Match(msg.Content ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
else
{
var match = _basicRegex.Match(embed.Footer?.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
}
public class LoggerBot
{
public ulong Id;

View file

@ -0,0 +1,58 @@
using Newtonsoft.Json;
using Serilog;
using StackExchange.Redis;
using PluralKit.Core;
namespace PluralKit.Bot;
public class RuntimeConfigService
{
private readonly RedisService _redis;
private readonly ILogger _logger;
private Dictionary<string, string> settings = new();
private string RedisKey;
public RuntimeConfigService(ILogger logger, RedisService redis, BotConfig config)
{
_logger = logger.ForContext<RuntimeConfigService>();
_redis = redis;
var clusterId = config.Cluster?.NodeIndex ?? 0;
RedisKey = $"remote_config:dotnet_bot:{clusterId}";
}
public async Task LoadConfig()
{
var redisConfig = await _redis.Connection.GetDatabase().HashGetAllAsync(RedisKey);
foreach (var entry in redisConfig)
settings.Add(entry.Name, entry.Value);
var configStr = JsonConvert.SerializeObject(settings);
_logger.Information($"starting with runtime config: {configStr}");
}
public async Task Set(string key, string value)
{
await _redis.Connection.GetDatabase().HashSetAsync(RedisKey, new[] { new HashEntry(key, new RedisValue(value)) });
settings.Add(key, value);
_logger.Information($"updated runtime config: {key}={value}");
}
public async Task Delete(string key)
{
await _redis.Connection.GetDatabase().HashDeleteAsync(RedisKey, key);
settings.Remove(key);
_logger.Information($"updated runtime config: {key} removed");
}
public object? Get(string key) => settings.GetValueOrDefault(key);
public bool Exists(string key) => settings.ContainsKey(key);
public Dictionary<string, string> GetAll() => settings;
}