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

@ -33,7 +33,10 @@ public class ApplicationCommandProxiedMessage
var messageId = ctx.Event.Data!.TargetId!.Value;
var msg = await ctx.Repository.GetFullMessage(messageId);
if (msg == null)
throw Errors.MessageNotFound(messageId);
{
await QueryCommandMessage(ctx);
return;
}
var showContent = true;
var channel = await _rest.GetChannelOrNull(msg.Message.Channel);
@ -58,6 +61,20 @@ public class ApplicationCommandProxiedMessage
await ctx.Reply(embeds: embeds.ToArray());
}
private async Task QueryCommandMessage(InteractionContext ctx)
{
var messageId = ctx.Event.Data!.TargetId!.Value;
var msg = await ctx.Repository.GetCommandMessage(messageId);
if (msg == null)
throw Errors.MessageNotFound(messageId);
var embeds = new List<Embed>();
embeds.Add(await _embeds.CreateCommandMessageInfoEmbed(msg, true));
await ctx.Reply(embeds: embeds.ToArray());
}
public async Task DeleteMessage(InteractionContext ctx)
{
var messageId = ctx.Event.Data!.TargetId!.Value;

View file

@ -32,13 +32,14 @@ public class Bot
private readonly DiscordApiClient _rest;
private readonly RedisService _redis;
private readonly ILifetimeScope _services;
private readonly RuntimeConfigService _runtimeConfig;
private Timer _periodicTask; // Never read, just kept here for GC reasons
public Bot(ILifetimeScope services, ILogger logger, PeriodicStatCollector collector, IMetrics metrics,
BotConfig config, RedisService redis,
ErrorMessageService errorMessageService, CommandMessageService commandMessageService,
Cluster cluster, DiscordApiClient rest, IDiscordCache cache)
Cluster cluster, DiscordApiClient rest, IDiscordCache cache, RuntimeConfigService runtimeConfig)
{
_logger = logger.ForContext<Bot>();
_services = services;
@ -51,6 +52,7 @@ public class Bot
_rest = rest;
_redis = redis;
_cache = cache;
_runtimeConfig = runtimeConfig;
}
private string BotStatus => $"{(_config.Prefixes ?? BotConfig.DefaultPrefixes)[0]}help"
@ -97,13 +99,15 @@ public class Bot
private async Task OnEventReceived(int shardId, IGatewayEvent evt)
{
if (_runtimeConfig.Exists("disable_events")) return;
// we HandleGatewayEvent **before** getting the own user, because the own user is set in HandleGatewayEvent for ReadyEvent
await _cache.HandleGatewayEvent(evt);
await _cache.TryUpdateSelfMember(_config.ClientId, evt);
await OnEventReceivedInner(shardId, evt);
}
private async Task OnEventReceivedInner(int shardId, IGatewayEvent evt)
public async Task OnEventReceivedInner(int shardId, IGatewayEvent evt)
{
// HandleEvent takes a type parameter, automatically inferred by the event type
// It will then look up an IEventHandler<TypeOfEvent> in the DI container and call that object's handler method
@ -278,7 +282,7 @@ public class Bot
_logger.Debug("Running once-per-minute scheduled tasks");
// Check from a new custom status from Redis and update Discord accordingly
if (true)
if (!_config.DisableGateway)
{
var newStatus = await _redis.Connection.GetDatabase().StringGetAsync("pluralkit:botstatus");
if (newStatus != CustomStatusMessage)

View file

@ -24,6 +24,10 @@ public class BotConfig
public string? HttpCacheUrl { get; set; }
public bool HttpUseInnerCache { get; set; } = false;
public string? HttpListenerAddr { get; set; }
public bool DisableGateway { get; set; } = false;
public string? EventAwaiterTarget { get; set; }
public string? DiscordBaseUrl { get; set; }
public string? AvatarServiceUrl { get; set; }

View file

@ -108,7 +108,7 @@ public partial class CommandTree
ctx.Reply(
$"{Emojis.Error} Parsed command {ctx.Parameters.Callback().AsCode()} not implemented in PluralKit.Bot!"),
};
if (ctx.Match("system", "s"))
if (ctx.Match("system", "s", "account", "acc"))
return HandleSystemCommand(ctx);
if (ctx.Match("member", "m"))
return HandleMemberCommand(ctx);
@ -554,6 +554,8 @@ public partial class CommandTree
case "system":
case "systems":
case "s":
case "account":
case "acc":
await PrintCommandList(ctx, "systems", SystemCommands);
break;
case "member":

View file

@ -80,7 +80,7 @@ public class Context
internal readonly ModelRepository Repository;
internal readonly RedisService Redis;
public async Task<Message> Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null)
public async Task<Message> Reply(string text = null, Embed embed = null, AllowedMentions? mentions = null, MultipartFile[]? files = null)
{
var botPerms = await BotPermissions;
@ -91,20 +91,28 @@ public class Context
if (embed != null && !botPerms.HasFlag(PermissionSet.EmbedLinks))
throw new PKError("PluralKit does not have permission to send embeds in this channel. Please ensure I have the **Embed Links** permission enabled.");
if (files != null && !botPerms.HasFlag(PermissionSet.AttachFiles))
throw new PKError("PluralKit does not have permission to attach files in this channel. Please ensure I have the **Attach Files** permission enabled.");
var msg = await Rest.CreateMessage(Channel.Id, new MessageRequest
{
Content = text,
Embeds = embed != null ? new[] { embed } : null,
// Default to an empty allowed mentions object instead of null (which means no mentions allowed)
AllowedMentions = mentions ?? new AllowedMentions()
});
}, files: files);
// if (embed != null)
// {
// Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example)
// but since we can, we just store all sent messages for possible deletion
await _commandMessageService.RegisterMessage(msg.Id, Guild?.Id ?? 0, msg.ChannelId, Author.Id);
// }
// store log of sent message, so it can be queried or deleted later
// skip DMs as DM messages can always be deleted
if (Guild != null)
await Repository.AddCommandMessage(new Core.CommandMessage
{
Mid = msg.Id,
Guild = Guild!.Id,
Channel = Channel.Id,
Sender = Author.Id,
OriginalMid = Message.Id,
});
return msg;
}

