feat: pk;config

This commit is contained in:
spiral 2021-11-29 21:35:21 -05:00
parent d195c80d92
commit 56d07e0f2d
No known key found for this signature in database
GPG Key ID: A6059F0CA0E1BD31
41 changed files with 648 additions and 313 deletions

View File

@ -42,14 +42,14 @@ public class MemberController: ControllerBase
return BadRequest("Member name must be specified."); return BadRequest("Member name must be specified.");
var systemId = User.CurrentSystem(); var systemId = User.CurrentSystem();
var systemData = await _repo.GetSystem(systemId); var config = await _repo.GetSystemConfig(systemId);
await using var conn = await _db.Obtain(); await using var conn = await _db.Obtain();
// Enforce per-system member limit // Enforce per-system member limit
var memberCount = await conn.QuerySingleAsync<int>("select count(*) from members where system = @System", var memberCount = await conn.QuerySingleAsync<int>("select count(*) from members where system = @System",
new { System = systemId }); new { System = systemId });
var memberLimit = systemData?.MemberLimitOverride ?? Limits.MaxMemberCount; var memberLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (memberCount >= memberLimit) if (memberCount >= memberLimit)
return BadRequest($"Member limit reached ({memberCount} / {memberLimit})."); return BadRequest($"Member limit reached ({memberCount} / {memberLimit}).");

View File

@ -54,10 +54,11 @@ public class GroupControllerV2: PKControllerBase
public async Task<IActionResult> GroupCreate([FromBody] JObject data) public async Task<IActionResult> GroupCreate([FromBody] JObject data)
{ {
var system = await ResolveSystem("@me"); var system = await ResolveSystem("@me");
var config = await _repo.GetSystemConfig(system.Id);
// Check group cap // Check group cap
var existingGroupCount = await _repo.GetSystemGroupCount(system.Id); var existingGroupCount = await _repo.GetSystemGroupCount(system.Id);
var groupLimit = system.GroupLimitOverride ?? Limits.MaxGroupCount; var groupLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (existingGroupCount >= groupLimit) if (existingGroupCount >= groupLimit)
throw Errors.GroupLimitReached; throw Errors.GroupLimitReached;

View File

@ -37,9 +37,10 @@ public class MemberControllerV2: PKControllerBase
public async Task<IActionResult> MemberCreate([FromBody] JObject data) public async Task<IActionResult> MemberCreate([FromBody] JObject data)
{ {
var system = await ResolveSystem("@me"); var system = await ResolveSystem("@me");
var config = await _repo.GetSystemConfig(system.Id);
var memberCount = await _repo.GetSystemMemberCount(system.Id); var memberCount = await _repo.GetSystemMemberCount(system.Id);
var memberLimit = system.MemberLimitOverride ?? Limits.MaxMemberCount; var memberLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (memberCount >= memberLimit) if (memberCount >= memberLimit)
throw Errors.MemberLimitReached; throw Errors.MemberLimitReached;

View File

@ -34,4 +34,32 @@ public class SystemControllerV2: PKControllerBase
var newSystem = await _repo.UpdateSystem(system.Id, patch); var newSystem = await _repo.UpdateSystem(system.Id, patch);
return Ok(newSystem.ToJson(LookupContext.ByOwner, APIVersion.V2)); return Ok(newSystem.ToJson(LookupContext.ByOwner, APIVersion.V2));
} }
[HttpGet("{systemRef}/settings")]
public async Task<IActionResult> GetSystemSettings(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (ContextFor(system) != LookupContext.ByOwner)
throw Errors.GenericMissingPermissions;
var config = await _repo.GetSystemConfig(system.Id);
return Ok(config.ToJson());
}
[HttpPatch("{systemRef}/settings")]
public async Task<IActionResult> DoSystemSettingsPatch(string systemRef, [FromBody] JObject data)
{
var system = await ResolveSystem(systemRef);
if (ContextFor(system) != LookupContext.ByOwner)
throw Errors.GenericMissingPermissions;
var patch = SystemConfigPatch.FromJson(data);
patch.AssertIsValid();
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var newConfig = await _repo.UpdateSystemConfig(system.Id, patch);
return Ok(newConfig.ToJson());
}
} }

View File

@ -12,18 +12,18 @@ public partial class CommandTree
public static Command SystemAvatar = new Command("system icon", "system icon [url|@mention]", "Changes your system's icon"); public static Command SystemAvatar = new Command("system icon", "system icon [url|@mention]", "Changes your system's icon");
public static Command SystemBannerImage = new Command("system banner", "system banner [url]", "Set the system's banner image"); public static Command SystemBannerImage = new Command("system banner", "system banner [url]", "Set the system's banner image");
public static Command SystemDelete = new Command("system delete", "system delete", "Deletes your system"); public static Command SystemDelete = new Command("system delete", "system delete", "Deletes your system");
public static Command SystemTimezone = new Command("system timezone", "system timezone [timezone]", "Changes your system's time zone");
public static Command SystemProxy = new Command("system proxy", "system proxy [server id] [on|off]", "Enables or disables message proxying in a specific server"); public static Command SystemProxy = new Command("system proxy", "system proxy [server id] [on|off]", "Enables or disables message proxying in a specific server");
public static Command SystemList = new Command("system list", "system [system] list [full]", "Lists a system's members"); public static Command SystemList = new Command("system list", "system [system] list [full]", "Lists a system's members");
public static Command SystemFind = new Command("system find", "system [system] find [full] <search term>", "Searches a system's members given a search term"); public static Command SystemFind = new Command("system find", "system [system] find [full] <search term>", "Searches a system's members given a search term");
public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)"); public static Command SystemFronter = new Command("system fronter", "system [system] fronter", "Shows a system's fronter(s)");
public static Command SystemFrontHistory = new Command("system fronthistory", "system [system] fronthistory", "Shows a system's front history"); 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 SystemFrontPercent = new Command("system frontpercent", "system [system] frontpercent [timespan]", "Shows a system's front breakdown");
public static Command SystemPing = new Command("system ping", "system ping <enable|disable>", "Changes your system's ping preferences");
public static Command SystemPrivacy = new Command("system privacy", "system privacy <description|members|fronter|fronthistory|all> <public|private>", "Changes your system's privacy settings"); public static Command SystemPrivacy = new Command("system privacy", "system privacy <description|members|fronter|fronthistory|all> <public|private>", "Changes your system's privacy settings");
public static Command ConfigTimezone = new Command("config timezone", "config timezone [timezone]", "Changes your system's time zone");
public static Command ConfigPing = new Command("config ping", "config ping <enable|disable>", "Changes your system's ping preferences");
public static Command ConfigAutoproxyAccount = new Command("config autoproxy account", "autoproxy account [on|off]", "Toggles autoproxy globally for the current account");
public static Command ConfigAutoproxyTimeout = new Command("config autoproxy timeout", "autoproxy timeout [<duration>|off|reset]", "Sets the latch timeout duration for your system");
public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server"); public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server");
public static Command AutoproxyTimeout = new Command("autoproxy", "autoproxy timeout [<duration>|off|reset]", "Sets the latch timeout duration for your system");
public static Command AutoproxyAccount = new Command("autoproxy", "autoproxy account [on|off]", "Toggles autoproxy globally for the current account");
public static Command MemberInfo = new Command("member", "member <member>", "Looks up information about a member"); 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 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"); public static Command MemberRename = new Command("member rename", "member <member> rename <new name>", "Renames a member");
@ -95,8 +95,7 @@ public partial class CommandTree
public static Command[] SystemCommands = public static Command[] SystemCommands =
{ {
SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemBannerImage, SystemColor, SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, SystemAvatar, SystemBannerImage, SystemColor,
SystemDelete, SystemTimezone, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemDelete, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent, SystemPrivacy, SystemProxy
SystemPrivacy, SystemProxy
}; };
public static Command[] MemberCommands = public static Command[] MemberCommands =
@ -124,7 +123,10 @@ public partial class CommandTree
Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll
}; };
public static Command[] AutoproxyCommands = { AutoproxySet, AutoproxyTimeout, AutoproxyAccount }; public static Command[] ConfigCommands =
{
ConfigTimezone, ConfigPing, ConfigAutoproxyAccount, ConfigAutoproxyTimeout
};
public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable }; public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable };

View File

