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

This commit is contained in:
dusk 2025-11-23 16:46:43 +00:00
commit a29ed2bda0
No known key found for this signature in database
28 changed files with 446 additions and 193 deletions

15
Cargo.lock generated
View file

@ -98,6 +98,20 @@ dependencies = [
"twilight-http", "twilight-http",
] ]
[[package]]
name = "app-commands"
version = "0.1.0"
dependencies = [
"anyhow",
"futures",
"libpk",
"tokio",
"tracing",
"twilight-http",
"twilight-model",
"twilight-util",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.7.1" version = "1.7.1"
@ -4844,6 +4858,7 @@ version = "0.16.0"
source = "git+https://github.com/pluralkit/twilight?branch=pluralkit-7f08d95#054a2aa5d29fb46220af1cd5df568b73511cdb26" source = "git+https://github.com/pluralkit/twilight?branch=pluralkit-7f08d95#054a2aa5d29fb46220af1cd5df568b73511cdb26"
dependencies = [ dependencies = [
"twilight-model", "twilight-model",
"twilight-validate",
] ]
[[package]] [[package]]

View file

@ -27,7 +27,7 @@ axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit"
twilight-gateway = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" } twilight-gateway = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" }
twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator"] } twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator"] }
twilight-util = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator"] } twilight-util = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator", "builder"] }
twilight-model = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" } twilight-model = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" }
twilight-http = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", default-features = false, features = ["rustls-aws_lc_rs", "rustls-native-roots"] } twilight-http = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", default-features = false, features = ["rustls-aws_lc_rs", "rustls-native-roots"] }

View file

@ -11,6 +11,7 @@ public record MessageComponent
public string? Url { get; init; } public string? Url { get; init; }
public bool? Disabled { get; init; } public bool? Disabled { get; init; }
public uint? AccentColor { get; init; } public uint? AccentColor { get; init; }
public int? Spacing { get; init; }
public ComponentMedia? Media { get; init; } public ComponentMedia? Media { get; init; }
public ComponentMediaItem[]? Items { get; init; } public ComponentMediaItem[]? Items { get; init; }

View file

@ -43,11 +43,10 @@ public class ApplicationCommandProxiedMessage
if (channel == null) if (channel == null)
showContent = false; showContent = false;
var embeds = new List<Embed>(); var components = new List<MessageComponent>();
var guild = await _cache.GetGuild(ctx.GuildId); var guild = await _cache.GetGuild(ctx.GuildId);
if (msg.Member != null) if (msg.Member != null)
embeds.Add(await _embeds.CreateMemberEmbed( components.AddRange(await _embeds.CreateMemberMessageComponents(
msg.System, msg.System,
msg.Member, msg.Member,
guild, guild,
@ -55,10 +54,12 @@ public class ApplicationCommandProxiedMessage
LookupContext.ByNonOwner, LookupContext.ByNonOwner,
DateTimeZone.Utc DateTimeZone.Utc
)); ));
components.Add(new MessageComponent()
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent, ctx.Config)); {
Type = ComponentType.Separator
await ctx.Reply(embeds: embeds.ToArray()); });
components.AddRange(await _embeds.CreateMessageInfoMessageComponents(msg, showContent, ctx.Config));
await ctx.Reply(components: components.ToArray());
} }
private async Task QueryCommandMessage(InteractionContext ctx) private async Task QueryCommandMessage(InteractionContext ctx)
@ -68,11 +69,7 @@ public class ApplicationCommandProxiedMessage
if (msg == null) if (msg == null)
throw Errors.MessageNotFound(messageId); throw Errors.MessageNotFound(messageId);
var embeds = new List<Embed>(); await ctx.Reply(components: await _embeds.CreateCommandMessageInfoMessageComponents(msg, true));
embeds.Add(await _embeds.CreateCommandMessageInfoEmbed(msg, true));
await ctx.Reply(embeds: embeds.ToArray());
} }
public async Task DeleteMessage(InteractionContext ctx) public async Task DeleteMessage(InteractionContext ctx)

View file