View file

@ -443,10 +443,11 @@ public class Groups
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Description($"This group's color is **#{target.Color}**."
+ (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}group {target.Reference(ctx)} color -clear`." : ""))
.Build());
.Build(),
files: [MiscUtils.GenerateColorPreview(target.Color)]);
return;
}
@ -471,8 +472,9 @@ public class Groups
await ctx.Reply(embed: new EmbedBuilder()
.Title($"{Emojis.Success} Group color changed.")
.Color(color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20"))
.Build());
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Build(),
files: [MiscUtils.GenerateColorPreview(color)]);
}
}

View file

@ -308,10 +308,11 @@ public class MemberEdit
await ctx.Reply(embed: new EmbedBuilder()
.Title("Member color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Description($"This member's color is **#{target.Color}**."
+ (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}member {target.Reference(ctx)} color -clear`." : ""))
.Build());
.Build(),
files: [MiscUtils.GenerateColorPreview(target.Color)]);
return;
}
@ -336,8 +337,9 @@ public class MemberEdit
await ctx.Reply(embed: new EmbedBuilder()
.Title($"{Emojis.Success} Member color changed.")
.Color(color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{color}/?text=%20"))
.Build());
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Build(),
files: [MiscUtils.GenerateColorPreview(color)]);
}
}

View file

@ -305,7 +305,7 @@ public class ProxiedMessage
throw new PKError(error);
}
var lastMessage = _lastMessageCache.GetLastMessage(ctx.Message.ChannelId);
var lastMessage = await _lastMessageCache.GetLastMessage(ctx.Message.GuildId ?? 0, ctx.Message.ChannelId);
var isLatestMessage = lastMessage?.Current.Id == ctx.Message.Id
? lastMessage?.Previous?.Id == msg.Mid
@ -347,13 +347,8 @@ public class ProxiedMessage
var message = await ctx.Repository.GetFullMessage(messageId.Value);
if (message == null)
{
if (isDelete)
{
await DeleteCommandMessage(ctx, messageId.Value);
return;
}
throw Errors.MessageNotFound(messageId.Value);
await GetCommandMessage(ctx, messageId.Value, isDelete);
return;
}
var showContent = true;
@ -448,20 +443,35 @@ public class ProxiedMessage
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
}
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete)
{
var cmessage = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
if (cmessage == null)
var msg = await _repo.GetCommandMessage(messageId);
if (msg == null)
throw Errors.MessageNotFound(messageId);
if (cmessage!.AuthorId != ctx.Author.Id)
throw new PKError("You can only delete command messages queried by this account.");
if (isDelete)
{
if (msg.Sender != ctx.Author.Id)
throw new PKError("You can only delete command messages queried by this account.");
await ctx.Rest.DeleteMessage(cmessage.ChannelId, messageId);
await ctx.Rest.DeleteMessage(msg.Channel, messageId);
if (ctx.Guild != null)
await ctx.Rest.DeleteMessage(ctx.Message);
else
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = Emojis.Success });
if (ctx.Guild != null)
await ctx.Rest.DeleteMessage(ctx.Message);
else
await ctx.Rest.CreateReaction(ctx.Message.ChannelId, ctx.Message.Id, new Emoji { Name = Emojis.Success });
return;
}
var showContent = true;
var channel = await _rest.GetChannelOrNull(msg.Channel);
if (channel == null)
showContent = false;
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false;
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent));
}
}