@ -20,6 +20,8 @@ public partial class CommandTree
return CommandHelpRoot(ctx); return CommandHelpRoot(ctx);
if (ctx.Match("ap", "autoproxy", "auto")) if (ctx.Match("ap", "autoproxy", "auto"))
return HandleAutoproxyCommand(ctx); return HandleAutoproxyCommand(ctx);
if (ctx.Match("config", "cfg"))
return HandleConfigCommand(ctx);
if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd")) if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd"))
return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System)); return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
if (ctx.Match("link")) if (ctx.Match("link"))
@ -167,7 +169,7 @@ public partial class CommandTree
else if (ctx.Match("webhook", "hook")) else if (ctx.Match("webhook", "hook"))
await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx)); await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx));
else if (ctx.Match("timezone", "tz")) else if (ctx.Match("timezone", "tz"))
await ctx.Execute<SystemEdit>(SystemTimezone, m => m.SystemTimezone(ctx)); await ctx.Execute<Config>(ConfigTimezone, m => m.SystemTimezone(ctx), true);
else if (ctx.Match("proxy")) else if (ctx.Match("proxy"))
await ctx.Execute<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx)); await ctx.Execute<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx));
else if (ctx.Match("list", "l", "members")) else if (ctx.Match("list", "l", "members"))
@ -190,7 +192,7 @@ public partial class CommandTree
else if (ctx.Match("privacy")) else if (ctx.Match("privacy"))
await ctx.Execute<SystemEdit>(SystemPrivacy, m => m.SystemPrivacy(ctx)); await ctx.Execute<SystemEdit>(SystemPrivacy, m => m.SystemPrivacy(ctx));
else if (ctx.Match("ping")) else if (ctx.Match("ping"))
await ctx.Execute<SystemEdit>(SystemPing, m => m.SystemPing(ctx)); await ctx.Execute<Config>(ConfigPing, m => m.SystemPing(ctx), true);
else if (ctx.Match("commands", "help")) else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "systems", SystemCommands); await PrintCommandList(ctx, "systems", SystemCommands);
else if (ctx.Match("groups", "gs", "g")) else if (ctx.Match("groups", "gs", "g"))
@ -206,8 +208,7 @@ public partial class CommandTree
if (target == null) if (target == null)
{ {
var list = CreatePotentialCommandList(SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc, var list = CreatePotentialCommandList(SystemInfo, SystemNew, SystemRename, SystemTag, SystemDesc,
SystemAvatar, SystemDelete, SystemTimezone, SystemList, SystemFronter, SystemFrontHistory, SystemAvatar, SystemDelete, SystemList, SystemFronter, SystemFrontHistory, SystemFrontPercent);
SystemFrontPercent);
await ctx.Reply( await ctx.Reply(
$"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\nPerhaps you meant to use one of the following commands?\n{list}"); $"{Emojis.Error} {await CreateSystemNotFoundError(ctx)}\n\nPerhaps you meant to use one of the following commands?\n{list}");
} }
@ -435,9 +436,9 @@ public partial class CommandTree
case "bl": case "bl":
await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands);
break; break;
case "autoproxy": case "config":
case "ap": case "cfg":
await PrintCommandList(ctx, "autoproxy", AutoproxyCommands); await PrintCommandList(ctx, "settings", ConfigCommands);
break; break;
// todo: are there any commands that still need to be added? // todo: are there any commands that still need to be added?
default: default:
@ -448,22 +449,43 @@ public partial class CommandTree
private Task HandleAutoproxyCommand(Context ctx) private Task HandleAutoproxyCommand(Context ctx)
{ {
if (ctx.Match("commands"))
return PrintCommandList(ctx, "autoproxy", AutoproxyCommands);
// ctx.CheckSystem(); // ctx.CheckSystem();
// oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything. // oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything.
// so we just emulate checking and throwing an error. // so we just emulate checking and throwing an error.
if (ctx.System == null) if (ctx.System == null)
return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}"); return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}");
// todo: move this whole block to Autoproxy.cs when these are removed
if (ctx.Match("account", "ac")) if (ctx.Match("account", "ac"))
return ctx.Execute<Autoproxy>(AutoproxyAccount, m => m.AutoproxyAccount(ctx)); return ctx.Execute<Config>(ConfigAutoproxyAccount, m => m.AutoproxyAccount(ctx), true);
if (ctx.Match("timeout", "tm")) if (ctx.Match("timeout", "tm"))
return ctx.Execute<Autoproxy>(AutoproxyTimeout, m => m.AutoproxyTimeout(ctx)); return ctx.Execute<Config>(ConfigAutoproxyTimeout, m => m.AutoproxyTimeout(ctx), true);
return ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx)); return ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx));
} }
private Task HandleConfigCommand(Context ctx)
{
if (ctx.System == null)
return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}");
if (!ctx.HasNext())
return ctx.Execute<Config>(null, m => m.ShowConfig(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "account", "ac" }))
return ctx.Execute<Config>(null, m => m.AutoproxyAccount(ctx));
if (ctx.MatchMultiple(new[] { "autoproxy", "ap" }, new[] { "timeout", "tm" }))
return ctx.Execute<Config>(null, m => m.AutoproxyTimeout(ctx));
if (ctx.Match("timezone", "zone", "tz"))
return ctx.Execute<Config>(null, m => m.SystemTimezone(ctx));
if (ctx.Match("ping"))
return ctx.Execute<Config>(null, m => m.SystemPing(ctx));
// todo: maybe add the list of configuration keys here?
return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `pk;commands config` for the list of possible config settings.");
}
private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands)
{ {
var commandListStr = CreatePotentialCommandList(potentialCommands); var commandListStr = CreatePotentialCommandList(potentialCommands);

View File

@ -5,6 +5,8 @@ using App.Metrics;
using Autofac; using Autofac;
using NodaTime;
using Myriad.Cache; using Myriad.Cache;
using Myriad.Extensions; using Myriad.Extensions;
using Myriad.Gateway; using Myriad.Gateway;
@ -27,13 +29,14 @@ public class Context
private Command? _currentCommand; private Command? _currentCommand;
public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset, public Context(ILifetimeScope provider, Shard shard, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
PKSystem senderSystem, MessageContext messageContext) PKSystem senderSystem, SystemConfig config, MessageContext messageContext)
{ {
Message = (Message)message; Message = (Message)message;
Shard = shard; Shard = shard;
Guild = guild; Guild = guild;
Channel = channel; Channel = channel;
System = senderSystem; System = senderSystem;
Config = config;
MessageContext = messageContext; MessageContext = messageContext;
Cache = provider.Resolve<IDiscordCache>(); Cache = provider.Resolve<IDiscordCache>();
Database = provider.Resolve<IDatabase>(); Database = provider.Resolve<IDatabase>();
@ -64,6 +67,8 @@ public class Context
public readonly PKSystem System; public readonly PKSystem System;
public readonly SystemConfig Config;
public DateTimeZone Zone => Config?.Zone ?? DateTimeZone.Utc;
public readonly Parameters Parameters; public readonly Parameters Parameters;
@ -99,7 +104,7 @@ public class Context
return msg; return msg;
} }
public async Task Execute<T>(Command? commandDef, Func<T, Task> handler) public async Task Execute<T>(Command? commandDef, Func<T, Task> handler, bool deprecated = false)
{ {
_currentCommand = commandDef; _currentCommand = commandDef;
@ -123,6 +128,9 @@ public class Context
// Got a complaint the old error was a bit too patronizing. Hopefully this is better? // Got a complaint the old error was a bit too patronizing. Hopefully this is better?
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?"); await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
} }
if (deprecated && commandDef != null)
await Reply($"{Emojis.Warn} This command is deprecated and will be removed soon. In the future, please use `pk;{commandDef.Key}`.");
} }
public LookupContext LookupContextFor(PKSystem target) => public LookupContext LookupContextFor(PKSystem target) =>

View File