@ -407,21 +407,33 @@ public class ProxiedMessage
if (author) if (author)
{ {
var user = await _rest.GetUser(message.Message.Sender); var user = await _rest.GetUser(message.Message.Sender);
var eb = new EmbedBuilder() if (ctx.MatchFlag("show-embed", "se"))
.Author(new Embed.EmbedAuthor( {
user != null var eb = new EmbedBuilder()
? $"{user.Username}#{user.Discriminator}" .Author(new Embed.EmbedAuthor(
: $"Deleted user ${message.Message.Sender}", user != null
IconUrl: user != null ? user.AvatarUrl() : null)) ? $"{user.Username}#{user.Discriminator}"
.Description(message.Message.Sender.ToString()); : $"Deleted user ${message.Message.Sender}",
IconUrl: user != null ? user.AvatarUrl() : null))
.Description(message.Message.Sender.ToString());
await ctx.Reply( await ctx.Reply(
user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*", user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {message.Message.Sender})*",
eb.Build()); eb.Build());
return;
}
await ctx.Reply(components: await _embeds.CreateAuthorMessageComponents(user, message));
return; return;
} }
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config)); if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
return;
}
await ctx.Reply(components: await _embeds.CreateMessageInfoMessageComponents(message, showContent, ctx.Config));
} }
private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete) private async Task GetCommandMessage(Context ctx, ulong messageId, bool isDelete)
@ -453,6 +465,11 @@ public class ProxiedMessage
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel)) else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false; showContent = false;
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent)); if (ctx.MatchFlag("show-embed", "se"))
{
await ctx.Reply(embed: await _embeds.CreateCommandMessageInfoEmbed(msg, showContent));
return;
}
await ctx.Reply(components: await _embeds.CreateCommandMessageInfoMessageComponents(msg, showContent));
} }
} }

View file

@ -186,10 +186,9 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
{ {
var dm = await _dmCache.GetOrCreateDmChannel(evt.UserId); var dm = await _dmCache.GetOrCreateDmChannel(evt.UserId);
var embeds = new List<Embed>(); var components = new List<MessageComponent>();
if (msg.Member != null) if (msg.Member != null)
embeds.Add(await _embeds.CreateMemberEmbed( components.AddRange(await _embeds.CreateMemberMessageComponents(
msg.System, msg.System,
msg.Member, msg.Member,
guild, guild,
@ -197,10 +196,12 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
LookupContext.ByNonOwner, LookupContext.ByNonOwner,
DateTimeZone.Utc DateTimeZone.Utc
)); ));
components.Add(new MessageComponent()
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, true, config)); {
Type = ComponentType.Separator
await _rest.CreateMessage(dm, new MessageRequest { Embeds = embeds.ToArray() }); });
components.AddRange(await _embeds.CreateMessageInfoMessageComponents(msg, true, config));
await _rest.CreateMessage(dm, new MessageRequest { Components = components.ToArray(), Flags = Message.MessageFlags.IsComponentsV2, AllowedMentions = new AllowedMentions() });
} }
catch (ForbiddenException) { } // No permissions to DM, can't check for this :( catch (ForbiddenException) { } // No permissions to DM, can't check for this :(

View file

