Merge pull request #150 from xSke/feature/autoproxy

Implement autoproxy. Closes #149.
This commit is contained in:
Astrid 2020-01-25 16:35:31 +01:00 committed by GitHub
commit eb44c8fda2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 393 additions and 12 deletions

View File

@ -111,6 +111,7 @@ namespace PluralKit.Bot
.AddTransient<HelpCommands>()
.AddTransient<ModCommands>()
.AddTransient<MiscCommands>()
.AddTransient<AutoproxyCommands>()
.AddTransient<EmbedService>()
.AddTransient<ProxyService>()
@ -120,6 +121,7 @@ namespace PluralKit.Bot
.AddTransient<ProxyCacheService>()
.AddSingleton<WebhookCacheService>()
.AddSingleton<AutoproxyCacheService>()
.AddSingleton<ShardInfoService>()
.AddSingleton<CpuStatService>()

View File

@ -0,0 +1,144 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Discord;
using PluralKit.Bot.CommandSystem;
namespace PluralKit.Bot.Commands
{
public class AutoproxyCommands
{
private IDataStore _data;
private AutoproxyCacheService _cache;
public AutoproxyCommands(IDataStore data, AutoproxyCacheService cache)
{
_data = data;
_cache = cache;
}
public async Task Autoproxy(Context ctx)
{
ctx.CheckSystem().CheckGuildContext();
if (ctx.Match("off", "stop", "cancel", "no"))
await AutoproxyOff(ctx);
else if (ctx.Match("latch", "last", "proxy", "stick", "sticky"))
await AutoproxyLatch(ctx);
else if (ctx.Match("front", "fronter", "switch"))
await AutoproxyFront(ctx);
else if (ctx.Match("member"))
throw new PKSyntaxError("Member-mode autoproxy must target a specific member. Use the `pk;autoproxy <member>` command, where `member` is the name or ID of a member in your system.");
else if (await ctx.MatchMember() is PKMember member)
await AutoproxyMember(ctx, member);
else if (!ctx.HasNext())
await ctx.Reply(embed: await CreateAutoproxyStatusEmbed(ctx));
else
throw new PKSyntaxError($"Invalid autoproxy mode `{ctx.PopArgument().EscapeMarkdown()}`.");
}
private async Task AutoproxyOff(Context ctx)
{
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
if (settings.AutoproxyMode == AutoproxyMode.Off)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already off in this server.");
}
else
{
settings.AutoproxyMode = AutoproxyMode.Off;
settings.AutoproxyMember = null;
await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings);
await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id);
await ctx.Reply($"{Emojis.Success} Autoproxy turned off in this server.");
}
}
private async Task AutoproxyLatch(Context ctx)
{
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
if (settings.AutoproxyMode == AutoproxyMode.Latch)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to latch mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
}
else
{
settings.AutoproxyMode = AutoproxyMode.Latch;
settings.AutoproxyMember = null;
await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings);
await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id);
await ctx.Reply($"{Emojis.Success} Autoproxy set to latch mode in this server. Messages will now be autoproxied using the *last-proxied member* in this server.");
}
}
private async Task AutoproxyFront(Context ctx)
{
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
if (settings.AutoproxyMode == AutoproxyMode.Front)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already set to front mode in this server. If you want to disable autoproxying, use `pk;autoproxy off`.");
}
else
{
settings.AutoproxyMode = AutoproxyMode.Front;
settings.AutoproxyMember = null;
await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings);
await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id);
await ctx.Reply($"{Emojis.Success} Autoproxy set to front mode in this server. Messages will now be autoproxied using the *current first fronter*, if any.");
}
}
private async Task AutoproxyMember(Context ctx, PKMember member)
{
ctx.CheckOwnMember(member);
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
settings.AutoproxyMode = AutoproxyMode.Member;
settings.AutoproxyMember = member.Id;
await _data.SetSystemGuildSettings(ctx.System, ctx.Guild.Id, settings);
await _cache.FlushCacheForSystem(ctx.System, ctx.Guild.Id);
await ctx.Reply($"{Emojis.Success} Autoproxy set to **{member.Name}** in this server.");
}
private async Task<Embed> CreateAutoproxyStatusEmbed(Context ctx)
{
var settings = await _data.GetSystemGuildSettings(ctx.System, ctx.Guild.Id);
var commandList = "**pk;autoproxy latch** - Autoproxies as last-proxied member\n**pk;autoproxy front** - Autoproxies as current (first) fronter\n**pk;autoproxy <member>** - Autoproxies as a specific member";
var eb = new EmbedBuilder().WithTitle($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
switch (settings.AutoproxyMode) {
case AutoproxyMode.Off: eb.WithDescription($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}");
break;
case AutoproxyMode.Front: {
var lastSwitch = await _data.GetLatestSwitch(ctx.System);
if (lastSwitch == null)
eb.WithDescription("Autoproxy is currently set to **front mode** in this server, but you have no registered switches. Use the `pk;switch` command to log one.");
else
{
var firstMember = await _data.GetSwitchMembers(lastSwitch).FirstOrDefaultAsync();
eb.WithDescription(firstMember == null
? "Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered."
: $"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{firstMember.Name.EscapeMarkdown()}** (`{firstMember.Hid}`). To disable, type `pk;autoproxy off`.");
}
break;
}
// AutoproxyMember is never null if Mode is Member, this is just to make the compiler shut up
case AutoproxyMode.Member when settings.AutoproxyMember != null: {
var member = await _data.GetMemberById(settings.AutoproxyMember.Value);
eb.WithDescription($"Autoproxy is active for member **{member.Name}** (`{member.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
break;
}
case AutoproxyMode.Latch:
eb.WithDescription($"Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. To disable, type `pk;autoproxy off`.");
break;
default: throw new ArgumentOutOfRangeException();
}
return eb.Build();
}
}
}