@ -48,6 +48,23 @@ public static class ContextArgumentsExt
return ctx.Match(ref used, potentialMatches); return ctx.Match(ref used, potentialMatches);
} }
/// <summary>
/// Matches the next *n* parameters against each parameter consecutively.
/// <br />
/// Note that this is handled differently than single-parameter Match:
/// each method parameter is an array of potential matches for the *n*th command string parameter.
/// </summary>
public static bool MatchMultiple(this Context ctx, params string[][] potentialParametersMatches)
{
foreach (var param in potentialParametersMatches)
if (!ctx.Match(param)) return false;
for (var i = 0; i < potentialParametersMatches.Length; i++)
ctx.PopArgument();
return true;
}
public static bool MatchFlag(this Context ctx, params string[] potentialMatches) public static bool MatchFlag(this Context ctx, params string[] potentialMatches)
{ {
// Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here. // Flags are *ALWAYS PARSED LOWERCASE*. This means we skip out on a "ToLower" call here.

View File

@ -97,7 +97,9 @@ public class Admin
if (target == null) if (target == null)
throw new PKError("Unknown system."); throw new PKError("Unknown system.");
var currentLimit = target.MemberLimitOverride ?? Limits.MaxMemberCount; var config = await _repo.GetSystemConfig(target.Id);
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (!ctx.HasNext()) if (!ctx.HasNext())
{ {
await ctx.Reply($"Current member limit is **{currentLimit}** members."); await ctx.Reply($"Current member limit is **{currentLimit}** members.");
@ -111,7 +113,7 @@ public class Admin
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update")) if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
throw new PKError("Member limit change cancelled."); throw new PKError("Member limit change cancelled.");
await _repo.UpdateSystem(target.Id, new SystemPatch { MemberLimitOverride = newLimit }); await _repo.UpdateSystemConfig(target.Id, new SystemConfigPatch { MemberLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Member limit updated."); await ctx.Reply($"{Emojis.Success} Member limit updated.");
} }
@ -123,7 +125,9 @@ public class Admin
if (target == null) if (target == null)
throw new PKError("Unknown system."); throw new PKError("Unknown system.");
var currentLimit = target.GroupLimitOverride ?? Limits.MaxGroupCount; var config = await _repo.GetSystemConfig(target.Id);
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (!ctx.HasNext()) if (!ctx.HasNext())
{ {
await ctx.Reply($"Current group limit is **{currentLimit}** groups."); await ctx.Reply($"Current group limit is **{currentLimit}** groups.");
@ -137,7 +141,7 @@ public class Admin
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update")) if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
throw new PKError("Group limit change cancelled."); throw new PKError("Group limit change cancelled.");
await _repo.UpdateSystem(target.Id, new SystemPatch { GroupLimitOverride = newLimit }); await _repo.UpdateSystemConfig(target.Id, new SystemConfigPatch { GroupLimitOverride = newLimit });
await ctx.Reply($"{Emojis.Success} Group limit updated."); await ctx.Reply($"{Emojis.Success} Group limit updated.");
} }
} }

View File

@ -143,102 +143,6 @@ public class Autoproxy
return eb.Build(); return eb.Build();
} }
public async Task AutoproxyTimeout(Context ctx)
{
if (!ctx.HasNext())
{
var timeout = ctx.System.LatchTimeout.HasValue
? Duration.FromSeconds(ctx.System.LatchTimeout.Value)
: (Duration?)null;
if (timeout == null)
await ctx.Reply(
$"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
else if (timeout == Duration.Zero)
await ctx.Reply(
"Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
else
await ctx.Reply(
$"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
return;
}
Duration? newTimeout;
var overflow = Duration.Zero;
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove"))
{
newTimeout = Duration.Zero;
}
else if (ctx.Match("reset", "default"))
{
newTimeout = null;
}
else
{
var timeoutStr = ctx.RemainderOrNull();
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
if (timeoutPeriod == null)
throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
if (timeoutPeriod.Value.TotalHours > 100000)
{
// sanity check to prevent seconds overflow if someone types in 999999999
overflow = timeoutPeriod.Value;
newTimeout = Duration.Zero;
}
else
{
newTimeout = timeoutPeriod;
}
}
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { LatchTimeout = (int?)newTimeout?.TotalSeconds });
if (newTimeout == null)
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
else if (newTimeout == Duration.Zero && overflow != Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)");
else if (newTimeout == Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}.");
}
public async Task AutoproxyAccount(Context ctx)
{
// todo: this might be useful elsewhere, consider moving it to ctx.MatchToggle
if (ctx.Match("enable", "on"))
{
await AutoproxyEnableDisable(ctx, true);
}
else if (ctx.Match("disable", "off"))
{
await AutoproxyEnableDisable(ctx, false);
}
else if (ctx.HasNext())
{
throw new PKSyntaxError("You must pass either \"on\" or \"off\".");
}
else
{
var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled";
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.");
}
}
private async Task AutoproxyEnableDisable(Context ctx, bool allow)
{
var statusString = allow ? "enabled" : "disabled";
if (ctx.MessageContext.AllowAutoproxy == allow)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
return;
}
var patch = new AccountPatch { AllowAutoproxy = allow };
await _repo.UpdateAccount(ctx.Author.Id, patch);
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
}
private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) private async Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember)
{ {
await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id); await _repo.GetSystemGuild(ctx.Guild.Id, ctx.System.Id);

View File

@ -0,0 +1,301 @@
using System.Text;
using Humanizer;
using NodaTime;
using NodaTime.Text;
using NodaTime.TimeZones;
using PluralKit.Core;
namespace PluralKit.Bot;
public class Config
{
private readonly ModelRepository _repo;
public Config(ModelRepository repo)
{
_repo = repo;
}
private record PaginatedConfigItem(string Key, string Description, string? CurrentValue, string DefaultValue);
public async Task ShowConfig(Context ctx)
{
var items = new List<PaginatedConfigItem>();
items.Add(new(
"autoproxy account",
"Whether autoproxy is enabled for the current account",
EnabledDisabled(ctx.MessageContext.AllowAutoproxy),
"enabled"
));
items.Add(new(
"autoproxy timeout",
"If this is set, latch-mode autoproxy will not keep autoproxying after this amount of time has elapsed since the last message sent in the server",
ctx.Config.LatchTimeout.HasValue
? (
ctx.Config.LatchTimeout.Value != 0
? Duration.FromSeconds(ctx.Config.LatchTimeout.Value).ToTimeSpan().Humanize(4)
: "disabled"
)
: null,
ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)
));
items.Add(new(
"timezone",
"The system's time zone - shows timestamps in your local time",
ctx.Config.UiTz,
"UTC"
));
items.Add(new(
"ping",
$"Whether other users are able to mention you via a {Emojis.Bell} reaction",
EnabledDisabled(ctx.Config.PingsEnabled),
"enabled"
));
items.Add(new(
"Member limit",
"The maximum number of registered members for your system",
ctx.Config.MemberLimitOverride?.ToString(),
Limits.MaxMemberCount.ToString()
));
items.Add(new(
"Group limit",
"The maximum number of registered groups for your system",
ctx.Config.GroupLimitOverride?.ToString(),
Limits.MaxGroupCount.ToString()
));
await ctx.Paginate<PaginatedConfigItem>(
items.ToAsyncEnumerable(),
items.Count,
10,
"Current settings for your system",
ctx.System.Color,
(eb, l) =>
{
var description = new StringBuilder();
foreach (var item in l)
{
description.Append(item.Key.AsCode());
description.Append($" **({item.CurrentValue ?? item.DefaultValue})**");
if (item.CurrentValue != null && item.CurrentValue != item.DefaultValue)
description.Append("\ud83d\udd39");
description.AppendLine();
description.Append(item.Description);
description.AppendLine();
description.AppendLine();
}
eb.Description(description.ToString());
return Task.CompletedTask;
}
);
}
public async Task AutoproxyAccount(Context ctx)
{
// todo: this might be useful elsewhere, consider moving it to ctx.MatchToggle
if (ctx.Match("enable", "on"))
await AutoproxyEnableDisable(ctx, true);
else if (ctx.Match("disable", "off"))
await AutoproxyEnableDisable(ctx, false);
else if (ctx.HasNext())
throw new PKSyntaxError("You must pass either \"on\" or \"off\".");
else
{
var statusString = ctx.MessageContext.AllowAutoproxy ? "enabled" : "disabled";
await ctx.Reply($"Autoproxy is currently **{statusString}** for account <@{ctx.Author.Id}>.");
}
}
private string EnabledDisabled(bool value) => value ? "enabled" : "disabled";
private async Task AutoproxyEnableDisable(Context ctx, bool allow)
{
var statusString = EnabledDisabled(allow);
if (ctx.MessageContext.AllowAutoproxy == allow)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
return;
}
var patch = new AccountPatch { AllowAutoproxy = allow };
await _repo.UpdateAccount(ctx.Author.Id, patch);
await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.");
}
public async Task AutoproxyTimeout(Context ctx)
{
if (!ctx.HasNext())
{
var timeout = ctx.Config.LatchTimeout.HasValue
? Duration.FromSeconds(ctx.Config.LatchTimeout.Value)
: (Duration?)null;
if (timeout == null)
await ctx.Reply($"You do not have a custom autoproxy timeout duration set. The default latch timeout duration is {ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}.");
else if (timeout == Duration.Zero)
await ctx.Reply("Latch timeout is currently **disabled** for your system. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"The current latch timeout duration for your system is {timeout.Value.ToTimeSpan().Humanize(4)}.");
return;
}
Duration? newTimeout;
Duration overflow = Duration.Zero;
if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeout = Duration.Zero;
else if (ctx.Match("reset", "default")) newTimeout = null;
else
{
var timeoutStr = ctx.RemainderOrNull();
var timeoutPeriod = DateUtils.ParsePeriod(timeoutStr);
if (timeoutPeriod == null) throw new PKError($"Could not parse '{timeoutStr}' as a valid duration. Try using a syntax such as \"3h5m\" (i.e. 3 hours and 5 minutes).");
if (timeoutPeriod.Value.TotalHours > 100000)
{
// sanity check to prevent seconds overflow if someone types in 999999999
overflow = timeoutPeriod.Value;
newTimeout = Duration.Zero;
}
else newTimeout = timeoutPeriod;
}
await _repo.UpdateSystemConfig(ctx.System.Id, new() { LatchTimeout = (int?)newTimeout?.TotalSeconds });
if (newTimeout == null)
await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize(4)}).");
else if (newTimeout == Duration.Zero && overflow != Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow.ToTimeSpan().Humanize(4)} is too long)");
else if (newTimeout == Duration.Zero)
await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out.");
else
await ctx.Reply($"{Emojis.Success} Latch timeout set to {newTimeout.Value!.ToTimeSpan().Humanize(4)}.");
}
public async Task SystemPing(Context ctx)
{
ctx.CheckSystem();
if (!ctx.HasNext())
{
if (ctx.Config.PingsEnabled) { await ctx.Reply("Reaction pings are currently **enabled** for your system. To disable reaction pings, type `pk;s ping disable`."); }
else { await ctx.Reply("Reaction pings are currently **disabled** for your system. To enable reaction pings, type `pk;s ping enable`."); }
}
else
{
if (ctx.Match("on", "enable"))
{
await _repo.UpdateSystemConfig(ctx.System.Id, new() { PingsEnabled = true });
await ctx.Reply("Reaction pings have now been enabled.");
}
if (ctx.Match("off", "disable"))
{
await _repo.UpdateSystemConfig(ctx.System.Id, new() { PingsEnabled = false });
await ctx.Reply("Reaction pings have now been disabled.");
}
}
}
public async Task SystemTimezone(Context ctx)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (await ctx.MatchClear())
{
await _repo.UpdateSystemConfig(ctx.System.Id, new() { UiTz = "UTC" });
await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC).");
return;
}
var zoneStr = ctx.RemainderOrNull();
if (zoneStr == null)
{
await ctx.Reply(
$"Your current system time zone is set to **{ctx.Config.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.Config.Zone)}** in that time zone. To change your system time zone, type `pk;s tz <zone>`.");
return;
}
var zone = await FindTimeZone(ctx, zoneStr);
if (zone == null) throw Errors.InvalidTimeZone(zoneStr);
var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone);
var msg = $"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?";
if (!await ctx.PromptYesNo(msg, "Change Timezone")) throw Errors.TimezoneChangeCancelled;
await _repo.UpdateSystemConfig(ctx.System.Id, new() { UiTz = zone.Id });
await ctx.Reply($"System time zone changed to **{zone.Id}**.");
}
private async Task<DateTimeZone> FindTimeZone(Context ctx, string zoneStr)
{
// First, if we're given a flag emoji, we extract the flag emoji code from it.
zoneStr = Core.StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr;
// Then, we find all *locations* matching either the given country code or the country name.
var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations;
var matchingLocations = locations.Where(l => l.Countries.Any(c =>
string.Equals(c.Code, zoneStr, StringComparison.InvariantCultureIgnoreCase) ||
string.Equals(c.Name, zoneStr, StringComparison.InvariantCultureIgnoreCase)));
// Then, we find all (unique) time zone IDs that match.
var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId))
.Distinct().ToList();
// If the set of matching zones is empty (ie. we didn't find anything), we try a few other things.
if (matchingZones.Count == 0)
{
// First, we try to just find the time zone given directly and return that.
var givenZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(zoneStr);
if (givenZone != null) return givenZone;
// If we didn't find anything there either, we try parsing the string as an offset, then
// find all possible zones that match that offset. For an offset like UTC+2, this doesn't *quite*
// work, since there are 57(!) matching zones (as of 2019-06-13) - but for less populated time zones
// this could work nicely.
var inputWithoutUtc = zoneStr.Replace("UTC", "").Replace("GMT", "");
var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc);
if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc);
// If *this* didn't parse correctly, fuck it, bail.
if (!res.Success) return null;
var offset = res.Value;
// To try to reduce the count, we go by locations from the 1970+ database instead of just the full database
// This elides regions that have been identical since 1970, omitting small distinctions due to Ancient History(tm).
var allZones = TzdbDateTimeZoneSource.Default.Zone1970Locations.Select(l => l.ZoneId).Distinct();
matchingZones = allZones.Select(z => DateTimeZoneProviders.Tzdb.GetZoneOrNull(z))
.Where(z => z.GetUtcOffset(SystemClock.Instance.GetCurrentInstant()) == offset).ToList();
}
// If we have a list of viable time zones, we ask the user which is correct.
// If we only have one, return that one.
if (matchingZones.Count == 1)
return matchingZones.First();
// Otherwise, prompt and return!
return await ctx.Choose("There were multiple matches for your time zone query. Please select the region that matches you the closest:", matchingZones,
z =>
{
if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id))
return $"**{z.Id}**, {string.Join(", ", TzdbDateTimeZoneSource.Default.Aliases[z.Id])}";
return $"**{z.Id}**";
});
}
}