View file

@ -37,10 +37,13 @@ public class System
.Field(new Embed.Field("Getting Started",
"New to PK? Check out our Getting Started guide on setting up members and proxies: https://pluralkit.me/start\n" +
$"Otherwise, type `{ctx.DefaultPrefix}system` to view your system and `{ctx.DefaultPrefix}system help` for more information about commands you can use."))
.Field(new Embed.Field($"{Emojis.Warn} Notice {Emojis.Warn}", "PluralKit is a bot meant to help you share information about your system. " +
.Field(new Embed.Field($"{Emojis.Warn} Notice: Public By Default {Emojis.Warn}", "PluralKit is a bot meant to help you share information about your system. " +
"Member descriptions are meant to be the equivalent to a Discord About Me. Because of this, any info you put in PK is **public by default**.\n" +
"Note that this does **not** include message content, only member fields. For more information, check out " +
"[the privacy section of the user guide](https://pluralkit.me/guide/#privacy). "))
.Field(new Embed.Field($"{Emojis.Warn} Notice: Implicit Acceptance of ToS {Emojis.Warn}", "By using the PluralKit bot you implicitly agree to our " +
"[Terms of Service](https://pluralkit.me/terms-of-service/). For questions please ask in our [support server](<https://discord.gg/PczBt78>) or " +
"email legal@pluralkit.me"))
.Field(new Embed.Field("System Recovery", "In the case of your Discord account getting lost or deleted, the PluralKit staff can help you recover your system. " +
"In order to do so, we will need your **PluralKit token**. This is the *only* way you can prove ownership so we can help you recover your system. " +
$"To get it, run `{ctx.DefaultPrefix}token` and then store it in a safe place.\n\n" +

View file

@ -233,8 +233,9 @@ public class SystemEdit
await ctx.Reply(embed: new EmbedBuilder()
.Title($"{Emojis.Success} System color changed.")
.Color(newColor.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{newColor}/?text=%20"))
.Build());
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Build(),
files: [MiscUtils.GenerateColorPreview(color)]););
}
public async Task ClearColor(Context ctx, PKSystem target, bool flagConfirmYes)
@ -274,10 +275,11 @@ public class SystemEdit
await ctx.Reply(embed: new EmbedBuilder()
.Title("System color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
.Thumbnail(new Embed.EmbedThumbnail($"attachment://color.gif"))
.Description(
$"This system's color is **#{target.Color}**." + (isOwnSystem ? $" To clear it, type `{ctx.DefaultPrefix}s color -clear`." : ""))
.Build());
.Build(),
files: [MiscUtils.GenerateColorPreview(target.Color)]);
}
public async Task ClearTag(Context ctx, PKSystem target, bool flagConfirmYes)
@ -475,7 +477,7 @@ public class SystemEdit
else
str +=
" Member names will now use the global system tag when proxied in the current server, if there is one set."
+ "\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`.";
+ $"\n\nTo check or change where your tag appears in your name use the command `{ctx.DefaultPrefix}cfg name format`.";
}
}

View file