View File

@ -23,6 +23,7 @@ namespace PluralKit.Bot.Commands
public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history");
public static Command SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown");
public static Command SystemPrivacy = new Command("system privacy", "system privacy <description|members|fronter|fronthistory> <public|private>", "Changes your system's privacy settings");
public static Command Autoproxy = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for this server");
public static Command MemberInfo = new Command("member", "member <member>", "Looks up information about a member");
public static Command MemberNew = new Command("member new", "member new <name>", "Creates a new member");
public static Command MemberRename = new Command("member rename", "member <member> rename <new name>", "Renames a member");
@ -87,6 +88,8 @@ namespace PluralKit.Bot.Commands
return HandleMemberCommand(ctx);
if (ctx.Match("switch", "sw"))
return HandleSwitchCommand(ctx);
if (ctx.Match("ap", "autoproxy", "auto"))
return ctx.Execute<AutoproxyCommands>(Autoproxy, m => m.Autoproxy(ctx));
if (ctx.Match("link"))
return ctx.Execute<LinkCommands>(Link, m => m.LinkSystem(ctx));
if (ctx.Match("unlink"))

View File

@ -0,0 +1,73 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using Microsoft.Extensions.Caching.Memory;
namespace PluralKit.Bot
{
public class AutoproxyCacheResult
{
public SystemGuildSettings GuildSettings;
public PKSystem System;
public PKMember AutoproxyMember;
}
public class AutoproxyCacheService
{
private IMemoryCache _cache;
private IDataStore _data;
private DbConnectionFactory _conn;
public AutoproxyCacheService(IMemoryCache cache, DbConnectionFactory conn, IDataStore data)
{
_cache = cache;
_conn = conn;
_data = data;
}
public async Task<AutoproxyCacheResult> GetGuildSettings(ulong account, ulong guild) =>
await _cache.GetOrCreateAsync(GetKey(account, guild), entry => FetchSettings(account, guild, entry));
public async Task FlushCacheForSystem(PKSystem system, ulong guild)
{
foreach (var account in await _data.GetSystemAccounts(system))
FlushCacheFor(account, guild);
}
public void FlushCacheFor(ulong account, ulong guild) =>
_cache.Remove(GetKey(account, guild));
private async Task<AutoproxyCacheResult> FetchSettings(ulong account, ulong guild, ICacheEntry entry)
{
using var conn = await _conn.Obtain();
var data = (await conn.QueryAsync<SystemGuildSettings, PKSystem, PKMember, AutoproxyCacheResult>(
"select system_guild.*, systems.*, members.* from accounts inner join systems on systems.id = accounts.system inner join system_guild on system_guild.system = systems.id left join members on system_guild.autoproxy_member = members.id where accounts.uid = @Uid and system_guild.guild = @Guild",
(guildSettings, system, autoproxyMember) => new AutoproxyCacheResult
{
GuildSettings = guildSettings,
System = system,
AutoproxyMember = autoproxyMember
},
new {Uid = account, Guild = guild})).FirstOrDefault();
if (data != null)
{
// Long expiry for accounts with no system/settings registered
entry.SetSlidingExpiration(TimeSpan.FromMinutes(5));
entry.SetAbsoluteExpiration(TimeSpan.FromHours(1));
}
else
{
// Shorter expiry if they already have settings
entry.SetSlidingExpiration(TimeSpan.FromMinutes(1));
entry.SetAbsoluteExpiration(TimeSpan.FromMinutes(5));
}
return data;
}
private string GetKey(ulong account, ulong guild) => $"_system_guild_{account}_{guild}";
}
}