View File

@ -49,7 +49,7 @@ public class Groups
// Check group cap // Check group cap
var existingGroupCount = await _repo.GetSystemGroupCount(ctx.System.Id); var existingGroupCount = await _repo.GetSystemGroupCount(ctx.System.Id);
var groupLimit = ctx.System.GroupLimitOverride ?? Limits.MaxGroupCount; var groupLimit = ctx.Config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (existingGroupCount >= groupLimit) if (existingGroupCount >= groupLimit)
throw new PKError( throw new PKError(
$"System has reached the maximum number of groups ({groupLimit}). Please delete unused groups first in order to create new ones."); $"System has reached the maximum number of groups ({groupLimit}). Please delete unused groups first in order to create new ones.");
@ -574,7 +574,7 @@ public class Groups
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var rangeStart = DateUtils.ParseDateTime(durationStr, true, targetSystem.Zone); var rangeStart = DateUtils.ParseDateTime(durationStr, true, ctx.Zone);
if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); if (rangeStart == null) throw Errors.InvalidDateTime(durationStr);
if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
@ -589,7 +589,7 @@ public class Groups
var frontpercent = await _db.Execute(c => var frontpercent = await _db.Execute(c =>
_repo.GetFrontBreakdown(c, targetSystem.Id, target.Id, rangeStart.Value.ToInstant(), now)); _repo.GetFrontBreakdown(c, targetSystem.Id, target.Id, rangeStart.Value.ToInstant(), now));
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, targetSystem, target, await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, targetSystem, target,
targetSystem.Zone, ctx.LookupContextFor(targetSystem), title.ToString(), ignoreNoFronters, showFlat)); ctx.Zone, ctx.LookupContextFor(targetSystem), title.ToString(), ignoreNoFronters, showFlat));
} }
private async Task<PKSystem> GetGroupSystem(Context ctx, PKGroup target) private async Task<PKSystem> GetGroupSystem(Context ctx, PKGroup target)

View File

@ -100,7 +100,7 @@ public class ImportExport
var json = await ctx.BusyIndicator(async () => var json = await ctx.BusyIndicator(async () =>
{ {
// Make the actual data file // Make the actual data file
var data = await _dataFiles.ExportSystem(ctx.System); var data = await _dataFiles.ExportSystem(ctx.System, ctx.Config.UiTz);
return JsonConvert.SerializeObject(data, Formatting.None); return JsonConvert.SerializeObject(data, Formatting.None);
}); });

View File

@ -205,7 +205,6 @@ public static class ContextListExt
void LongRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page) void LongRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
{ {
var zone = ctx.System?.Zone ?? DateTimeZone.Utc;
foreach (var m in page) foreach (var m in page)
{ {
var profile = new StringBuilder($"**ID**: {m.Hid}"); var profile = new StringBuilder($"**ID**: {m.Hid}");
@ -231,11 +230,11 @@ public static class ContextListExt
if ((opts.IncludeLastSwitch || opts.SortProperty == SortProperty.LastSwitch) && if ((opts.IncludeLastSwitch || opts.SortProperty == SortProperty.LastSwitch) &&
m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw)) m.MetadataPrivacy.TryGet(lookupCtx, m.LastSwitchTime, out var lastSw))
profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(zone)}"); profile.Append($"\n**Last switched in:** {lastSw.Value.FormatZoned(ctx.Zone)}");
if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) && if ((opts.IncludeCreated || opts.SortProperty == SortProperty.CreationDate) &&
m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created)) m.MetadataPrivacy.TryGet(lookupCtx, m.Created, out var created))
profile.Append($"\n**Created on:** {created.FormatZoned(zone)}"); profile.Append($"\n**Created on:** {created.FormatZoned(ctx.Zone)}");
if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatar) if (opts.IncludeAvatar && m.AvatarFor(lookupCtx) is { } avatar)
profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}"); profile.Append($"\n**Avatar URL:** {avatar.TryGetCleanCdnUrl()}");

View File

@ -50,7 +50,7 @@ public class Member
// Enforce per-system member limit // Enforce per-system member limit
var memberCount = await _repo.GetSystemMemberCount(ctx.System.Id); var memberCount = await _repo.GetSystemMemberCount(ctx.System.Id);
var memberLimit = ctx.System.MemberLimitOverride ?? Limits.MaxMemberCount; var memberLimit = ctx.Config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (memberCount >= memberLimit) if (memberCount >= memberLimit)
throw Errors.MemberLimitReachedError(memberLimit); throw Errors.MemberLimitReachedError(memberLimit);
@ -117,7 +117,7 @@ public class Member
{ {
var system = await _repo.GetSystem(target.System); var system = await _repo.GetSystem(target.System);
await ctx.Reply( await ctx.Reply(
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system), ctx.Zone));
} }
public async Task Soulscream(Context ctx, PKMember target) public async Task Soulscream(Context ctx, PKMember target)

View File

@ -314,7 +314,7 @@ public class MemberEdit
LocalDate? birthday; LocalDate? birthday;
if (birthdayStr == "today" || birthdayStr == "now") if (birthdayStr == "today" || birthdayStr == "now")
birthday = SystemClock.Instance.InZone(ctx.System.Zone).GetCurrentDate(); birthday = SystemClock.Instance.InZone(ctx.Zone).GetCurrentDate();
else else
birthday = DateUtils.ParseDate(birthdayStr, true); birthday = DateUtils.ParseDate(birthdayStr, true);

View File

@ -34,7 +34,7 @@ public class Random
var randInt = randGen.Next(members.Count); var randInt = randGen.Next(members.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild,
ctx.LookupContextFor(ctx.System))); ctx.LookupContextFor(ctx.System), ctx.Zone));
} }
public async Task Group(Context ctx) public async Task Group(Context ctx)
@ -72,6 +72,6 @@ public class Random
var randInt = randGen.Next(ms.Count); var randInt = randGen.Next(ms.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild,
ctx.LookupContextFor(ctx.System))); ctx.LookupContextFor(ctx.System), ctx.Zone));
} }
} }