@ -55,7 +55,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
public (ulong?, ulong?) ErrorChannelFor(MessageCreateEvent evt, ulong userId) => (evt.GuildId, evt.ChannelId);
private bool IsDuplicateMessage(Message msg) =>
// We consider a message duplicate if it has the same ID as the previous message that hit the gateway
_lastMessageCache.GetLastMessage(msg.ChannelId)?.Current.Id == msg.Id;
// use only the local cache here
// http gateway sets last message before forwarding the message here, so this will always return true
_lastMessageCache._GetLastMessage(msg.ChannelId)?.Current.Id == msg.Id;
public async Task Handle(int shardId, MessageCreateEvent evt)
{

View file

@ -65,7 +65,7 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
var guild = await _cache.TryGetGuild(channel.GuildId!.Value);
if (guild == null)
throw new Exception("could not find self guild in MessageEdited event");
var lastMessage = _lastMessageCache.GetLastMessage(evt.ChannelId)?.Current;
var lastMessage = (await _lastMessageCache.GetLastMessage(evt.GuildId.HasValue ? evt.GuildId.Value ?? 0 : 0, evt.ChannelId))?.Current;
// Only react to the last message in the channel
if (lastMessage?.Id != evt.Id)
@ -107,10 +107,6 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
? new Message.Reference(channel.GuildId, evt.ChannelId, lastMessage.ReferencedMessage.Value)
: null;
var messageType = lastMessage.ReferencedMessage != null
? Message.MessageType.Reply
: Message.MessageType.Default;
// TODO: is this missing anything?
var equivalentEvt = new MessageCreateEvent
{
@ -123,7 +119,7 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
Attachments = evt.Attachments.Value ?? Array.Empty<Message.Attachment>(),
MessageReference = messageReference,
ReferencedMessage = referencedMessage,
Type = messageType,
Type = evt.Type,
};
return equivalentEvt;
}

View file