@ -66,6 +66,15 @@ public class ProxyService
var autoproxySettings = await _repo.GetAutoproxySettings(ctx.SystemId.Value, guild.Id, null); var autoproxySettings = await _repo.GetAutoproxySettings(ctx.SystemId.Value, guild.Id, null);
if (IsDisableAutoproxy(message))
{
await _repo.UpdateAutoproxy(ctx.SystemId.Value, guild.Id, null, new()
{
AutoproxyMode = AutoproxyMode.Off
});
return false;
}
if (autoproxySettings.AutoproxyMode == AutoproxyMode.Latch && IsUnlatch(message)) if (autoproxySettings.AutoproxyMode == AutoproxyMode.Latch && IsUnlatch(message))
{ {
// "unlatch" // "unlatch"
@ -495,6 +504,9 @@ public class ProxyService
public static bool IsUnlatch(Message message) public static bool IsUnlatch(Message message)
=> message.Content.StartsWith(@"\\") || message.Content.StartsWith("\\\u200b\\"); => message.Content.StartsWith(@"\\") || message.Content.StartsWith("\\\u200b\\");
public static bool IsDisableAutoproxy(Message message)
=> message.Content.StartsWith(@"\\\") || message.Content.StartsWith("\\\u200b\\\u200b\\");
private async Task HandleProxyExecutedActions(MessageContext ctx, AutoproxySettings autoproxySettings, private async Task HandleProxyExecutedActions(MessageContext ctx, AutoproxySettings autoproxySettings,
Message triggerMessage, Message proxyMessage, ProxyMatch match, Message triggerMessage, Message proxyMessage, ProxyMatch match,
bool deletePrevious = true) bool deletePrevious = true)

View file

@ -766,6 +766,158 @@ public class EmbedService
.Build(); .Build();
} }
public async Task<MessageComponent[]> CreateMessageInfoMessageComponents(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
{
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
var ctx = LookupContext.ByNonOwner;
var serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
// Need this whole dance to handle cases where:
// - the user is deleted (userInfo == null)
// - the bot's no longer in the server we're querying (channel == null)
// - the member is no longer in the server we're querying (memberInfo == null)
// TODO: optimize ordering here a bit with new cache impl; and figure what happens if bot leaves server -> channel still cached -> hits this bit and 401s?
GuildMemberPartial memberInfo = null;
User userInfo = null;
if (channel != null)
{
GuildMember member = null;
try
{
member = await _rest.GetGuildMember(channel.GuildId!.Value, msg.Message.Sender);
}
catch (ForbiddenException)
{
// no permission, couldn't fetch, oh well
}
if (member != null)
// Don't do an extra request if we already have this info from the member lookup
userInfo = member.User;
memberInfo = member;
}
if (userInfo == null)
userInfo = await _cache.GetOrFetchUser(_rest, msg.Message.Sender);
// Calculate string displayed under "Sent by"
string userStr;
if (showContent && memberInfo != null && memberInfo.Nick != null)
userStr = $"**\n Username:** {userInfo.NameAndMention()}\n** Nickname:** {memberInfo.Nick}";
else if (userInfo != null) userStr = userInfo.NameAndMention();
else userStr = $"*(deleted user {msg.Message.Sender})*";
var content = serverMsg?.Content?.NormalizeLineEndSpacing();
if (content == null || !showContent)
content = "*(message contents deleted or inaccessible)*";
var systemStr = msg.System == null
? "*(deleted or unknown system)*"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.DisplayHid(ccfg)}`)" : $"`{msg.System.DisplayHid(ccfg)}`";
var memberStr = msg.Member == null
? "*(deleted member)*"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.DisplayHid(ccfg)}`)";
var roles = memberInfo?.Roles?.ToList();
var rolesContent = "";
if (roles != null && roles.Count > 0 && showContent)
{
var guild = await _cache.GetGuild(channel.GuildId!.Value);
var rolesString = string.Join(", ", (roles
.Select(id =>
{
var role = Array.Find(guild.Roles, r => r.Id == id);
if (role != null)
return role;
return new Role { Name = "*(unknown role)*", Position = 0 };
}))
.OrderByDescending(role => role.Position)
.Select(role => role.Name));
rolesContent = $"**Account Roles ({roles.Count})**\n{rolesString}";
}
MessageComponent authorData = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"**System:** {systemStr}\n**Member:** {memberStr}\n**Sent by:** {userStr}\n\n{rolesContent}"
};
var avatarURL = msg.Member?.AvatarFor(ctx).TryGetCleanCdnUrl();
MessageComponent header = (avatarURL == "" || avatarURL == null) ? authorData : new MessageComponent()
{
Type = ComponentType.Section,
Components = [authorData],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new ComponentMedia()
{
Url = avatarURL
}
}
};
List<MessageComponent> body = [
new MessageComponent()
{
Type = ComponentType.Separator,
Spacing = 2
}
];
if (content != "")
{
body.Add(new MessageComponent()
{
Type = ComponentType.Text,
Content = content
});
}
if (showContent)
{
if (serverMsg != null)
{
var media = new List<ComponentMediaItem>();
foreach (Message.Attachment attachment in serverMsg?.Attachments)
{
var url = attachment.Url;
if (url != null && url != "")
media.Add(new ComponentMediaItem()
{
Media = new ComponentMedia()
{
Url = url
}
});
}
if (media.Count > 0)
body.Add(new MessageComponent()
{
Type = ComponentType.MediaGallery,
Items = media.ToArray()
});
}
}
MessageComponent footer = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# Original Message ID: {msg.Message.OriginalMid} · <t:{DiscordUtils.SnowflakeToTimestamp(msg.Message.Mid)}:f>"
};
return [
new MessageComponent()
{
Type = ComponentType.Container,
Components = [
header,
..body
]
},
footer
];
}
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null) public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
{ {
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel); var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
@ -852,6 +1004,106 @@ public class EmbedService
return eb.Build(); return eb.Build();
} }
public async Task<MessageComponent[]> CreateAuthorMessageComponents(User? user, FullMessage msg)
{
MessageComponent authorInfo;
var author = user != null
? $"{user.Username}#{user.Discriminator}"
: $"Deleted user ${msg.Message.Sender}";
var avatarUrl = user?.AvatarUrl();
var authorString = $"{author}\n**ID: **`{msg.Message.Sender.ToString()}`";
if (user != null && avatarUrl != "")
{
authorInfo = new MessageComponent()
{
Type = ComponentType.Section,
Components = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = authorString
}
],
Accessory = new MessageComponent()
{
Type = ComponentType.Thumbnail,
Media = new ComponentMedia()
{
Url = avatarUrl
}
}
};
}
else
{
authorInfo = new MessageComponent()
{
Type = ComponentType.Text,
Content = authorString
};
}
MessageComponent container = new MessageComponent()
{
Type = ComponentType.Container,
Components = [
authorInfo,
]
};
return (
[
new MessageComponent()
{
Type = ComponentType.Text,
Content = user != null ? $"{user.Mention()} ({user.Id})" : $"*(deleted user {msg.Message.Sender})*"
},
container
]
);
}
public async Task<MessageComponent[]> CreateCommandMessageInfoMessageComponents(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;
}
List<MessageComponent> body = [
new MessageComponent()
{
Type = ComponentType.Text,
Content = $"### Command response message\n**Original message:** https://discord.com/channels/{msg.Guild}/{msg.Channel}/{msg.OriginalMid}\n**Sent By:** <@{msg.Sender}>"
},
new MessageComponent()
{
Type = ComponentType.Separator,
},
new MessageComponent()
{
Type = ComponentType.Text,
Content = content
},
];
MessageComponent footer = new MessageComponent()
{
Type = ComponentType.Text,
Content = $"-# Original Message ID: {msg.OriginalMid} · <t:{DiscordUtils.SnowflakeToTimestamp(msg.OriginalMid)}:f>"
};
return [
new MessageComponent(){
Type = ComponentType.Container,
Components = [
..body
]
},
footer
];
}
public async Task<Embed> CreateCommandMessageInfoEmbed(Core.CommandMessage msg, bool showContent) public async Task<Embed> CreateCommandMessageInfoEmbed(Core.CommandMessage msg, bool showContent)
{ {
var content = "*(command message deleted or inaccessible)*"; var content = "*(command message deleted or inaccessible)*";

View file

@ -39,6 +39,9 @@ public static class DiscordUtils
public static Instant SnowflakeToInstant(ulong snowflake) => public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22); Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22);
public static ulong SnowflakeToTimestamp(ulong snowflake) =>
((ulong)Instant.FromUtc(2015, 1, 1, 0, 0, 0).ToUnixTimeMilliseconds() + (snowflake >> 22)) / 1000;
public static ulong InstantToSnowflake(Instant time) => public static ulong InstantToSnowflake(Instant time) =>
(ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22; (ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22;

View file

@ -5,6 +5,7 @@ using Autofac;
using Myriad.Cache; using Myriad.Cache;
using Myriad.Gateway; using Myriad.Gateway;
using Myriad.Rest; using Myriad.Rest;
using Myriad.Rest.Types;
using Myriad.Types; using Myriad.Types;
using PluralKit.Core; using PluralKit.Core;
@ -76,6 +77,17 @@ public class InteractionContext
}); });
} }
public async Task Reply(MessageComponent[] components = null, AllowedMentions? mentions = null)
{
await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
new InteractionApplicationCommandCallbackData
{
Components = components,
Flags = Message.MessageFlags.Ephemeral | Message.MessageFlags.IsComponentsV2,
AllowedMentions = mentions ?? new AllowedMentions()
});
}
public async Task Defer() public async Task Defer()
{ {
await Respond(InteractionResponse.ResponseType.DeferredChannelMessageWithSource, await Respond(InteractionResponse.ResponseType.DeferredChannelMessageWithSource,

View file

@ -127,13 +127,10 @@ fn router(ctx: ApiContext) -> Router {
.route("/v2/groups/{group_id}/oembed.json", get(rproxy)) .route("/v2/groups/{group_id}/oembed.json", get(rproxy))
.layer(middleware::ratelimit::ratelimiter(middleware::ratelimit::do_request_ratelimited)) // this sucks .layer(middleware::ratelimit::ratelimiter(middleware::ratelimit::do_request_ratelimited)) // this sucks
.layer(axum::middleware::from_fn(middleware::ignore_invalid_routes::ignore_invalid_routes)) .layer(axum::middleware::from_fn(middleware::ignore_invalid_routes::ignore_invalid_routes))
.layer(axum::middleware::from_fn(middleware::logger::logger))
.layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::params::params)) .layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::params::params))
.layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::auth::auth)) .layer(axum::middleware::from_fn_with_state(ctx.clone(), middleware::auth::auth))
.layer(axum::middleware::from_fn(middleware::logger::logger))
.layer(axum::middleware::from_fn(middleware::cors::cors)) .layer(axum::middleware::from_fn(middleware::cors::cors))
.layer(tower_http::catch_panic::CatchPanicLayer::custom(util::handle_panic)) .layer(tower_http::catch_panic::CatchPanicLayer::custom(util::handle_panic))