View File

@ -68,7 +68,7 @@ public class Switch
var timeToMove = ctx.RemainderOrNull() ?? var timeToMove = ctx.RemainderOrNull() ??
throw new PKSyntaxError("Must pass a date or time to move the switch to."); throw new PKSyntaxError("Must pass a date or time to move the switch to.");
var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.System.UiTz ?? "UTC"); var tz = TzdbDateTimeZoneSource.Default.ForId(ctx.Config?.UiTz ?? "UTC");
var result = DateUtils.ParseDateTime(timeToMove, true, tz); var result = DateUtils.ParseDateTime(timeToMove, true, tz);
if (result == null) throw Errors.InvalidDateTime(timeToMove); if (result == null) throw Errors.InvalidDateTime(timeToMove);

View File

@ -509,39 +509,6 @@ public class SystemEdit
await ctx.Reply($"Message proxying in {serverText} is now **disabled** for your system."); await ctx.Reply($"Message proxying in {serverText} is now **disabled** for your system.");
} }
public async Task SystemTimezone(Context ctx)
{
if (ctx.System == null) throw Errors.NoSystemError;
if (await ctx.MatchClear())
{
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { UiTz = "UTC" });
await ctx.Reply($"{Emojis.Success} System time zone cleared (set to UTC).");
return;
}
var zoneStr = ctx.RemainderOrNull();
if (zoneStr == null)
{
await ctx.Reply(
$"Your current system time zone is set to **{ctx.System.UiTz}**. It is currently **{SystemClock.Instance.GetCurrentInstant().FormatZoned(ctx.System)}** in that time zone. To change your system time zone, type `pk;s tz <zone>`.");
return;
}
var zone = await FindTimeZone(ctx, zoneStr);
if (zone == null) throw Errors.InvalidTimeZone(zoneStr);
var currentTime = SystemClock.Instance.GetCurrentInstant().InZone(zone);
var msg =
$"This will change the system time zone to **{zone.Id}**. The current time is **{currentTime.FormatZoned()}**. Is this correct?";
if (!await ctx.PromptYesNo(msg, "Change Timezone")) throw Errors.TimezoneChangeCancelled;
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { UiTz = zone.Id });
await ctx.Reply($"System time zone changed to **{zone.Id}**.");
}
public async Task SystemPrivacy(Context ctx) public async Task SystemPrivacy(Context ctx)
{ {
ctx.CheckSystem(); ctx.CheckSystem();
@ -609,96 +576,4 @@ public class SystemEdit
else else
await SetLevel(ctx.PopSystemPrivacySubject(), ctx.PopPrivacyLevel()); await SetLevel(ctx.PopSystemPrivacySubject(), ctx.PopPrivacyLevel());
} }
public async Task SystemPing(Context ctx)
{
ctx.CheckSystem();
if (!ctx.HasNext())
{
if (ctx.System.PingsEnabled)
await ctx.Reply(
"Reaction pings are currently **enabled** for your system. To disable reaction pings, type `pk;s ping disable`.");
else
await ctx.Reply(
"Reaction pings are currently **disabled** for your system. To enable reaction pings, type `pk;s ping enable`.");
}
else
{
if (ctx.Match("on", "enable"))
{
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { PingsEnabled = true });
await ctx.Reply("Reaction pings have now been enabled.");
}
if (ctx.Match("off", "disable"))
{
await _repo.UpdateSystem(ctx.System.Id, new SystemPatch { PingsEnabled = false });
await ctx.Reply("Reaction pings have now been disabled.");
}
}
}
public async Task<DateTimeZone> FindTimeZone(Context ctx, string zoneStr)
{
// First, if we're given a flag emoji, we extract the flag emoji code from it.
zoneStr = StringUtils.ExtractCountryFlag(zoneStr) ?? zoneStr;
// Then, we find all *locations* matching either the given country code or the country name.
var locations = TzdbDateTimeZoneSource.Default.Zone1970Locations;
var matchingLocations = locations.Where(l => l.Countries.Any(c =>
string.Equals(c.Code, zoneStr, StringComparison.InvariantCultureIgnoreCase) ||
string.Equals(c.Name, zoneStr, StringComparison.InvariantCultureIgnoreCase)));
// Then, we find all (unique) time zone IDs that match.
var matchingZones = matchingLocations.Select(l => DateTimeZoneProviders.Tzdb.GetZoneOrNull(l.ZoneId))
.Distinct().ToList();
// If the set of matching zones is empty (ie. we didn't find anything), we try a few other things.
if (matchingZones.Count == 0)
{
// First, we try to just find the time zone given directly and return that.
var givenZone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(zoneStr);
if (givenZone != null) return givenZone;
// If we didn't find anything there either, we try parsing the string as an offset, then
// find all possible zones that match that offset. For an offset like UTC+2, this doesn't *quite*
// work, since there are 57(!) matching zones (as of 2019-06-13) - but for less populated time zones
// this could work nicely.
var inputWithoutUtc = zoneStr.Replace("UTC", "").Replace("GMT", "");
var res = OffsetPattern.CreateWithInvariantCulture("+H").Parse(inputWithoutUtc);
if (!res.Success) res = OffsetPattern.CreateWithInvariantCulture("+H:mm").Parse(inputWithoutUtc);
// If *this* didn't parse correctly, fuck it, bail.
if (!res.Success) return null;
var offset = res.Value;
// To try to reduce the count, we go by locations from the 1970+ database instead of just the full database
// This elides regions that have been identical since 1970, omitting small distinctions due to Ancient History(tm).
var allZones = TzdbDateTimeZoneSource.Default.Zone1970Locations.Select(l => l.ZoneId).Distinct();
matchingZones = allZones.Select(z => DateTimeZoneProviders.Tzdb.GetZoneOrNull(z))
.Where(z => z.GetUtcOffset(SystemClock.Instance.GetCurrentInstant()) == offset).ToList();
}
// If we have a list of viable time zones, we ask the user which is correct.
// If we only have one, return that one.
if (matchingZones.Count == 1)
return matchingZones.First();
// Otherwise, prompt and return!
return await ctx.Choose(
"There were multiple matches for your time zone query. Please select the region that matches you the closest:",
matchingZones,
z =>
{
if (TzdbDateTimeZoneSource.Default.Aliases.Contains(z.Id))
return $"**{z.Id}**, {string.Join(", ", TzdbDateTimeZoneSource.Default.Aliases[z.Id])}";
return $"**{z.Id}**";
});
}
} }

View File

@ -27,7 +27,7 @@ public class SystemFront
var sw = await _repo.GetLatestSwitch(system.Id); var sw = await _repo.GetLatestSwitch(system.Id);
if (sw == null) throw Errors.NoRegisteredSwitches; if (sw == null) throw Errors.NoRegisteredSwitches;
await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, system.Zone, ctx.LookupContextFor(system))); await ctx.Reply(embed: await _embeds.CreateFronterEmbed(sw, ctx.Zone, ctx.LookupContextFor(system)));
} }
public async Task SystemFrontHistory(Context ctx, PKSystem system) public async Task SystemFrontHistory(Context ctx, PKSystem system)
@ -77,12 +77,12 @@ public class SystemFront
// Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one // Calculate the time between the last switch (that we iterated - ie. the next one on the timeline) and the current one
var switchDuration = lastSw.Value - sw.Timestamp; var switchDuration = lastSw.Value - sw.Timestamp;
stringToAdd = stringToAdd =
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n"; $"**{membersStr}** ({sw.Timestamp.FormatZoned(ctx.Zone)}, {switchSince.FormatDuration()} ago, for {switchDuration.FormatDuration()})\n";
} }
else else
{ {
stringToAdd = stringToAdd =
$"**{membersStr}** ({sw.Timestamp.FormatZoned(system.Zone)}, {switchSince.FormatDuration()} ago)\n"; $"**{membersStr}** ({sw.Timestamp.FormatZoned(ctx.Zone)}, {switchSince.FormatDuration()} ago)\n";
} }
if (sb.Length + stringToAdd.Length >= 4096) if (sb.Length + stringToAdd.Length >= 4096)
@ -113,7 +113,7 @@ public class SystemFront
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var rangeStart = DateUtils.ParseDateTime(durationStr, true, system.Zone); var rangeStart = DateUtils.ParseDateTime(durationStr, true, ctx.Zone);
if (rangeStart == null) throw Errors.InvalidDateTime(durationStr); if (rangeStart == null) throw Errors.InvalidDateTime(durationStr);
if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture; if (rangeStart.Value.ToInstant() > now) throw Errors.FrontPercentTimeInFuture;
@ -127,7 +127,7 @@ public class SystemFront
var showFlat = ctx.MatchFlag("flat"); var showFlat = ctx.MatchFlag("flat");
var frontpercent = await _db.Execute(c => var frontpercent = await _db.Execute(c =>
_repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now)); _repo.GetFrontBreakdown(c, system.Id, null, rangeStart.Value.ToInstant(), now));
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, system.Zone, await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, null, ctx.Zone,
ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat)); ctx.LookupContextFor(system), title.ToString(), ignoreNoFronters, showFlat));
} }

View File