@ -111,6 +111,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
case "\U0001F514": // Bell
case "\U0001F6CE": // Bellhop bell
case "\U0001F3D3": // Ping pong paddle (lol)
case "\U0001FAD1": // Bell pepper
case "\u23F0": // Alarm clock
case "\u2757": // Exclamation mark
{

View file

@ -57,16 +57,6 @@ public class Init
var cache = services.Resolve<IDiscordCache>();
if (config.Cluster == null)
{
// "Connect to the database" (ie. set off database migrations and ensure state)
logger.Information("Connecting to database");
await services.Resolve<IDatabase>().ApplyMigrations();
// Clear shard status from Redis
await redis.Connection.GetDatabase().KeyDeleteAsync("pluralkit:shardstatus");
}
logger.Information("Initializing bot");
var bot = services.Resolve<Bot>();
@ -76,10 +66,19 @@ public class Init
// Init the bot instance itself, register handlers and such to the client before beginning to connect
bot.Init();
// Start the Discord shards themselves (handlers already set up)
logger.Information("Connecting to Discord");
await StartCluster(services);
// load runtime config from redis
await services.Resolve<RuntimeConfigService>().LoadConfig();
// Start HTTP server
if (config.HttpListenerAddr != null)
services.Resolve<HttpListenerService>().Start(config.HttpListenerAddr);
// Start the Discord shards themselves (handlers already set up)
if (!config.DisableGateway)
{
logger.Information("Connecting to Discord");
await StartCluster(services);
}
logger.Information("Connected! All is good (probably).");
// Lastly, we just... wait. Everything else is handled in the DiscordClient event loop
@ -149,7 +148,7 @@ public class Init
var builder = new ContainerBuilder();
builder.RegisterInstance(config);
builder.RegisterModule(new ConfigModule<BotConfig>("Bot"));
builder.RegisterModule(new LoggingModule("bot"));
builder.RegisterModule(new LoggingModule("dotnet-bot"));
builder.RegisterModule(new MetricsModule());
builder.RegisterModule<DataStoreModule>();
builder.RegisterModule<BotModule>();

View file

@ -28,7 +28,7 @@ public abstract class BaseInteractive
ButtonStyle style = ButtonStyle.Secondary, bool disabled = false)
{
var dispatch = _ctx.Services.Resolve<InteractionDispatchService>();
var customId = dispatch.Register(handler, Timeout);
var customId = dispatch.Register(_ctx.ShardId, handler, Timeout);
var button = new Button
{
@ -89,7 +89,7 @@ public abstract class BaseInteractive
{
var dispatch = ctx.Services.Resolve<InteractionDispatchService>();
foreach (var button in _buttons)
button.CustomId = dispatch.Register(button.Handler, Timeout);
button.CustomId = dispatch.Register(_ctx.ShardId, button.Handler, Timeout);
}
public abstract Task Start();

View file

@ -1,5 +1,6 @@
using Autofac;
using Myriad.Cache;
using Myriad.Gateway;
using Myriad.Rest.Types;
using Myriad.Types;
@ -69,6 +70,9 @@ public class YesNoPrompt: BaseInteractive
return true;
}
// no need to reawait message
// gateway will already have sent us only matching messages
return false;
}
@ -88,6 +92,17 @@ public class YesNoPrompt: BaseInteractive
{
try
{
// check if http gateway and set listener
// todo: this one needs to handle options for message
if (_ctx.Cache is HttpDiscordCache)
await (_ctx.Cache as HttpDiscordCache).AwaitMessage(
_ctx.Guild?.Id ?? 0,
_ctx.Channel.Id,
_ctx.Author.Id,
Timeout,
options: new[] { "yes", "y", "no", "n" }
);
await queue.WaitFor(MessagePredicate, Timeout, cts.Token);
}
catch (TimeoutException e)

View file

@ -49,8 +49,15 @@ public class BotModule: Module
if (botConfig.HttpCacheUrl != null)
{
var cache = new HttpDiscordCache(c.Resolve<ILogger>(),
c.Resolve<HttpClient>(), botConfig.HttpCacheUrl, botConfig.Cluster?.TotalShards ?? 1, botConfig.ClientId, botConfig.HttpUseInnerCache);
var cache = new HttpDiscordCache(
c.Resolve<ILogger>(),
c.Resolve<HttpClient>(),
botConfig.HttpCacheUrl,
botConfig.EventAwaiterTarget,
botConfig.Cluster?.TotalShards ?? 1,
botConfig.ClientId,
botConfig.HttpUseInnerCache
);
var metrics = c.Resolve<IMetrics>();
@ -153,6 +160,8 @@ public class BotModule: Module
builder.RegisterType<CommandMessageService>().AsSelf().SingleInstance();
builder.RegisterType<InteractionDispatchService>().AsSelf().SingleInstance();
builder.RegisterType<AvatarHostingService>().AsSelf().SingleInstance();
builder.RegisterType<HttpListenerService>().AsSelf().SingleInstance();
builder.RegisterType<RuntimeConfigService>().AsSelf().SingleInstance();
// Sentry stuff
builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope();

View file

@ -25,5 +25,6 @@
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="Sentry" Version="4.13.0" />
<PackageReference Include="Watson.Lite" Version="6.3.5" />
</ItemGroup>
</Project>

View file

@ -246,7 +246,7 @@ public class ProxyService
ChannelId = rootChannel.Id,
ThreadId = threadId,
MessageId = trigger.Id,
Name = await FixSameName(messageChannel.Id, ctx, match.Member),
Name = await FixSameName(trigger.GuildId!.Value, messageChannel.Id, ctx, match.Member),
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
Content = content,
Attachments = trigger.Attachments,
@ -458,11 +458,11 @@ public class ProxyService
};
}
private async Task<string> FixSameName(ulong channelId, MessageContext ctx, ProxyMember member)
private async Task<string> FixSameName(ulong guildId, ulong channelId, MessageContext ctx, ProxyMember member)
{
var proxyName = member.ProxyName(ctx);
var lastMessage = _lastMessage.GetLastMessage(channelId)?.Previous;
var lastMessage = (await _lastMessage.GetLastMessage(guildId, channelId))?.Previous;
if (lastMessage == null)
// cache is out of date or channel is empty.
return proxyName;

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;
}

View file