View file

@ -76,5 +76,10 @@ pub async fn auth(State(ctx): State<ApiContext>, mut req: Request, next: Next) -
req.extensions_mut() req.extensions_mut()
.insert(AuthState::new(authed_system_id, authed_app_id, internal)); .insert(AuthState::new(authed_system_id, authed_app_id, internal));
next.run(req).await let mut res = next.run(req).await;
res.extensions_mut()
.insert(AuthState::new(authed_system_id, authed_app_id, internal));
res
} }

View file

@ -12,9 +12,10 @@ const MIN_LOG_TIME: u128 = 2_000;
pub async fn logger(request: Request, next: Next) -> Response { pub async fn logger(request: Request, next: Next) -> Response {
let method = request.method().clone(); let method = request.method().clone();
let headers = request.headers().clone();
let remote_ip = header_or_unknown(request.headers().get("X-PluralKit-Client-IP")); let remote_ip = header_or_unknown(headers.get("X-PluralKit-Client-IP"));
let user_agent = header_or_unknown(request.headers().get("User-Agent")); let user_agent = header_or_unknown(headers.get("User-Agent"));
let extensions = request.extensions().clone(); let extensions = request.extensions().clone();
@ -24,10 +25,6 @@ pub async fn logger(request: Request, next: Next) -> Response {
.map(|v| v.as_str().to_string()) .map(|v| v.as_str().to_string())
.unwrap_or("unknown".to_string()); .unwrap_or("unknown".to_string());
let auth = extensions
.get::<AuthState>()
.expect("should always have AuthState");
let uri = request.uri().clone(); let uri = request.uri().clone();
let request_span = span!( let request_span = span!(
@ -43,15 +40,24 @@ pub async fn logger(request: Request, next: Next) -> Response {
let response = next.run(request).instrument(request_span).await; let response = next.run(request).instrument(request_span).await;
let elapsed = start.elapsed().as_millis(); let elapsed = start.elapsed().as_millis();
let system_id = auth let rext = response.extensions().clone();
.system_id() let auth = rext.get::<AuthState>();
.map(|v| v.to_string())
.unwrap_or("none".to_string());
let app_id = auth let system_id = if let Some(auth) = auth {
.app_id() auth.system_id()
.map(|v| v.to_string()) .map(|v| v.to_string())
.unwrap_or("none".to_string()); .unwrap_or("none".to_string())
} else {
"none".to_string()
};
let app_id = if let Some(auth) = auth {
auth.app_id()
.map(|v| v.to_string())
.unwrap_or("none".to_string())
} else {
"none".to_string()
};
counter!( counter!(
"pluralkit_api_requests", "pluralkit_api_requests",
@ -73,6 +79,14 @@ pub async fn logger(request: Request, next: Next) -> Response {
.record(elapsed as f64 / 1_000_f64); .record(elapsed as f64 / 1_000_f64);
info!( info!(
status = response.status().as_str(),
method = method.to_string(),
endpoint,
elapsed,
user_agent,
remote_ip,
system_id,
app_id,
"{} handled request for {} {} in {}ms", "{} handled request for {} {} in {}ms",
response.status(), response.status(),
method, method,

View file

@ -0,0 +1,14 @@
[package]
name = "app-commands"
version = "0.1.0"
edition = "2024"
[dependencies]
libpk = { path = "../libpk" }
anyhow = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
twilight-http = { workspace = true }
twilight-model = { workspace = true }
twilight-util = { workspace = true }

View file

@ -0,0 +1,41 @@
use twilight_model::{
application::command::{Command, CommandType},
guild::IntegrationApplication,
};
use twilight_util::builder::command::CommandBuilder;
#[libpk::main]
async fn main() -> anyhow::Result<()> {
let discord = twilight_http::Client::builder()
.token(
libpk::config
.discord
.as_ref()
.expect("missing discord config")
.bot_token
.clone(),
)
.build();
let interaction = discord.interaction(twilight_model::id::Id::new(
libpk::config
.discord
.as_ref()
.expect("missing discord config")
.client_id
.clone()
.get(),
));
let commands = vec![
// message commands
// description must be empty string
CommandBuilder::new("\u{2753} Message info", "", CommandType::Message).build(),
CommandBuilder::new("\u{274c} Delete message", "", CommandType::Message).build(),
CommandBuilder::new("\u{1f514} Ping author", "", CommandType::Message).build(),
];
interaction.set_global_commands(&commands).await?;
Ok(())
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -55,8 +55,8 @@ This includes:
For more information about why certain information must be public, see [this github issue](https://github.com/PluralKit/PluralKit/issues/238). For more information about why certain information must be public, see [this github issue](https://github.com/PluralKit/PluralKit/issues/238).
### Is there a way to restrict PluralKit usage to a certain role? / Can I remove PluralKit access for specific users in my server? ### Is there a way to restrict PluralKit usage to a certain role? / Can I remove PluralKit access for specific users in my server?
This is not a feature currently available in PluralKit. It may be added in the future. PluralKit does not, and *will not*, support restricting usage of the bot by role.
In the meantime, this feature is supported in Tupperbox (an alternative proxying bot) - ask about it in their support server: <https://discord.gg/Z4BHccHhy3> This feature is supported in Tupperbox (an alternative proxying bot) - ask about it in their support server: <https://discord.gg/Z4BHccHhy3>
### Is it possible to block proxied messages (like blocking a user)? ### Is it possible to block proxied messages (like blocking a user)?
No. Since proxied messages are posted through webhooks, and those technically aren't real users on Discord's end, it's not possible to block them. Blocking PluralKit itself will also not block the webhook messages. Discord also does not allow you to control who can receive a specific message, so it's not possible to integrate a blocking system in the bot, either. Sorry :/ No. Since proxied messages are posted through webhooks, and those technically aren't real users on Discord's end, it's not possible to block them. Blocking PluralKit itself will also not block the webhook messages. Discord also does not allow you to control who can receive a specific message, so it's not possible to integrate a blocking system in the bot, either. Sorry :/

View file

@ -11,4 +11,14 @@ This bot detects messages with certain tags associated with a profile, then repl
#### for example... #### for example...
![demonstration of PluralKit](./assets/demo.gif) ![demonstration of PluralKit](./assets/demo.gif)
For more information, see the links to the left, or click [here](https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904) to invite the bot to your server! For more information, see the links to the left, or click [here](https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904) to invite the bot to your server!
## Sponsors
<div style="text-align:center;">
![Prodigi logo](./assets/prodigi.png)
</div>
PluralKit's infrastructure is generously sponsored by [Prodigi](https://prodigi.nz), a New Zealand based technology services provider.

View file

@ -5,7 +5,7 @@ Because PluralKit deletes messages as part of proxying, this can often clutter u
## Bots with PluralKit support ## Bots with PluralKit support
Some moderation bots have official PluralKit support, and properly handle excluding proxy deletes, as well as add PK-specific information to relevant log messages: Some moderation bots have official PluralKit support, and properly handle excluding proxy deletes, as well as add PK-specific information to relevant log messages:
- [**Catalogger**](https://catalogger.starshines.xyz/docs) - [**Catalogger**](https://catalogger.app)
- [**Aero**](https://aero.bot/) - [**Aero**](https://aero.bot/)
- [**CoreBot**](https://discord.gg/GAAj6DDrCJ) - [**CoreBot**](https://discord.gg/GAAj6DDrCJ)
- [**Quark**](https://quark.bot) - [**Quark**](https://quark.bot)

View file

@ -1,4 +0,0 @@
/commands.json
*.pyc
__pycache__/

View file

@ -1,23 +0,0 @@
# PluralKit "application command" helpers
## Adding new commands
Edit the `COMMAND_LIST` global in `commands.py`, making sure that any
command names that are specified in that file match up with the
command names used in the bot code (which will generally be in the list
in `PluralKit.Bot/ApplicationCommandMeta/ApplicationCommandList.cs`).
TODO: add helpers for slash commands to this
## Dumping application command JSON
Run `python3 commands.py` to get a JSON dump of the available application
commands - this is in a format that can be sent to Discord as a `PUT` to
`/applications/{clientId}/commands`.
## Updating Discord's list of application commands
From the root of the repository (where your `pluralkit.conf` resides),
run `python3 ./scripts/app-commands/update.py`. This will **REPLACE**
any existing application commands that Discord knows about, with the
updated list.

View file

@ -1,10 +0,0 @@
from common import *
COMMAND_LIST = [
MessageCommand("\U00002753 Message info"),
MessageCommand("\U0000274c Delete message"),
MessageCommand("\U0001f514 Ping author"),
]
if __name__ == "__main__":
print(__import__('json').dumps(COMMAND_LIST))

View file

@ -1 +0,0 @@
from .types import MessageCommand

View file

@ -1,7 +0,0 @@
class MessageCommand(dict):
COMMAND_TYPE = 3
def __init__(self, name):
super().__init__()
self["type"] = self.__class__.COMMAND_TYPE
self["name"] = name

View file

@ -1,70 +0,0 @@
from common import *
from commands import COMMAND_LIST
import io
import os
import sys
import json
from pathlib import Path
from urllib import request
from urllib.error import URLError
DISCORD_API_BASE = "https://discord.com/api/v10"
def get_config():
data = {}
# prefer token from environment if present
envbase = ["PluralKit", "Bot"]
for var in ["Token", "ClientId"]:
for sep in [':', '__']:
envvar = sep.join(envbase + [var])
if envvar in os.environ:
data[var] = os.environ[envvar]
if "Token" in data and "ClientId" in data:
return data
# else fall back to config
cfg_path = Path(os.getcwd()) / "pluralkit.conf"
if cfg_path.exists():
cfg = {}
with open(str(cfg_path), 'r') as fh:
cfg = json.load(fh)
if 'PluralKit' in cfg and 'Bot' in cfg['PluralKit']:
return cfg['PluralKit']['Bot']
return None
def main():
config = get_config()
if config is None:
raise ArgumentError("config was not loaded")
if 'Token' not in config or 'ClientId' not in config:
raise ArgumentError("config is missing 'Token' or 'ClientId'")
data = json.dumps(COMMAND_LIST)
url = DISCORD_API_BASE + f"/applications/{config['ClientId']}/commands"
req = request.Request(url, method='PUT', data=data.encode('utf-8'))
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bot {config['Token']}")
req.add_header("User-Agent", "PluralKit (app-commands updater; https://pluralkit.me)")
try:
with request.urlopen(req) as resp:
if resp.status == 200:
print("Update successful!")
return 0
except URLError as resp:
print(f"[!!!] Update not successful: status {resp.status}", file=sys.stderr)
print(f"[!!!] Response body below:\n", file=sys.stderr)
print(resp.read(), file=sys.stderr)
sys.stderr.flush()
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,3 +0,0 @@
#!/bin/sh
docker-compose -f "$(dirname $0)/../docker-compose.yml" exec -T -u postgres db pg_dump postgres

View file

@ -1,15 +0,0 @@
#!/bin/sh
# Usage: rclone-db.sh <remote>:<path>
# eg. rclone-db.sh b2:pluralkit
FILENAME=pluralkit-$(date -u +"%Y-%m-%dT%H:%M:%S").sql.gz
echo Dumping database to /tmp/$FILENAME...
$(dirname $0)/dump-db.sh | gzip > /tmp/$FILENAME
echo Transferring to remote $1...
rclone -P copy /tmp/$FILENAME $1
echo Cleaning up...
rm /tmp/$FILENAME

View file

@ -1,5 +0,0 @@
#!/bin/sh
# Runs a local database in the background listening on port 5432, deleting itself once stopped
# Requires Docker. May need sudo if your user isn't in the `docker` group.
docker run --rm --detach --publish 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust postgres:alpine