@ -126,7 +126,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
try try
{ {
var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null; var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null;
await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, ctx)); var config = ctx.SystemId != null ? await _repo.GetSystemConfig(ctx.SystemId.Value) : null;
await _tree.ExecuteCommand(new Context(_services, shard, guild, channel, evt, cmdStart, system, config, ctx));
} }
catch (PKError) catch (PKError)
{ {

View File

@ -9,6 +9,8 @@ using Myriad.Types;
using PluralKit.Core; using PluralKit.Core;
using NodaTime;
using Serilog; using Serilog;
namespace PluralKit.Bot; namespace PluralKit.Bot;
@ -174,7 +176,8 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
msg.System, msg.System,
msg.Member, msg.Member,
guild, guild,
LookupContext.ByNonOwner LookupContext.ByNonOwner,
DateTimeZone.Utc
) )
}); });
@ -199,7 +202,9 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages; var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
if (member == null || !(await _cache.PermissionsFor(evt.ChannelId, member)).HasFlag(requiredPerms)) return; if (member == null || !(await _cache.PermissionsFor(evt.ChannelId, member)).HasFlag(requiredPerms)) return;
if (msg.System.PingsEnabled) var config = await _repo.GetSystemConfig(msg.System.Id);
if (config.PingsEnabled)
// If the system has pings enabled, go ahead // If the system has pings enabled, go ahead
await _rest.CreateMessage(evt.ChannelId, new MessageRequest await _rest.CreateMessage(evt.ChannelId, new MessageRequest
{ {

View File

@ -72,6 +72,7 @@ public class BotModule: Module
builder.RegisterType<Api>().AsSelf(); builder.RegisterType<Api>().AsSelf();
builder.RegisterType<Autoproxy>().AsSelf(); builder.RegisterType<Autoproxy>().AsSelf();
builder.RegisterType<Checks>().AsSelf(); builder.RegisterType<Checks>().AsSelf();
builder.RegisterType<Config>().AsSelf();
builder.RegisterType<Fun>().AsSelf(); builder.RegisterType<Fun>().AsSelf();
builder.RegisterType<Groups>().AsSelf(); builder.RegisterType<Groups>().AsSelf();
builder.RegisterType<GroupMember>().AsSelf(); builder.RegisterType<GroupMember>().AsSelf();

View File

@ -64,7 +64,7 @@ public class EmbedService
.Title(system.Name) .Title(system.Name)
.Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl())) .Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl()))
.Footer(new Embed.EmbedFooter( .Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(system)}")) $"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(color); .Color(color);
if (system.DescriptionPrivacy.CanAccess(ctx)) if (system.DescriptionPrivacy.CanAccess(ctx))
@ -139,7 +139,7 @@ public class EmbedService
return embed.Build(); return embed.Build();
} }
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, LookupContext ctx) public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, LookupContext ctx, DateTimeZone zone)
{ {
// string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone)); // string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
@ -174,7 +174,7 @@ public class EmbedService
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray) // .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
.Color(color) .Color(color)
.Footer(new Embed.EmbedFooter( .Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(system)}" : "")}")); $"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(zone)}" : "")}"));
if (member.DescriptionPrivacy.CanAccess(ctx)) if (member.DescriptionPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(member.BannerImage)); eb.Image(new Embed.EmbedImage(member.BannerImage));
@ -249,7 +249,7 @@ public class EmbedService
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx))) .Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx)))
.Color(color) .Color(color)
.Footer(new Embed.EmbedFooter( .Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}")); $"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(ctx.Zone)}"));
if (target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System))) if (target.DescriptionPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
eb.Image(new Embed.EmbedImage(target.BannerImage)); eb.Image(new Embed.EmbedImage(target.BannerImage));

View File

@ -23,8 +23,9 @@
as $$ as $$
-- CTEs to query "static" (accessible only through args) data -- CTEs to query "static" (accessible only through args) data
with with
system as (select systems.*, system_guild.tag as guild_tag, system_guild.tag_enabled as tag_enabled, allow_autoproxy as account_autoproxy from accounts system as (select systems.*, config.latch_timeout, system_guild.tag as guild_tag, system_guild.tag_enabled as tag_enabled, allow_autoproxy as account_autoproxy from accounts
left join systems on systems.id = accounts.system left join systems on systems.id = accounts.system
left join config on config.system = accounts.system
left join system_guild on system_guild.system = accounts.system and system_guild.guild = guild_id left join system_guild on system_guild.system = accounts.system and system_guild.guild = guild_id
where accounts.uid = account_id), where accounts.uid = account_id),
guild as (select * from servers where id = guild_id), guild as (select * from servers where id = guild_id),
@ -48,11 +49,12 @@ as $$
coalesce(system.tag_enabled, true) as tag_enabled, coalesce(system.tag_enabled, true) as tag_enabled,
system.avatar_url as system_avatar, system.avatar_url as system_avatar,
system.account_autoproxy as allow_autoproxy, system.account_autoproxy as allow_autoproxy,
system.latch_timeout as latch_timeout config.latch_timeout as latch_timeout
-- We need a "from" clause, so we just use some bogus data that's always present -- We need a "from" clause, so we just use some bogus data that's always present
-- This ensure we always have exactly one row going forward, so we can left join afterwards and still get data -- This ensure we always have exactly one row going forward, so we can left join afterwards and still get data
from (select 1) as _placeholder from (select 1) as _placeholder
left join system on true left join system on true
left join config on true
left join guild on true left join guild on true
left join last_message on true left join last_message on true
left join system_last_switch on system_last_switch.system = system.id left join system_last_switch on system_last_switch.system = system.id

View File

@ -0,0 +1,29 @@
-- schema version 21
-- create `config` table
create table config (
system int primary key references systems(id) on delete cascade,
ui_tz text not null default 'UTC',
pings_enabled bool not null default true,
latch_timeout int,
member_limit_override int,
group_limit_override int
);
insert into config select
id as system,
ui_tz,
pings_enabled,
latch_timeout,
member_limit_override,
group_limit_override
from systems;
alter table systems
drop column ui_tz,
drop column pings_enabled,
drop column latch_timeout,
drop column member_limit_override,
drop column group_limit_override;
update info set schema_version = 21;

View File

@ -0,0 +1,23 @@
using SqlKata;
namespace PluralKit.Core;
public partial class ModelRepository
{
public Task<SystemConfig> GetSystemConfig(SystemId system)
=> _db.QueryFirst<SystemConfig>(new Query("config").Where("system", system));
public async Task<SystemConfig> UpdateSystemConfig(SystemId system, SystemConfigPatch patch)
{
var query = patch.Apply(new Query("config").Where("system", system));
var config = await _db.QueryFirst<SystemConfig>(query, "returning *");
_ = _dispatch.Dispatch(system, new UpdateDispatchData
{
Event = DispatchEvent.UPDATE_SETTINGS,
EventData = patch.ToJson()
});
return config;
}
}

View File

@ -1,4 +1,6 @@
#nullable enable #nullable enable
using Dapper;
using SqlKata; using SqlKata;
namespace PluralKit.Core; namespace PluralKit.Core;
@ -78,6 +80,8 @@ public partial class ModelRepository
var system = await _db.QueryFirst<PKSystem>(conn, query, "returning *"); var system = await _db.QueryFirst<PKSystem>(conn, query, "returning *");
_logger.Information("Created {SystemId}", system.Id); _logger.Information("Created {SystemId}", system.Id);
await _db.Execute(conn => conn.QueryAsync("insert into config (system) value (@system)", new { system = system.Id }));
// no dispatch call here - system was just created, we don't have a webhook URL // no dispatch call here - system was just created, we don't have a webhook URL
return system; return system;
} }

View File