@ -1,6 +1,7 @@
using Autofac;
using Myriad.Builders;
using Myriad.Cache;
using Myriad.Gateway;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types.Requests;
@ -40,8 +41,12 @@ public static class ContextUtils
}
public static async Task<MessageReactionAddEvent> AwaitReaction(this Context ctx, Message message,
User user = null, Func<MessageReactionAddEvent, bool> predicate = null, Duration? timeout = null)
User user, Func<MessageReactionAddEvent, bool> predicate = null, Duration? timeout = null)
{
// check if http gateway and set listener
if (ctx.Cache is HttpDiscordCache)
await (ctx.Cache as HttpDiscordCache).AwaitReaction(ctx.Guild?.Id ?? 0, message.Id, user!.Id, timeout);
bool ReactionPredicate(MessageReactionAddEvent evt)
{
if (message.Id != evt.MessageId) return false; // Ignore reactions for different messages
@ -57,11 +62,17 @@ public static class ContextUtils
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply, bool treatAsHid = false)
{
var timeout = Duration.FromMinutes(1);
// check if http gateway and set listener
if (ctx.Cache is HttpDiscordCache)
await (ctx.Cache as HttpDiscordCache).AwaitMessage(ctx.Guild?.Id ?? 0, ctx.Channel.Id, ctx.Author.Id, timeout);
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(1));
.WaitFor(Predicate, timeout);
var content = msg.Content;
if (treatAsHid)
@ -96,11 +107,17 @@ public static class ContextUtils
async Task<int> PromptPageNumber()
{
var timeout = Duration.FromMinutes(0.5);
// check if http gateway and set listener
if (ctx.Cache is HttpDiscordCache)
await (ctx.Cache as HttpDiscordCache).AwaitMessage(ctx.Guild?.Id ?? 0, ctx.Channel.Id, ctx.Author.Id, timeout);
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(0.5));
.WaitFor(Predicate, timeout);
int.TryParse(msg.Content, out var num);

View file

@ -1,7 +1,8 @@
using System.Net;
using System.Net.Sockets;
using System.Globalization;
using Myriad.Rest.Exceptions;
using Myriad.Rest.Types;
using Newtonsoft.Json;
@ -102,4 +103,26 @@ public static class MiscUtils
return true;
}
public static MultipartFile GenerateColorPreview(string color)
{
//generate a 128x128 solid color gif from bytes
//image data is a 1x1 pixel, using the background color to fill the rest of the canvas
var imgBytes = new byte[]
{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // Header
0x80, 0x00, 0x80, 0x00, 0x80, 0x00, 0x00, // Logical Screen Descriptor
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Global Color Table
0x21, 0xF9, 0x04, 0x08, 0x00, 0x00, 0x00, 0x00, // Graphics Control Extension
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor
0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data
0x3B // Trailer
}; //indices 13, 14 and 15 are the R, G, and B values respectively
imgBytes[13] = byte.Parse(color.Substring(0, 2), NumberStyles.HexNumber);
imgBytes[14] = byte.Parse(color.Substring(2, 2), NumberStyles.HexNumber);
imgBytes[15] = byte.Parse(color.Substring(4, 2), NumberStyles.HexNumber);
return new MultipartFile("color.gif", new MemoryStream(imgBytes), null, null, null);
}
}

View file