View File

@ -9,6 +9,9 @@ using Discord;
using Discord.Net;
using Discord.WebSocket;
using NodaTime;
using NodaTime.Extensions;
using PluralKit.Core;
using Serilog;
@ -18,7 +21,7 @@ namespace PluralKit.Bot
class ProxyMatch {
public PKMember Member;
public PKSystem System;
public ProxyTag ProxyTags;
public ProxyTag? ProxyTags;
public string InnerText;
}
@ -31,8 +34,9 @@ namespace PluralKit.Bot
private ILogger _logger;
private WebhookExecutorService _webhookExecutor;
private ProxyCacheService _cache;
private AutoproxyCacheService _autoproxyCache;
public ProxyService(IDiscordClient client, LogChannelService logChannel, IDataStore data, EmbedService embeds, ILogger logger, ProxyCacheService cache, WebhookExecutorService webhookExecutor, DbConnectionFactory conn)
public ProxyService(IDiscordClient client, LogChannelService logChannel, IDataStore data, EmbedService embeds, ILogger logger, ProxyCacheService cache, WebhookExecutorService webhookExecutor, DbConnectionFactory conn, AutoproxyCacheService autoproxyCache)
{
_client = client;
_logChannel = logChannel;
@ -41,6 +45,7 @@ namespace PluralKit.Bot
_cache = cache;
_webhookExecutor = webhookExecutor;
_conn = conn;
_autoproxyCache = autoproxyCache;
_logger = logger.ForContext<ProxyService>();
}
@ -92,8 +97,13 @@ namespace PluralKit.Bot
if (!(message.Channel is ITextChannel channel)) return;
// Find a member with proxy tags matching the message
var results = await _cache.GetResultsFor(message.Author.Id);
var results = (await _cache.GetResultsFor(message.Author.Id)).ToList();
var match = GetProxyTagMatch(message.Content, results);
// If we didn't get a match by proxy tags, try to get one by autoproxy
if (match == null) match = await GetAutoproxyMatch(message, channel);
// If we still haven't found any, just yeet
if (match == null) return;
// Gather all "extra" data from DB at once
@ -122,8 +132,9 @@ namespace PluralKit.Bot
if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName);
// Add the proxy tags into the proxied message if that option is enabled
var messageContents = match.Member.KeepProxy
? $"{match.ProxyTags.Prefix}{match.InnerText}{match.ProxyTags.Suffix}"
// Also check if the member has any proxy tags - some cases autoproxy can return a member with no tags
var messageContents = (match.Member.KeepProxy && match.ProxyTags.HasValue)
? $"{match.ProxyTags.Value.Prefix}{match.InnerText}{match.ProxyTags.Value.Suffix}"
: match.InnerText;
// Sanitize @everyone, but only if the original user wouldn't have permission to
@ -138,7 +149,7 @@ namespace PluralKit.Bot
);
// Store the message in the database, and log it in the log channel (if applicable)
await _data.AddMessage(message.Author.Id, hookMessageId, message.Channel.Id, message.Id, match.Member);
await _data.AddMessage(message.Author.Id, hookMessageId, channel.GuildId, message.Channel.Id, message.Id, match.Member);
await _logChannel.LogMessage(match.System, match.Member, hookMessageId, message.Id, message.Channel as IGuildChannel, message.Author, match.InnerText, aux.Guild);
// Wait a second or so before deleting the original message
@ -155,6 +166,57 @@ namespace PluralKit.Bot
}
}
private async Task<ProxyMatch> GetAutoproxyMatch(IMessage message, IGuildChannel channel)
{
// For now we use a backslash as an "escape character", subject to change later
if ((message.Content ?? "").TrimStart().StartsWith("\"")) return null;
// Fetch info from the cache, bail if we don't have anything (either no system or no autoproxy settings - AP defaults to off so this works)
var autoproxyCache = await _autoproxyCache.GetGuildSettings(message.Author.Id, channel.GuildId);
if (autoproxyCache == null) return null;
PKMember member = null;
// Figure out which member to proxy as
switch (autoproxyCache.GuildSettings.AutoproxyMode)
{
case AutoproxyMode.Off:
// Autoproxy off, bail
return null;
case AutoproxyMode.Front:
// Front mode: just use the current first fronter
member = await _data.GetFirstFronter(autoproxyCache.System);
break;
case AutoproxyMode.Latch:
// Latch mode: find last proxied message, use *that* member
var msg = await _data.GetLastMessageInGuild(message.Author.Id, channel.GuildId);
if (msg == null) return null; // No message found
// If the message is older than 6 hours, ignore it and force the sender to "refresh" a proxy
// This can be revised in the future, it's a preliminary value.
var timestamp = SnowflakeUtils.FromSnowflake(msg.Message.Mid).ToInstant();
var timeSince = SystemClock.Instance.GetCurrentInstant() - timestamp;
if (timeSince > Duration.FromHours(6)) return null;
member = msg.Member;
break;
case AutoproxyMode.Member:
// Member mode: just use that member
member = autoproxyCache.AutoproxyMember;
break;
}
// If we haven't found the member (eg. front mode w/ no fronter), bail again
if (member == null) return null;
return new ProxyMatch
{
System = autoproxyCache.System,
Member = member,
// Autoproxying members with no proxy tags is possible, return the correct result
ProxyTags = member.ProxyTags.Count > 0 ? member.ProxyTags.First() : (ProxyTag?) null,
InnerText = message.Content
};
}
private static string SanitizeEveryoneMaybe(IMessage message, string messageContents)
{
var senderPermissions = ((IGuildUser) message.Author).GetPermissions(message.Channel as IGuildChannel);

View File

@ -0,0 +1,15 @@
-- Same sort of psuedo-enum due to Dapper limitations. See 2.sql.
-- 1 = autoproxy off
-- 2 = front mode (first fronter)
-- 3 = latch mode (last proxyer)
-- 4 = member mode (specific member)
alter table system_guild add column autoproxy_mode int check (autoproxy_mode in (1, 2, 3, 4)) not null default 1;
-- for member mode
alter table system_guild add column autoproxy_member int references members (id) on delete set null;
-- for latch mode
-- not *really* nullable, null just means old (pre-schema-change) data.
alter table messages add column guild bigint default null;
update info set schema_version = 3;

View File

@ -10,7 +10,7 @@ using Serilog;
namespace PluralKit {
public class SchemaService
{
private const int TargetSchemaVersion = 2;
private const int TargetSchemaVersion = 3;
private DbConnectionFactory _conn;
private ILogger _logger;

View File

@ -9,6 +9,14 @@ using NodaTime;
using Serilog;
namespace PluralKit {
public enum AutoproxyMode
{
Off = 1,
Front = 2,
Latch = 3,
Member = 4
}
public class FullMessage
{
public PKMessage Message;
@ -19,6 +27,7 @@ namespace PluralKit {
public struct PKMessage
{
public ulong Mid;
public ulong? Guild; // null value means "no data" (ie. from before this field being added)
public ulong Channel;
public ulong Sender;
public ulong? OriginalMid;
@ -68,6 +77,9 @@ namespace PluralKit {
public class SystemGuildSettings
{
public bool ProxyEnabled { get; set; } = true;
public AutoproxyMode AutoproxyMode { get; set; } = AutoproxyMode.Off;
public int? AutoproxyMember { get; set; }
}
public class MemberGuildSettings
@ -267,12 +279,13 @@ namespace PluralKit {
/// Saves a posted message to the database.
/// </summary>
/// <param name="senderAccount">The ID of the account that sent the original trigger message.</param>
/// <param name="guildId">The ID of the guild the message was posted to.</param>
/// <param name="channelId">The ID of the channel the message was posted to.</param>
/// <param name="postedMessageId">The ID of the message posted by the webhook.</param>
/// <param name="triggerMessageId">The ID of the original trigger message containing the proxy tags.</param>
/// <param name="proxiedMember">The member (and by extension system) that was proxied.</param>
/// <returns></returns>
Task AddMessage(ulong senderAccount, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember);
Task AddMessage(ulong senderAccount, ulong guildId, ulong channelId, ulong postedMessageId, ulong triggerMessageId, PKMember proxiedMember);
/// <summary>
/// Deletes a message from the data store.
@ -286,6 +299,12 @@ namespace PluralKit {
/// <param name="postedMessageIds">The IDs of the webhook messages to delete.</param>
Task DeleteMessagesBulk(IEnumerable<ulong> postedMessageIds);
/// <summary>
/// Gets the most recent message sent by a given account in a given guild.
/// </summary>
/// <returns>The full message object, or null if none was found.</returns>
Task<FullMessage> GetLastMessageInGuild(ulong account, ulong guild);
/// <summary>
/// Gets switches from a system.
/// </summary>
@ -335,6 +354,12 @@ namespace PluralKit {
/// <returns></returns>
Task<FrontBreakdown> GetFrontBreakdown(PKSystem system, Instant periodStart, Instant periodEnd);
/// <summary>
/// Gets the first listed fronter in a system.
/// </summary>
/// <returns>The first fronter, or null if none are registered.</returns>
Task<PKMember> GetFirstFronter(PKSystem system);
/// <summary>
/// Registers a switch with the given members in the given system.
/// </summary>
@ -434,11 +459,13 @@ namespace PluralKit {
public async Task SetSystemGuildSettings(PKSystem system, ulong guild, SystemGuildSettings settings)
{
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into system_guild (system, guild, proxy_enabled) values (@System, @Guild, @ProxyEnabled) on conflict (system, guild) do update set proxy_enabled = @ProxyEnabled", new
await conn.ExecuteAsync("insert into system_guild (system, guild, proxy_enabled, autoproxy_mode, autoproxy_member) values (@System, @Guild, @ProxyEnabled, @AutoproxyMode, @AutoproxyMember) on conflict (system, guild) do update set proxy_enabled = @ProxyEnabled, autoproxy_mode = @AutoproxyMode, autoproxy_member = @AutoproxyMember", new
{
System = system.Id,
Guild = guild,
ProxyEnabled = settings.ProxyEnabled
settings.ProxyEnabled,
settings.AutoproxyMode,
settings.AutoproxyMember
});
}
@ -661,10 +688,11 @@ namespace PluralKit {
using (var conn = await _conn.Obtain())
return await conn.ExecuteScalarAsync<ulong>("select count(id) from members");
}
public async Task AddMessage(ulong senderId, ulong messageId, ulong channelId, ulong originalMessage, PKMember member) {
public async Task AddMessage(ulong senderId, ulong messageId, ulong guildId, ulong channelId, ulong originalMessage, PKMember member) {
using (var conn = await _conn.Obtain())
await conn.ExecuteAsync("insert into messages(mid, channel, member, sender, original_mid) values(@MessageId, @ChannelId, @MemberId, @SenderId, @OriginalMid)", new {
await conn.ExecuteAsync("insert into messages(mid, guild, channel, member, sender, original_mid) values(@MessageId, @GuildId, @ChannelId, @MemberId, @SenderId, @OriginalMid)", new {
MessageId = messageId,
GuildId = guildId,
ChannelId = channelId,
MemberId = member.Id,
SenderId = senderId,
@ -703,6 +731,17 @@ namespace PluralKit {
}
}
public async Task<FullMessage> GetLastMessageInGuild(ulong account, ulong guild)
{
using var conn = await _conn.Obtain();
return (await conn.QueryAsync<PKMessage, PKMember, PKSystem, FullMessage>("select messages.*, members.*, systems.* from messages, members, systems where messages.guild = @Guild and messages.sender = @Uid and messages.member = members.id and systems.id = members.system order by mid desc limit 1", (msg, member, system) => new FullMessage
{
Message = msg,
System = system,
Member = member
}, new { Uid = account, Guild = guild })).FirstOrDefault();
}
public async Task<ulong> GetTotalMessages()
{
using (var conn = await _conn.Obtain())
@ -767,6 +806,15 @@ namespace PluralKit {
};
}
public async Task<PKMember> GetFirstFronter(PKSystem system)
{
// TODO: move to extension method since it doesn't rely on internals
var lastSwitch = await GetLatestSwitch(system);
if (lastSwitch == null) return null;
return await GetSwitchMembers(lastSwitch).FirstOrDefaultAsync();
}
public async Task AddSwitch(PKSystem system, IEnumerable<PKMember> members)
{
// Use a transaction here since we're doing multiple executed commands in one

View File

@ -311,6 +311,38 @@ Since the messages will be posted by PluralKit's webhook, there's no way to dele
To delete a PluralKit-proxied message, you can react to it with the ❌ emoji. Note that this only works if the message has
been sent from your own account.
### Autoproxying
The bot's *autoproxy* feature allows you to have messages be proxied without directly including the proxy tags. Autoproxy can be set up in various ways. There are three autoproxy modes currently implemented:
To see your system's current autoproxy settings, simply use the command:
pk;autoproxy
To disable autoproxying for the current server, use the command:
pk;autoproxy off
*(hint: `pk;autoproxy` can be shortened to `pk;ap` in all related commands)*
#### Front mode
This autoproxy mode will proxy messages as the current *first* fronter of the system. If you register a switch with `Alice` and `Bob`, messages without proxy tags will be autoproxied as `Alice`.
To enable front-mode autoproxying for a given server, use the following command:
pk;autoproxy front
#### Latch mode
This autoproxy mode will essentially "continue" previous proxy tags. If you proxy a message with `Alice`'s proxy tags, messages posted afterwards will be proxied as Alice. Proxying again with someone else's proxy tags, say, `Bob`, will cause messages *from then on* to be proxied as Bob.
In other words, it means proxy tags become "sticky". This will carry over across all channels in the same server.
To enable latch-mode autoproxying for a given server, use the following command:
pk;autoproxy latch
#### Member mode
This autoproxy mode will autoproxy for a specific selected member, irrelevant of past proxies or fronters.
To enable member-mode autoproxying for a given server, use the following command, where `<member>` is a member name (in "quotes" if multiple words) or 5-letter ID:
pk;autoproxy <member>
## Managing switches
PluralKit allows you to log member switches through the bot.
Essentially, this means you can mark one or more members as *the current fronter(s)* for the duration until the next switch.

View File

@ -24,6 +24,7 @@ Words in \<angle brackets> are *required parameters*. Words in [square brackets]
- `pk;system [id] frontpercent [timeframe]` - Shows the aggregated front history of a system within a given time frame.
- `pk;system [id] list` - Shows a paginated list of a system's members.
- `pk;system [id] list full` - Shows a paginated list of a system's members, with increased detail.
- `pk;autoproxy [off|front|latch|member]` - Updates the system's autoproxy settings for a given server.
- `pk;link <account>` - Links this system to a different account.
- `pk;unlink [account]` - Unlinks an account from this system.
## Member commands

View File

@ -21,6 +21,7 @@ PluralKit has a couple of useful command shorthands to reduce the typing:
|pk;member new|pk;m n|
|pk;switch|pk;sw|
|pk;message|pk;msg|
|pk;autoproxy|pk;ap|
## Permission checker command
If you're having issues with PluralKit not proxying, it may be an issue with your server's channel permission setup.