@ -9,7 +9,7 @@ namespace PluralKit.Core;
internal class DatabaseMigrator internal class DatabaseMigrator
{ {
private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files
private const int TargetSchemaVersion = 20; private const int TargetSchemaVersion = 21;
private readonly ILogger _logger; private readonly ILogger _logger;
public DatabaseMigrator(ILogger logger) public DatabaseMigrator(ILogger logger)

View File

@ -11,6 +11,7 @@ public enum DispatchEvent
{ {
PING, PING,
UPDATE_SYSTEM, UPDATE_SYSTEM,
UPDATE_SETTINGS,
CREATE_MEMBER, CREATE_MEMBER,
UPDATE_MEMBER, UPDATE_MEMBER,
DELETE_MEMBER, DELETE_MEMBER,

View File

@ -46,18 +46,11 @@ public class PKSystem
public string WebhookUrl { get; } public string WebhookUrl { get; }
public string WebhookToken { get; } public string WebhookToken { get; }
public Instant Created { get; } public Instant Created { get; }
public string UiTz { get; set; }
public bool PingsEnabled { get; }
public int? LatchTimeout { get; }
public PrivacyLevel DescriptionPrivacy { get; } public PrivacyLevel DescriptionPrivacy { get; }
public PrivacyLevel MemberListPrivacy { get; } public PrivacyLevel MemberListPrivacy { get; }
public PrivacyLevel FrontPrivacy { get; } public PrivacyLevel FrontPrivacy { get; }
public PrivacyLevel FrontHistoryPrivacy { get; } public PrivacyLevel FrontHistoryPrivacy { get; }
public PrivacyLevel GroupListPrivacy { get; } public PrivacyLevel GroupListPrivacy { get; }
public int? MemberLimitOverride { get; }
public int? GroupLimitOverride { get; }
[JsonIgnore] public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz);
} }
public static class PKSystemExt public static class PKSystemExt
@ -84,7 +77,7 @@ public static class PKSystemExt
{ {
case APIVersion.V1: case APIVersion.V1:
{ {
o.Add("tz", system.UiTz); o.Add("tz", null);
o.Add("description_privacy", o.Add("description_privacy",
ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null); ctx == LookupContext.ByOwner ? system.DescriptionPrivacy.ToJsonString() : null);
@ -98,7 +91,8 @@ public static class PKSystemExt
} }
case APIVersion.V2: case APIVersion.V2:
{ {
o.Add("timezone", system.UiTz); // todo: remove this
o.Add("timezone", null);
if (ctx == LookupContext.ByOwner) if (ctx == LookupContext.ByOwner)
{ {

View File

@ -0,0 +1,68 @@
using Newtonsoft.Json.Linq;
using NodaTime;
using SqlKata;
namespace PluralKit.Core;
public class SystemConfigPatch: PatchObject
{
public Partial<string> UiTz { get; set; }
public Partial<bool> PingsEnabled { get; set; }
public Partial<int?> LatchTimeout { get; set; }
public Partial<int?> MemberLimitOverride { get; set; }
public Partial<int?> GroupLimitOverride { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("ui_tz", UiTz)
.With("pings_enabled", PingsEnabled)
.With("latch_timeout", LatchTimeout)
.With("member_limit_override", MemberLimitOverride)
.With("group_limit_override", GroupLimitOverride)
);
public new void AssertIsValid()
{
if (UiTz.IsPresent && DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz.Value) == null)
Errors.Add(new ValidationError("timezone"));
}
public JObject ToJson()
{
var o = new JObject();
if (UiTz.IsPresent)
o.Add("timezone", UiTz.Value);
if (PingsEnabled.IsPresent)
o.Add("pings_enabled", PingsEnabled.Value);
if (LatchTimeout.IsPresent)
o.Add("latch_timeout", LatchTimeout.Value);
if (MemberLimitOverride.IsPresent)
o.Add("member_limit", MemberLimitOverride.Value);
if (GroupLimitOverride.IsPresent)
o.Add("group_limit", GroupLimitOverride.Value);
return o;
}
public static SystemConfigPatch FromJson(JObject o)
{
var patch = new SystemConfigPatch();
if (o.ContainsKey("timezone"))
patch.UiTz = o.Value<string>("timezone");
if (o.ContainsKey("pings_enabled"))
patch.PingsEnabled = o.Value<bool>("pings_enabled");
if (o.ContainsKey("latch_timeout"))
patch.LatchTimeout = o.Value<int>("latch_timeout");
return patch;
}
}

View File

@ -19,16 +19,11 @@ public class SystemPatch: PatchObject
public Partial<string?> Token { get; set; } public Partial<string?> Token { get; set; }
public Partial<string?> WebhookUrl { get; set; } public Partial<string?> WebhookUrl { get; set; }
public Partial<string?> WebhookToken { get; set; } public Partial<string?> WebhookToken { get; set; }
public Partial<string> UiTz { get; set; }
public Partial<PrivacyLevel> DescriptionPrivacy { get; set; } public Partial<PrivacyLevel> DescriptionPrivacy { get; set; }
public Partial<PrivacyLevel> MemberListPrivacy { get; set; } public Partial<PrivacyLevel> MemberListPrivacy { get; set; }
public Partial<PrivacyLevel> GroupListPrivacy { get; set; } public Partial<PrivacyLevel> GroupListPrivacy { get; set; }
public Partial<PrivacyLevel> FrontPrivacy { get; set; } public Partial<PrivacyLevel> FrontPrivacy { get; set; }
public Partial<PrivacyLevel> FrontHistoryPrivacy { get; set; } public Partial<PrivacyLevel> FrontHistoryPrivacy { get; set; }
public Partial<bool> PingsEnabled { get; set; }
public Partial<int?> LatchTimeout { get; set; }
public Partial<int?> MemberLimitOverride { get; set; }
public Partial<int?> GroupLimitOverride { get; set; }
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("name", Name) .With("name", Name)
@ -41,16 +36,11 @@ public class SystemPatch: PatchObject
.With("token", Token) .With("token", Token)
.With("webhook_url", WebhookUrl) .With("webhook_url", WebhookUrl)
.With("webhook_token", WebhookToken) .With("webhook_token", WebhookToken)
.With("ui_tz", UiTz)
.With("description_privacy", DescriptionPrivacy) .With("description_privacy", DescriptionPrivacy)
.With("member_list_privacy", MemberListPrivacy) .With("member_list_privacy", MemberListPrivacy)
.With("group_list_privacy", GroupListPrivacy) .With("group_list_privacy", GroupListPrivacy)
.With("front_privacy", FrontPrivacy) .With("front_privacy", FrontPrivacy)
.With("front_history_privacy", FrontHistoryPrivacy) .With("front_history_privacy", FrontHistoryPrivacy)
.With("pings_enabled", PingsEnabled)
.With("latch_timeout", LatchTimeout)
.With("member_limit_override", MemberLimitOverride)
.With("group_limit_override", GroupLimitOverride)
); );
public new void AssertIsValid() public new void AssertIsValid()
@ -69,8 +59,6 @@ public class SystemPatch: PatchObject
s => MiscUtils.TryMatchUri(s, out var bannerUri)); s => MiscUtils.TryMatchUri(s, out var bannerUri));
if (Color.Value != null) if (Color.Value != null)
AssertValid(Color.Value, "color", "^[0-9a-fA-F]{6}$"); AssertValid(Color.Value, "color", "^[0-9a-fA-F]{6}$");
if (UiTz.IsPresent && DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz.Value) == null)
Errors.Add(new ValidationError("timezone"));
} }
#nullable disable #nullable disable
@ -84,14 +72,11 @@ public class SystemPatch: PatchObject
if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty(); if (o.ContainsKey("avatar_url")) patch.AvatarUrl = o.Value<string>("avatar_url").NullIfEmpty();
if (o.ContainsKey("banner")) patch.BannerImage = o.Value<string>("banner").NullIfEmpty(); if (o.ContainsKey("banner")) patch.BannerImage = o.Value<string>("banner").NullIfEmpty();
if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty(); if (o.ContainsKey("color")) patch.Color = o.Value<string>("color").NullIfEmpty();
if (o.ContainsKey("timezone")) patch.UiTz = o.Value<string>("timezone") ?? "UTC";
switch (v) switch (v)
{ {
case APIVersion.V1: case APIVersion.V1:
{ {
if (o.ContainsKey("tz")) patch.UiTz = o.Value<string>("tz") ?? "UTC";
if (o.ContainsKey("description_privacy")) if (o.ContainsKey("description_privacy"))
patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy"); patch.DescriptionPrivacy = patch.ParsePrivacy(o, "description_privacy");
if (o.ContainsKey("member_list_privacy")) if (o.ContainsKey("member_list_privacy"))
@ -149,8 +134,6 @@ public class SystemPatch: PatchObject
o.Add("banner", BannerImage.Value); o.Add("banner", BannerImage.Value);
if (Color.IsPresent) if (Color.IsPresent)
o.Add("color", Color.Value); o.Add("color", Color.Value);
if (UiTz.IsPresent)
o.Add("timezone", UiTz.Value);
if ( if (
DescriptionPrivacy.IsPresent DescriptionPrivacy.IsPresent

View File

@ -0,0 +1,33 @@
using Newtonsoft.Json.Linq;
using NodaTime;
namespace PluralKit.Core;
public class SystemConfig
{
public SystemId Id { get; }
public string UiTz { get; set; }
public bool PingsEnabled { get; }
public int? LatchTimeout { get; }
public int? MemberLimitOverride { get; }
public int? GroupLimitOverride { get; }
public DateTimeZone Zone => DateTimeZoneProviders.Tzdb.GetZoneOrNull(UiTz);
}
public static class SystemConfigExt
{
public static JObject ToJson(this SystemConfig cfg)
{
var o = new JObject();
o.Add("timezone", cfg.UiTz);
o.Add("pings_enabled", cfg.PingsEnabled);
o.Add("latch_timeout", cfg.LatchTimeout);
o.Add("member_limit", cfg.MemberLimitOverride ?? Limits.MaxMemberCount);
o.Add("group_limit", cfg.GroupLimitOverride ?? Limits.MaxGroupCount);
return o;
}
}

View File

@ -21,7 +21,7 @@ public class DataFileService
_dispatch = dispatch; _dispatch = dispatch;
} }
public async Task<JObject> ExportSystem(PKSystem system) public async Task<JObject> ExportSystem(PKSystem system, string timezone)
{ {
await using var conn = await _db.Obtain(); await using var conn = await _db.Obtain();
@ -30,7 +30,7 @@ public class DataFileService
o.Merge(system.ToJson(LookupContext.ByOwner)); o.Merge(system.ToJson(LookupContext.ByOwner));
o.Add("timezone", system.UiTz); o.Add("timezone", timezone);
o.Add("accounts", new JArray((await _repo.GetSystemAccounts(system.Id)).ToList())); o.Add("accounts", new JArray((await _repo.GetSystemAccounts(system.Id)).ToList()));
o.Add("members", o.Add("members",
new JArray((await _repo.GetSystemMembers(system.Id).ToListAsync()).Select(m => new JArray((await _repo.GetSystemMembers(system.Id).ToListAsync()).Select(m =>

View File

@ -21,6 +21,7 @@ public partial class BulkImporter: IAsyncDisposable
private ModelRepository _repo { get; init; } private ModelRepository _repo { get; init; }
private PKSystem _system { get; set; } private PKSystem _system { get; set; }
private SystemConfig _cfg { get; set; }
private IPKConnection _conn { get; init; } private IPKConnection _conn { get; init; }
private IPKTransaction _tx { get; init; } private IPKTransaction _tx { get; init; }
@ -60,6 +61,8 @@ public partial class BulkImporter: IAsyncDisposable
importer._system = system; importer._system = system;
} }
importer._cfg = await repo.GetSystemConfig(system.Id);
// Fetch all members in the system and log their names and hids // Fetch all members in the system and log their names and hids
var members = await conn.QueryAsync<PKMember>("select id, hid, name from members where system = @System", var members = await conn.QueryAsync<PKMember>("select id, hid, name from members where system = @System",
new { System = system.Id }); new { System = system.Id });
@ -120,7 +123,7 @@ public partial class BulkImporter: IAsyncDisposable
private async Task AssertMemberLimitNotReached(int newMembers) private async Task AssertMemberLimitNotReached(int newMembers)
{ {
var memberLimit = _system.MemberLimitOverride ?? Limits.MaxMemberCount; var memberLimit = _cfg.MemberLimitOverride ?? Limits.MaxMemberCount;
var existingMembers = await _repo.GetSystemMemberCount(_system.Id); var existingMembers = await _repo.GetSystemMemberCount(_system.Id);
if (existingMembers + newMembers > memberLimit) if (existingMembers + newMembers > memberLimit)
throw new ImportException($"Import would exceed the maximum number of members ({memberLimit})."); throw new ImportException($"Import would exceed the maximum number of members ({memberLimit}).");
@ -128,7 +131,7 @@ public partial class BulkImporter: IAsyncDisposable
private async Task AssertGroupLimitNotReached(int newGroups) private async Task AssertGroupLimitNotReached(int newGroups)
{ {
var limit = _system.GroupLimitOverride ?? Limits.MaxGroupCount; var limit = _cfg.GroupLimitOverride ?? Limits.MaxGroupCount;
var existing = await _repo.GetSystemGroupCount(_system.Id); var existing = await _repo.GetSystemGroupCount(_system.Id);
if (existing + newGroups > limit) if (existing + newGroups > limit)
throw new ImportException($"Import would exceed the maximum number of groups ({limit})."); throw new ImportException($"Import would exceed the maximum number of groups ({limit}).");

View File

@ -29,6 +29,5 @@ public static class DateTimeFormats
public static string FormatExport(this LocalDate date) => DateExportFormat.Format(date); public static string FormatExport(this LocalDate date) => DateExportFormat.Format(date);
public static string FormatZoned(this ZonedDateTime zdt) => ZonedDateTimeFormat.Format(zdt); public static string FormatZoned(this ZonedDateTime zdt) => ZonedDateTimeFormat.Format(zdt);
public static string FormatZoned(this Instant i, DateTimeZone zone) => i.InZone(zone).FormatZoned(); public static string FormatZoned(this Instant i, DateTimeZone zone) => i.InZone(zone).FormatZoned();
public static string FormatZoned(this Instant i, PKSystem sys) => i.FormatZoned(sys.Zone);
public static string FormatDuration(this Duration d) => DurationFormat.Format(d); public static string FormatDuration(this Duration d) => DurationFormat.Format(d);
} }

View File

@ -37,6 +37,7 @@ PluralKit will send invalid requests to your endpoint, with `PING` event type, o
|---|---|---|---| |---|---|---|---|
|PING|PluralKit is checking if your webhook URL is working.|null|Reply with a 200 status code if the `signing_token` is correct, or a 401 status code if it is invalid.| |PING|PluralKit is checking if your webhook URL is working.|null|Reply with a 200 status code if the `signing_token` is correct, or a 401 status code if it is invalid.|
|UPDATE_SYSTEM|your system was updated|[system object](/api/models#system-model) only containing modififed keys| |UPDATE_SYSTEM|your system was updated|[system object](/api/models#system-model) only containing modififed keys|
|UPDATE_SETTINGS|your bot settings were updated|[system settings object](/api/models#system-settings-model) only containing modified keys|
|CREATE_MEMBER|a new member was created|[member object](/api/models#member-model) only containing `name` key|new member ID can be found in the top-level `id` key`| |CREATE_MEMBER|a new member was created|[member object](/api/models#member-model) only containing `name` key|new member ID can be found in the top-level `id` key`|
|UPDATE_MEMBER|a member was updated|[member object](/api/models#member-model) only containing modified keys|member ID can be found in the top-level `id` key`| |UPDATE_MEMBER|a member was updated|[member object](/api/models#member-model) only containing modified keys|member ID can be found in the top-level `id` key`|
|DELETE_MEMBER|a member was deleted|null|old member ID can be found in the top-level `id` key`| |DELETE_MEMBER|a member was deleted|null|old member ID can be found in the top-level `id` key`|

View File

@ -28,6 +28,20 @@ Takes a partial [system object](/api/models#system-model).
Returns a [system object](/api/models#system-model). Returns a [system object](/api/models#system-model).
### Get System Settings
GET `/systems/{systemRef}/settings`
Returns a [system settings object](/api/models#system-settings-model).
### Update System Settings
PATCH `/systems/{systemRef}/settings`
Takes a partial [system settings object](/api/models#system-settings-model).
Returns a [system settings object](/api/models#system-settings-model).
### Get System Guild Settings ### Get System Guild Settings
GET `/systems/@me/guilds/{guild_id}` GET `/systems/@me/guilds/{guild_id}`

View File

@ -28,7 +28,6 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID.
|banner|?string|256-character limit, must be a publicly-accessible URL| |banner|?string|256-character limit, must be a publicly-accessible URL|
|color|string|6-character hex code, no `#` at the beginning| |color|string|6-character hex code, no `#` at the beginning|
|created|datetime|| |created|datetime||
|timezone|string|defaults to `UTC`|
|privacy|?system privacy object|| |privacy|?system privacy object||
* System privacy keys: `description_privacy`, `member_list_privacy`, `group_list_privacy`, `front_privacy`, `front_history_privacy` * System privacy keys: `description_privacy`, `member_list_privacy`, `group_list_privacy`, `front_privacy`, `front_history_privacy`
@ -100,6 +99,16 @@ Every PluralKit entity has two IDs: a short (5-character) ID and a longer UUID.
|system|full System object|The system that proxied the message.| |system|full System object|The system that proxied the message.|
|member|full Member object|The member that proxied the message.| |member|full Member object|The member that proxied the message.|
### System settings model
|key|type|notes|
|---|---|---|
|timezone|string|defaults to `UTC`|
|pings_enabled|boolean|
|latch_timeout|int?|
|member_limit|int|read-only, defaults to 1000|
|group_limit|int|read-only, defaults to 250|
### System guild settings model ### System guild settings model
|key|type|notes| |key|type|notes|

View File

@ -43,7 +43,6 @@ Some arguments indicate the use of specific Discord features. These include:
- `pk;system privacy <subject> <public|private>` - Changes your systems privacy settings. - `pk;system privacy <subject> <public|private>` - Changes your systems privacy settings.
- `pk;system tag [tag]` - Changes the system tag of your system. - `pk;system tag [tag]` - Changes the system tag of your system.
- `pk;system servertag [tag|-enable|-disable]` - Changes your system's tag in the current server, or disables it for the current server. - `pk;system servertag [tag|-enable|-disable]` - Changes your system's tag in the current server, or disables it for the current server.
- `pk;system timezone [location]` - Changes the time zone of your system.
- `pk;system proxy [server id] [on|off]` - Toggles message proxying for a specific server. - `pk;system proxy [server id] [on|off]` - Toggles message proxying for a specific server.
- `pk;system delete` - Deletes your system. - `pk;system delete` - Deletes your system.
- `pk;system [system] fronter` - Shows the current fronter of a system. - `pk;system [system] fronter` - Shows the current fronter of a system.
@ -107,8 +106,12 @@ Some arguments indicate the use of specific Discord features. These include:
## Autoproxy commands ## Autoproxy commands
- `pk;autoproxy [off|front|latch|<member>]` - Sets your system's autoproxy mode for the current server. - `pk;autoproxy [off|front|latch|<member>]` - Sets your system's autoproxy mode for the current server.
- `pk;autoproxy timeout [<duration>|off|reset]` - Sets the latch timeout duration for your system.
- `pk;autoproxy account [on|off]` - Toggles autoproxy globally for the current account. ## Config commands
- `pk;config timezone [location]` - Changes the time zone of your system.
- `pk;config ping <enable|disable>` - Changes your system's ping preferences.
- `pk;config autoproxy timeout [<duration>|off|reset]` - Sets the latch timeout duration for your system.
- `pk;config autoproxy account [on|off]` - Toggles autoproxy globally for the current account.
## Server owner commands ## Server owner commands
*(all commands here require Manage Server permission)* *(all commands here require Manage Server permission)*