@ -14,6 +14,16 @@
"resolved": "4.13.0",
"contentHash": "Wfw3M1WpFcrYaGzPm7QyUTfIOYkVXQ1ry6p4WYjhbLz9fPwV23SGQZTFDpdox67NHM0V0g1aoQ4YKLm4ANtEEg=="
},
"Watson.Lite": {
"type": "Direct",
"requested": "[6.3.5, )",
"resolved": "6.3.5",
"contentHash": "YF8+se3IVenn8YlyNeb4wSJK6QMnVD0QHIOEiZ22wS4K2wkwoSDzWS+ZAjk1MaPeB+XO5gRoENUN//pOc+wI2g==",
"dependencies": {
"CavemanTcp": "2.0.5",
"Watson.Core": "6.3.5"
}
},
"App.Metrics": {
"type": "Transitive",
"resolved": "4.3.0",
@ -107,6 +117,11 @@
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.1"
}
},
"CavemanTcp": {
"type": "Transitive",
"resolved": "2.0.5",
"contentHash": "90wywmGpjrj26HMAkufYZwuZI8sVYB1mRwEdqugSR3kgDnPX+3l0jO86gwtFKsPvsEpsS4Dn/1EbhguzUxMU8Q=="
},
"Dapper": {
"type": "Transitive",
"resolved": "2.1.35",
@ -130,6 +145,11 @@
"System.Diagnostics.DiagnosticSource": "5.0.0"
}
},
"IpMatcher": {
"type": "Transitive",
"resolved": "1.0.5",
"contentHash": "WXNlWERj+0GN699AnMNsuJ7PfUAbU4xhOHP3nrNXLHqbOaBxybu25luSYywX1133NSlitA4YkSNmJuyPvea4sw=="
},
"IPNetwork2": {
"type": "Transitive",
"resolved": "3.0.667",
@ -391,18 +411,18 @@
"resolved": "8.5.0",
"contentHash": "VYYMZNitZ85UEhwOKkTQI63WEMvzUqwQc74I2mm8h/DBVAMcBBxqYPni4DmuRtbCwngmuONuK2yBJfWNRKzI+A=="
},
"Serilog": {
"RegexMatcher": {
"type": "Transitive",
"resolved": "4.2.0",
"contentHash": "gmoWVOvKgbME8TYR+gwMf7osROiWAURterc6Rt2dQyX7wtjZYpqFiA/pY6ztjGQKKV62GGCyOcmtP1UKMHgSmA=="
"resolved": "1.0.9",
"contentHash": "RkQGXIrqHjD5h1mqefhgCbkaSdRYNRG5rrbzyw5zeLWiS0K1wq9xR3cNhQdzYR2MsKZ3GN523yRUsEQIMPxh3Q=="
},
"Serilog.Extensions.Logging": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
"resolved": "8.0.0",
"contentHash": "YEAMWu1UnWgf1c1KP85l1SgXGfiVo0Rz6x08pCiPOIBt2Qe18tcZLvdBUuV5o1QHvrs8FAry9wTIhgBRtjIlEg==",
"dependencies": {
"Microsoft.Extensions.Logging": "9.0.0",
"Serilog": "4.2.0"
"Microsoft.Extensions.Logging": "8.0.0",
"Serilog": "3.1.1"
}
},
"Serilog.Formatting.Compact": {
@ -714,12 +734,36 @@
"System.Runtime": "4.3.0"
}
},
"Timestamps": {
"type": "Transitive",
"resolved": "1.0.11",
"contentHash": "SnWhXm3FkEStQGgUTfWMh9mKItNW032o/v8eAtFrOGqG0/ejvPPA1LdLZx0N/qqoY0TH3x11+dO00jeVcM8xNQ=="
},
"UrlMatcher": {
"type": "Transitive",
"resolved": "3.0.1",
"contentHash": "hHBZVzFSfikrx4XsRsnCIwmGLgbNKtntnlqf4z+ygcNA6Y/L/J0x5GiZZWfXdTfpxhy5v7mlt2zrZs/L9SvbOA=="
},
"Watson.Core": {
"type": "Transitive",
"resolved": "6.3.5",
"contentHash": "Y5YxKOCSLe2KDmfwvI/J0qApgmmZR77LwyoufRVfKH7GLdHiE7fY0IfoNxWTG7nNv8knBfgwyOxdehRm+4HaCg==",
"dependencies": {
"IpMatcher": "1.0.5",
"RegexMatcher": "1.0.9",
"System.Text.Json": "8.0.5",
"Timestamps": "1.0.11",
"UrlMatcher": "3.0.1"
}
},
"myriad": {
"type": "Project",
"dependencies": {
"NodaTime": "[3.2.0, )",
"NodaTime.Serialization.JsonNet": "[3.1.0, )",
"Polly": "[8.5.0, )",
"Polly.Contrib.WaitAndRetry": "[1.1.1, )",
"Serilog": "[4.2.0, )",
"Serilog": "[4.1.0, )",
"StackExchange.Redis": "[2.8.22, )",
"System.Linq.Async": "[6.0.1, )"
}
@ -747,8 +791,8 @@
"NodaTime.Serialization.JsonNet": "[3.1.0, )",
"Npgsql": "[9.0.2, )",
"Npgsql.NodaTime": "[9.0.2, )",
"Serilog": "[4.2.0, )",
"Serilog.Extensions.Logging": "[9.0.0, )",
"Serilog": "[4.1.0, )",
"Serilog.Extensions.Logging": "[8.0.0, )",
"Serilog.Formatting.Compact": "[3.0.0, )",
"Serilog.NodaTime": "[3.0.0, )",
"Serilog.Sinks.Async": "[2.1.0, )",
@ -762,6 +806,9 @@
"System.Interactive.Async": "[6.0.1, )",
"ipnetwork2": "[3.0.667, )"
}
},
"serilog": {
"type": "Project"
}
}
}