diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 6091e9df..c81953cd 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -4,9 +4,7 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v1 with: @@ -14,6 +12,6 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 3.1.100 + dotnet-version: 5.0.x - name: Build and test with dotnet run: dotnet test --configuration Release diff --git a/PluralKit.API/PluralKit.API.csproj b/PluralKit.API/PluralKit.API.csproj index a7f81a5d..ab363d32 100644 --- a/PluralKit.API/PluralKit.API.csproj +++ b/PluralKit.API/PluralKit.API.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net5.0 diff --git a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs index 0a0c98b5..3e1b2572 100644 --- a/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextArgumentsExt.cs @@ -86,8 +86,33 @@ namespace PluralKit.Bot members.Add(member); // Then add to the final output list } + if (members.Count == 0) throw new PKSyntaxError($"You must input at least one member."); return members; } + + public static async Task> ParseGroupList(this Context ctx, SystemId? restrictToSystem) + { + var groups = new List(); + + // Loop through all the given arguments + while (ctx.HasNext()) + { + // and attempt to match a group + var group = await ctx.MatchGroup(); + if (group == null) + // if we can't, big error. Every group name must be valid. + throw new PKError(ctx.CreateGroupNotFoundError(ctx.PopArgument())); + + if (restrictToSystem != null && group.System != restrictToSystem) + throw Errors.NotOwnGroupError; // TODO: name *which* group? + + groups.Add(group); // Then add to the final output list + } + + if (groups.Count == 0) throw new PKSyntaxError($"You must input at least one group."); + + return groups; + } } } diff --git a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs index 08190b60..32dd11c0 100644 --- a/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextEntityArgumentsExt.cs @@ -159,7 +159,7 @@ namespace PluralKit.Bot return null; var channel = await ctx.Shard.GetChannel(id); - if (channel == null || channel.Type != ChannelType.Text) return null; + if (channel == null || !(channel.Type == ChannelType.Text || channel.Type == ChannelType.News)) return null; ctx.PopArgument(); return channel; diff --git a/PluralKit.Bot/Commands/Autoproxy.cs b/PluralKit.Bot/Commands/Autoproxy.cs index 7181c1cf..39615607 100644 --- a/PluralKit.Bot/Commands/Autoproxy.cs +++ b/PluralKit.Bot/Commands/Autoproxy.cs @@ -1,10 +1,12 @@ using System; using System.Threading.Tasks; -using Dapper; - using DSharpPlus.Entities; +using Humanizer; + +using NodaTime; + using PluralKit.Core; namespace PluralKit.Bot @@ -20,9 +22,10 @@ namespace PluralKit.Bot _repo = repo; } - public async Task AutoproxyRoot(Context ctx) + public async Task SetAutoproxyMode(Context ctx) { - ctx.CheckSystem().CheckGuildContext(); + // no need to check account here, it's already done at CommandTree + ctx.CheckGuildContext(); if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) await AutoproxyOff(ctx); @@ -122,9 +125,86 @@ namespace PluralKit.Bot default: throw new ArgumentOutOfRangeException(); } + if (!ctx.MessageContext.AllowAutoproxy) + eb.AddField("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."); + 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()}."); + 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()}."); + return; + } + + // todo: somehow parse a more human-friendly date format + int newTimeoutHours; + if (ctx.Match("off", "stop", "cancel", "no", "disable", "remove")) newTimeoutHours = 0; + else if (ctx.Match("reset", "default")) newTimeoutHours = -1; + else if (!int.TryParse(ctx.RemainderOrNull(), out newTimeoutHours)) throw new PKError("Duration must be a number of hours."); + + int? overflow = null; + if (newTimeoutHours > 100000) + { + // sanity check to prevent seconds overflow if someone types in 999999999 + overflow = newTimeoutHours; + newTimeoutHours = 0; + } + + var newTimeout = newTimeoutHours > -1 ? Duration.FromHours(newTimeoutHours) : (Duration?) null; + await _db.Execute(conn => _repo.UpdateSystem(conn, ctx.System.Id, + new SystemPatch { LatchTimeout = (int?) newTimeout?.TotalSeconds })); + + if (newTimeoutHours == -1) + await ctx.Reply($"{Emojis.Success} Latch timeout reset to default ({ProxyMatcher.DefaultLatchExpiryTime.ToTimeSpan().Humanize()})."); + else if (newTimeoutHours == 0 && overflow != null) + await ctx.Reply($"{Emojis.Success} Latch timeout disabled. Latch mode autoproxy will never time out. ({overflow} hours is too long)"); + else if (newTimeoutHours == 0) + 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()}."); + } + + 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}>.", mentions: new IMention[]{}); + } + } + + 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}>.", mentions: new IMention[]{}); + return; + } + var patch = new AccountPatch { AllowAutoproxy = allow }; + await _db.Execute(conn => _repo.UpdateAccount(conn, ctx.Author.Id, patch)); + await ctx.Reply($"{Emojis.Success} Autoproxy {statusString} for account <@{ctx.Author.Id}>.", mentions: new IMention[]{}); + } + private Task UpdateAutoproxy(Context ctx, AutoproxyMode autoproxyMode, MemberId? autoproxyMember) { var patch = new SystemGuildPatch {AutoproxyMode = autoproxyMode, AutoproxyMember = autoproxyMember}; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index f0c825b9..60f2426f 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -28,7 +28,9 @@ namespace PluralKit.Bot 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 ", "Changes your system's ping preferences"); public static Command SystemPrivacy = new Command("system privacy", "system privacy ", "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 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 [|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 ", "Looks up information about a member"); public static Command MemberNew = new Command("member new", "member new ", "Creates a new member"); public static Command MemberRename = new Command("member rename", "member rename ", "Renames a member"); @@ -39,11 +41,15 @@ namespace PluralKit.Bot public static Command MemberProxy = new Command("member proxy", "member proxy [add|remove] [example proxy]", "Changes, adds, or removes a member's proxy tags"); public static Command MemberDelete = new Command("member delete", "member delete", "Deletes a member"); public static Command MemberAvatar = new Command("member avatar", "member avatar [url|@mention]", "Changes a member's avatar"); + public static Command MemberGroups = new Command("member group", "member group", "Shows the groups a member is in"); + public static Command MemberGroupAdd = new Command("member group", "member group add [group 2] [group 3...]", "Adds a member to one or more groups"); + public static Command MemberGroupRemove = new Command("member group", "member group remove [group 2] [group 3...]", "Removes a member from one or more groups"); public static Command MemberServerAvatar = new Command("member serveravatar", "member serveravatar [url|@mention]", "Changes a member's avatar in the current server"); public static Command MemberDisplayName = new Command("member displayname", "member displayname [display name]", "Changes a member's display name"); public static Command MemberServerName = new Command("member servername", "member servername [server name]", "Changes a member's display name in the current server"); + public static Command MemberAutoproxy = new Command("member autoproxy", "member autoproxy [on|off]", "Sets whether a member will be autoproxied when autoproxy is set to latch or front mode."); public static Command MemberKeepProxy = new Command("member keepproxy", "member keepproxy [on|off]", "Sets whether to include a member's proxy tags when proxying"); - public static Command MemberRandom = new Command("random", "random", "Looks up a random member from your system"); + public static Command MemberRandom = new Command("random", "random", "Shows the info card of a randomly selected member in your system."); public static Command MemberPrivacy = new Command("member privacy", "member privacy ", "Changes a members's privacy settings"); public static Command GroupInfo = new Command("group", "group ", "Looks up information about a group"); public static Command GroupNew = new Command("group new", "group new ", "Creates a new group"); @@ -57,10 +63,13 @@ namespace PluralKit.Bot public static Command GroupPrivacy = new Command("group privacy", "group privacy ", "Changes a group's privacy settings"); public static Command GroupIcon = new Command("group icon", "group icon [url|@mention]", "Changes a group's icon"); public static Command GroupDelete = new Command("group delete", "group delete", "Deletes a group"); + public static Command GroupMemberRandom = new Command("group random", "group random", "Shows the info card of a randomly selected member in a group."); + public static Command GroupRandom = new Command("random", "random group", "Shows the info card of a randomly selected group in your system."); public static Command Switch = new Command("switch", "switch [member 2] [member 3...]", "Registers a switch"); public static Command SwitchOut = new Command("switch out", "switch out", "Registers a switch with no members"); public static Command SwitchMove = new Command("switch move", "switch move ", "Moves the latest switch in time"); - public static Command SwitchDelete = new Command("switch delete", "switch delete [all]", "Deletes the latest switch (or them all)"); + public static Command SwitchDelete = new Command("switch delete", "switch delete", "Deletes the latest switch"); + public static Command SwitchDeleteAll = new Command("switch delete", "switch delete all", "Deletes all logged switches"); public static Command Link = new Command("link", "link ", "Links your system to another account"); public static Command Unlink = new Command("unlink", "unlink [account]", "Unlinks your system from an account"); public static Command TokenGet = new Command("token", "token", "Gets your system's API token"); @@ -71,6 +80,7 @@ namespace PluralKit.Bot public static Command Explain = new Command("explain", "explain", "Explains the basics of systems and proxying"); public static Command Message = new Command("message", "message ", "Looks up a proxied message"); public static Command LogChannel = new Command("log channel", "log channel ", "Designates a channel to post proxied messages to"); + public static Command LogChannelClear = new Command("log channel", "log channel -clear", "Clears the currently set log channel"); public static Command LogEnable = new Command("log enable", "log enable all| [channel 2] [channel 3...]", "Enables message logging in certain channels"); public static Command LogDisable = new Command("log disable", "log disable all| [channel 2] [channel 3...]", "Disables message logging in certain channels"); public static Command LogClean = new Command("logclean", "logclean [on|off]", "Toggles whether to clean up other bots' log channels"); @@ -87,8 +97,8 @@ namespace PluralKit.Bot public static Command[] MemberCommands = { MemberInfo, MemberNew, MemberRename, MemberDisplayName, MemberServerName, MemberDesc, MemberPronouns, - MemberColor, MemberBirthday, MemberProxy, MemberKeepProxy, MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, - MemberRandom + MemberColor, MemberBirthday, MemberProxy, MemberAutoproxy, MemberKeepProxy, MemberGroups, MemberGroupAdd, MemberGroupRemove, + MemberDelete, MemberAvatar, MemberServerAvatar, MemberPrivacy, MemberRandom }; public static Command[] GroupCommands = @@ -100,12 +110,16 @@ namespace PluralKit.Bot public static Command[] GroupCommandsTargeted = { GroupInfo, GroupAdd, GroupRemove, GroupMemberList, GroupRename, GroupDesc, GroupIcon, GroupPrivacy, - GroupDelete + GroupDelete, GroupMemberRandom }; - public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete}; + public static Command[] SwitchCommands = {Switch, SwitchOut, SwitchMove, SwitchDelete, SwitchDeleteAll}; - public static Command[] LogCommands = {LogChannel, LogEnable, LogDisable}; + public static Command[] AutoproxyCommands = {AutoproxySet, AutoproxyTimeout, AutoproxyAccount}; + + public static Command[] LogCommands = {LogChannel, LogChannelClear, LogEnable, LogDisable}; + + public static Command[] BlacklistCommands = {BlacklistAdd, BlacklistRemove, BlacklistShow}; private DiscordShardedClient _client; @@ -125,12 +139,12 @@ namespace PluralKit.Bot return HandleGroupCommand(ctx); if (ctx.Match("switch", "sw")) return HandleSwitchCommand(ctx); + if (ctx.Match("commands", "cmd", "c")) + return CommandHelpRoot(ctx); if (ctx.Match("ap", "autoproxy", "auto")) - return ctx.Execute(Autoproxy, m => m.AutoproxyRoot(ctx)); - if (ctx.Match("list", "l", "members")) + return HandleAutoproxyCommand(ctx); + if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd")) return ctx.Execute(SystemList, m => m.MemberList(ctx, ctx.System)); - if (ctx.Match("f", "find", "search", "query", "fd")) - return ctx.Execute(SystemFind, m => m.MemberList(ctx, ctx.System)); if (ctx.Match("link")) return ctx.Execute(Link, m => m.LinkSystem(ctx)); if (ctx.Match("unlink")) @@ -152,8 +166,6 @@ namespace PluralKit.Bot else return ctx.Execute(Help, m => m.HelpRoot(ctx)); if (ctx.Match("explain")) return ctx.Execute(Explain, m => m.Explain(ctx)); - if (ctx.Match("commands")) - return ctx.Reply("For the list of commands, see the website: "); if (ctx.Match("message", "msg")) return ctx.Execute(Message, m => m.GetMessage(ctx)); if (ctx.Match("log")) @@ -163,6 +175,8 @@ namespace PluralKit.Bot return ctx.Execute(LogEnable, m => m.SetLogEnabled(ctx, true)); else if (ctx.Match("disable", "off")) return ctx.Execute(LogDisable, m => m.SetLogEnabled(ctx, false)); + else if (ctx.Match("commands")) + return PrintCommandList(ctx, "message logging", LogCommands); else return PrintCommandExpectedError(ctx, LogCommands); if (ctx.Match("logclean")) return ctx.Execute(LogClean, m => m.SetLogCleanup(ctx)); @@ -173,7 +187,9 @@ namespace PluralKit.Bot return ctx.Execute(BlacklistRemove, m => m.SetBlacklisted(ctx, false)); else if (ctx.Match("list", "show")) return ctx.Execute(BlacklistShow, m => m.ShowBlacklisted(ctx)); - else return PrintCommandExpectedError(ctx, BlacklistAdd, BlacklistRemove, BlacklistShow); + else if (ctx.Match("commands")) + return PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); + else return PrintCommandExpectedError(ctx, BlacklistCommands); if (ctx.Match("proxy", "enable", "disable")) return ctx.Execute(SystemProxy, m => m.SystemProxy(ctx)); if (ctx.Match("invite")) return ctx.Execute(Invite, m => m.Invite(ctx)); @@ -187,7 +203,10 @@ namespace PluralKit.Bot if (ctx.Match("permcheck")) return ctx.Execute(PermCheck, m => m.PermCheckGuild(ctx)); if (ctx.Match("random", "r")) - return ctx.Execute(MemberRandom, m => m.MemberRandom(ctx)); + if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g")) + return ctx.Execute(GroupRandom, r => r.Group(ctx)); + else + return ctx.Execute(MemberRandom, m => m.Member(ctx)); // remove compiler warning return ctx.Reply( @@ -322,12 +341,21 @@ namespace PluralKit.Bot await ctx.Execute(MemberDelete, m => m.Delete(ctx, target)); else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic")) await ctx.Execute(MemberAvatar, m => m.Avatar(ctx, target)); + else if (ctx.Match("group", "groups")) + if (ctx.Match("add", "a")) + await ctx.Execute(MemberGroupAdd, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Add)); + else if (ctx.Match("remove", "rem")) + await ctx.Execute(MemberGroupRemove, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Remove)); + else + await ctx.Execute(MemberGroups, m => m.List(ctx, target)); else if (ctx.Match("serveravatar", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", "guildavatar", "guildpic", "guildicon", "sicon")) await ctx.Execute(MemberServerAvatar, m => m.ServerAvatar(ctx, target)); else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname")) await ctx.Execute(MemberDisplayName, m => m.DisplayName(ctx, target)); else if (ctx.Match("servername", "sn", "sname", "snick", "snickname", "servernick", "servernickname", "serverdisplayname", "guildname", "guildnick", "guildnickname", "serverdn")) await ctx.Execute(MemberServerName, m => m.ServerName(ctx, target)); + else if (ctx.Match("autoproxy", "ap")) + await ctx.Execute(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target)); else if (ctx.Match("keepproxy", "keeptags", "showtags")) await ctx.Execute(MemberKeepProxy, m => m.KeepProxy(ctx, target)); else if (ctx.Match("privacy")) @@ -336,6 +364,8 @@ namespace PluralKit.Bot await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private)); else if (ctx.Match("public", "shown", "show")) await ctx.Execute(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public)); + else if (ctx.Match("soulscream")) + await ctx.Execute(MemberInfo, m => m.Soulscream(ctx, target)); else if (!ctx.HasNext()) // Bare command await ctx.Execute(MemberInfo, m => m.ViewMember(ctx, target)); else @@ -366,6 +396,8 @@ namespace PluralKit.Bot await ctx.Execute(GroupRemove, g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove)); else if (ctx.Match("members", "list", "ms", "l")) await ctx.Execute(GroupMemberList, g => g.ListGroupMembers(ctx, target)); + else if (ctx.Match("random")) + await ctx.Execute(GroupMemberRandom, r => r.GroupMember(ctx, target)); else if (ctx.Match("privacy")) await ctx.Execute(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null)); else if (ctx.Match("public", "pub")) @@ -403,6 +435,74 @@ namespace PluralKit.Bot await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchDelete, SystemFronter, SystemFrontHistory); } + private async Task CommandHelpRoot(Context ctx) + { + if (!ctx.HasNext()) + { + await ctx.Reply($"{Emojis.Error} You need to pass a target command.\nAvailable command help targets: `system`, `member`, `group`, `switch`, `log`, `blacklist`.\nFor the full list of commands, see the website: "); + return; + } + + switch (ctx.PeekArgument()) { + case "system": + case "systems": + case "s": + await PrintCommandList(ctx, "systems", SystemCommands); + break; + case "member": + case "members": + case "m": + await PrintCommandList(ctx, "members", MemberCommands); + break; + case "group": + case "groups": + case "g": + await PrintCommandList(ctx, "groups", GroupCommands); + break; + case "switch": + case "switches": + case "switching": + case "sw": + await PrintCommandList(ctx, "switching", SwitchCommands); + break; + case "log": + await PrintCommandList(ctx, "message logging", LogCommands); + break; + case "blacklist": + case "bl": + await PrintCommandList(ctx, "channel blacklisting", BlacklistCommands); + break; + case "autoproxy": + case "ap": + await PrintCommandList(ctx, "autoproxy", AutoproxyCommands); + break; + // todo: are there any commands that still need to be added? + default: + await ctx.Reply("For the full list of commands, see the website: "); + break; + } + } + + private Task HandleAutoproxyCommand(Context ctx) + { + // todo: merge this with the changes from #251 + if (ctx.Match("commands")) + return PrintCommandList(ctx, "autoproxy", AutoproxyCommands); + + // ctx.CheckSystem(); + // oops, that breaks stuff! PKErrors before ctx.Execute don't actually do anything. + // so we just emulate checking and throwing an error. + if (ctx.System == null) + return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}"); + + if (ctx.Match("account", "ac")) + return ctx.Execute(AutoproxyAccount, m => m.AutoproxyAccount(ctx)); + else if (ctx.Match("timeout", "tm")) + return ctx.Execute(AutoproxyTimeout, m => m.AutoproxyTimeout(ctx)); + else + return ctx.Execute(AutoproxySet, m => m.SetAutoproxyMode(ctx)); + } + private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands) { var commandListStr = CreatePotentialCommandList(potentialCommands); diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index 09ef467b..f3f63fa5 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -18,11 +18,13 @@ namespace PluralKit.Bot { private readonly IDatabase _db; private readonly ModelRepository _repo; + private readonly EmbedService _embeds; - public Groups(IDatabase db, ModelRepository repo) + public Groups(IDatabase db, ModelRepository repo, EmbedService embeds) { _db = db; _repo = repo; + _embeds = embeds; } public async Task CreateGroup(Context ctx) @@ -177,8 +179,6 @@ namespace PluralKit.Bot { ctx.CheckOwnGroup(target); - if (img.Url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(img.Url); await AvatarUtils.VerifyAvatarOrThrow(img.Url); await _db.Execute(c => _repo.UpdateGroup(c, target.Id, new GroupPatch {Icon = img.Url})); @@ -282,87 +282,46 @@ namespace PluralKit.Bot public async Task ShowGroupCard(Context ctx, PKGroup target) { await using var conn = await _db.Obtain(); - var system = await GetGroupSystem(ctx, target, conn); - var pctx = ctx.LookupContextFor(system); - var memberCount = ctx.MatchPrivateFlag(pctx) ? await _repo.GetGroupMemberCount(conn, target.Id, PrivacyLevel.Public) : await _repo.GetGroupMemberCount(conn, target.Id); - - var nameField = target.Name; - if (system.Name != null) - nameField = $"{nameField} ({system.Name})"; - - var eb = new DiscordEmbedBuilder() - .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) - .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); - - if (target.DisplayName != null) - eb.AddField("Display Name", target.DisplayName); - - if (target.ListPrivacy.CanAccess(pctx)) - { - if (memberCount == 0 && pctx == LookupContext.ByOwner) - // Only suggest the add command if this is actually the owner lol - eb.AddField("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true); - else - eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true); - } - - if (target.DescriptionFor(pctx) is {} desc) - eb.AddField("Description", desc); - - if (target.IconFor(pctx) is {} icon) - eb.WithThumbnail(icon); - - await ctx.Reply(embed: eb.Build()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, system, target)); } public async Task AddRemoveMembers(Context ctx, PKGroup target, AddRemoveOperation op) { ctx.CheckOwnGroup(target); - var members = await ctx.ParseMemberList(ctx.System.Id); + var members = (await ctx.ParseMemberList(ctx.System.Id)) + .Select(m => m.Id) + .Distinct() + .ToList(); await using var conn = await _db.Obtain(); var existingMembersInGroup = (await conn.QueryMemberList(target.System, new DatabaseViewsExt.MemberListQueryOptions {GroupFilter = target.Id})) .Select(m => m.Id.Value) + .Distinct() .ToHashSet(); + List toAction; + if (op == AddRemoveOperation.Add) { - var membersNotInGroup = members - .Where(m => !existingMembersInGroup.Contains(m.Id.Value)) - .Select(m => m.Id) - .Distinct() + toAction = members + .Where(m => !existingMembersInGroup.Contains(m.Value)) .ToList(); - await _repo.AddMembersToGroup(conn, target.Id, membersNotInGroup); - - if (membersNotInGroup.Count == members.Count) - await ctx.Reply(members.Count == 0 ? $"{Emojis.Success} Member added to group." : $"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group."); - else - if (membersNotInGroup.Count == 0) - await ctx.Reply(members.Count == 1 ? $"{Emojis.Error} Member not added to group (member already in group)." : $"{Emojis.Error} No members added to group (members already in group)."); - else - await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersNotInGroup.Count)} added to group ({"members".ToQuantity(members.Count - membersNotInGroup.Count)} already in group)."); + await _repo.AddMembersToGroup(conn, target.Id, toAction); } else if (op == AddRemoveOperation.Remove) { - var membersInGroup = members - .Where(m => existingMembersInGroup.Contains(m.Id.Value)) - .Select(m => m.Id) - .Distinct() + toAction = members + .Where(m => existingMembersInGroup.Contains(m.Value)) .ToList(); - await _repo.RemoveMembersFromGroup(conn, target.Id, membersInGroup); - - if (membersInGroup.Count == members.Count) - await ctx.Reply(members.Count == 0 ? $"{Emojis.Success} Member removed from group." : $"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group."); - else - if (membersInGroup.Count == 0) - await ctx.Reply(members.Count == 1 ? $"{Emojis.Error} Member not removed from group (member already not in group)." : $"{Emojis.Error} No members removed from group (members already not in group)."); - else - await ctx.Reply($"{Emojis.Success} {"members".ToQuantity(membersInGroup.Count)} removed from group ({"members".ToQuantity(members.Count - membersInGroup.Count)} already not in group)."); + await _repo.RemoveMembersFromGroup(conn, target.Id, toAction); } + else return; // otherwise toAction "may be undefined" + + await ctx.Reply(MiscUtils.GroupAddRemoveResponse(members, toAction, op)); } public async Task ListGroupMembers(Context ctx, PKGroup target) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index 93bf1687..68c2830e 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -1,9 +1,14 @@ -using System.Linq; using System.Threading.Tasks; -using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Web; using Dapper; +using DSharpPlus.Entities; + +using Newtonsoft.Json.Linq; + using PluralKit.Core; namespace PluralKit.Bot @@ -13,12 +18,14 @@ namespace PluralKit.Bot private readonly IDatabase _db; private readonly ModelRepository _repo; private readonly EmbedService _embeds; + private readonly HttpClient _client; - public Member(EmbedService embeds, IDatabase db, ModelRepository repo) + public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client) { _embeds = embeds; _db = db; _repo = repo; + _client = client; } public async Task NewMember(Context ctx) { @@ -61,33 +68,33 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members."); } - public async Task MemberRandom(Context ctx) - { - ctx.CheckSystem(); - - var randGen = new global::System.Random(); - //Maybe move this somewhere else in the file structure since it doesn't need to get created at every command - - // TODO: don't buffer these, find something else to do ig - - var members = await _db.Execute(c => - { - if (ctx.MatchFlag("all", "a")) - return _repo.GetSystemMembers(c, ctx.System.Id); - return _repo.GetSystemMembers(c, ctx.System.Id) - .Where(m => m.MemberVisibility == PrivacyLevel.Public); - }).ToListAsync(); - - if (members == null || !members.Any()) - throw Errors.NoMembersError; - var randInt = randGen.Next(members.Count); - await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); - } - public async Task ViewMember(Context ctx, PKMember target) { var system = await _db.Execute(c => _repo.GetSystem(c, target.System)); await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system))); } + + public async Task Soulscream(Context ctx, PKMember target) + { + // this is for a meme, please don't take this code seriously. :) + + var name = target.NameFor(ctx.LookupContextFor(target)); + var encoded = HttpUtility.UrlEncode(name); + + var resp = await _client.GetAsync($"https://onomancer.sibr.dev/api/generateStats2?name={encoded}"); + if (resp.StatusCode != HttpStatusCode.OK) + // lol + return; + + var data = JObject.Parse(await resp.Content.ReadAsStringAsync()); + var scream = data["soulscream"]!.Value(); + + var eb = new DiscordEmbedBuilder() + .WithColor(DiscordColor.Red) + .WithTitle(name) + .WithUrl($"https://onomancer.sibr.dev/reflect?name={encoded}") + .WithDescription($"*{scream}*"); + await ctx.Reply(embed: eb.Build()); + } } } diff --git a/PluralKit.Bot/Commands/MemberAvatar.cs b/PluralKit.Bot/Commands/MemberAvatar.cs index 6164b82c..786779b6 100644 --- a/PluralKit.Bot/Commands/MemberAvatar.cs +++ b/PluralKit.Bot/Commands/MemberAvatar.cs @@ -102,18 +102,11 @@ namespace PluralKit.Bot } ctx.CheckSystem().CheckOwnMember(target); - await ValidateUrl(avatarArg.Value.Url); + await AvatarUtils.VerifyAvatarOrThrow(avatarArg.Value.Url); await UpdateAvatar(location, ctx, target, avatarArg.Value.Url); await PrintResponse(location, ctx, target, avatarArg.Value, guildData); } - private static Task ValidateUrl(string url) - { - if (url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(url); - return AvatarUtils.VerifyAvatarOrThrow(url); - } - private Task PrintResponse(AvatarLocation location, Context ctx, PKMember target, ParsedImage avatar, MemberGuildSettings? targetGuildData) { diff --git a/PluralKit.Bot/Commands/MemberEdit.cs b/PluralKit.Bot/Commands/MemberEdit.cs index af8a2eec..43d8fa82 100644 --- a/PluralKit.Bot/Commands/MemberEdit.cs +++ b/PluralKit.Bot/Commands/MemberEdit.cs @@ -24,10 +24,9 @@ namespace PluralKit.Bot _repo = repo; } - public async Task Name(Context ctx, PKMember target) { - // TODO: this method is pretty much a 1:1 copy/paste of the above creation method, find a way to clean? - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + public async Task Name(Context ctx, PKMember target) + { + ctx.CheckSystem().CheckOwnMember(target); var newName = ctx.RemainderOrNull() ?? throw new PKSyntaxError("You must pass a new name for the member."); @@ -58,15 +57,10 @@ namespace PluralKit.Bot } } - private void CheckEditMemberPermission(Context ctx, PKMember target) - { - if (target.System != ctx.System?.Id) throw Errors.NotOwnMemberError; - } - public async Task Description(Context ctx, PKMember target) { if (await ctx.MatchClear("this member's description")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Description = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -93,7 +87,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var description = ctx.RemainderOrNull().NormalizeLineEndSpacing(); if (description.IsLongerThan(Limits.MaxDescriptionLength)) @@ -109,7 +103,8 @@ namespace PluralKit.Bot public async Task Pronouns(Context ctx, PKMember target) { if (await ctx.MatchClear("this member's pronouns")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); + var patch = new MemberPatch {Pronouns = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); await ctx.Reply($"{Emojis.Success} Member pronouns cleared."); @@ -129,7 +124,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var pronouns = ctx.RemainderOrNull().NormalizeLineEndSpacing(); if (pronouns.IsLongerThan(Limits.MaxPronounsLength)) @@ -147,7 +142,7 @@ namespace PluralKit.Bot var color = ctx.RemainderOrNull(); if (await ctx.MatchClear()) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Color = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -176,7 +171,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); if (color.StartsWith("#")) color = color.Substring(1); if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color); @@ -195,7 +190,7 @@ namespace PluralKit.Bot { if (await ctx.MatchClear("this member's birthday")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {Birthday = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -216,7 +211,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var birthdayStr = ctx.RemainderOrNull(); var birthday = DateUtils.ParseDate(birthdayStr, true); @@ -281,7 +276,7 @@ namespace PluralKit.Bot if (await ctx.MatchClear("this member's display name")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberPatch {DisplayName = Partial.Null()}; await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); @@ -298,7 +293,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var newDisplayName = ctx.RemainderOrNull(); @@ -315,7 +310,7 @@ namespace PluralKit.Bot if (await ctx.MatchClear("this member's server name")) { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var patch = new MemberGuildPatch {DisplayName = null}; await _db.Execute(conn => _repo.UpsertMemberGuild(conn, target.Id, ctx.Guild.Id, patch)); @@ -335,7 +330,7 @@ namespace PluralKit.Bot } else { - CheckEditMemberPermission(ctx, target); + ctx.CheckOwnMember(target); var newServerName = ctx.RemainderOrNull(); @@ -348,8 +343,7 @@ namespace PluralKit.Bot public async Task KeepProxy(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); bool newValue; if (ctx.Match("on", "enabled", "true", "yes")) newValue = true; @@ -373,11 +367,37 @@ namespace PluralKit.Bot await ctx.Reply($"{Emojis.Success} Member proxy tags will now not be included in the resulting message when proxying."); } - public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) + public async Task MemberAutoproxy(Context ctx, PKMember target) { if (ctx.System == null) throw Errors.NoSystemError; if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + bool newValue; + if (ctx.Match("on", "enabled", "true", "yes") || ctx.MatchFlag("on", "enabled", "true", "yes")) newValue = true; + else if (ctx.Match("off", "disabled", "false", "no") || ctx.MatchFlag("off", "disabled", "false", "no")) newValue = false; + else if (ctx.HasNext()) throw new PKSyntaxError("You must pass either \"on\" or \"off\"."); + else + { + if (target.AllowAutoproxy) + await ctx.Reply("Latch/front autoproxy are **enabled** for this member. This member will be automatically proxied when autoproxy is set to latch or front mode."); + else + await ctx.Reply("Latch/front autoproxy are **disabled** for this member. This member will not be automatically proxied when autoproxy is set to latch or front mode."); + return; + }; + + var patch = new MemberPatch {AllowAutoproxy = Partial.Present(newValue)}; + await _db.Execute(conn => _repo.UpdateMember(conn, target.Id, patch)); + + if (newValue) + await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **enabled** for this member."); + else + await ctx.Reply($"{Emojis.Success} Latch / front autoproxy have been **disabled** for this member."); + } + + public async Task Privacy(Context ctx, PKMember target, PrivacyLevel? newValueFromCommand) + { + ctx.CheckSystem().CheckOwnMember(target); + // Display privacy settings if (!ctx.HasNext() && newValueFromCommand == null) { @@ -466,8 +486,7 @@ namespace PluralKit.Bot public async Task Delete(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); await ctx.Reply($"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__"); if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled; diff --git a/PluralKit.Bot/Commands/MemberGroup.cs b/PluralKit.Bot/Commands/MemberGroup.cs new file mode 100644 index 00000000..6a8f9f7b --- /dev/null +++ b/PluralKit.Bot/Commands/MemberGroup.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using DSharpPlus.Entities; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class MemberGroup + { + private readonly IDatabase _db; + private readonly ModelRepository _repo; + + public MemberGroup(IDatabase db, ModelRepository repo) + { + _db = db; + _repo = repo; + } + + public async Task AddRemove(Context ctx, PKMember target, Groups.AddRemoveOperation op) + { + ctx.CheckSystem().CheckOwnMember(target); + + var groups = (await ctx.ParseGroupList(ctx.System.Id)) + .Select(g => g.Id) + .Distinct() + .ToList(); + + await using var conn = await _db.Obtain(); + var existingGroups = (await _repo.GetMemberGroups(conn, target.Id).ToListAsync()) + .Select(g => g.Id) + .Distinct() + .ToList(); + + List toAction; + + if (op == Groups.AddRemoveOperation.Add) + { + toAction = groups + .Where(group => !existingGroups.Contains(group)) + .ToList(); + + await _repo.AddGroupsToMember(conn, target.Id, toAction); + } + else if (op == Groups.AddRemoveOperation.Remove) + { + toAction = groups + .Where(group => existingGroups.Contains(group)) + .ToList(); + + await _repo.RemoveGroupsFromMember(conn, target.Id, toAction); + } + else return; // otherwise toAction "may be unassigned" + + await ctx.Reply(MiscUtils.GroupAddRemoveResponse(groups, toAction, op)); + } + + public async Task List(Context ctx, PKMember target) + { + await using var conn = await _db.Obtain(); + + var pctx = ctx.LookupContextFor(target.System); + + var groups = await _repo.GetMemberGroups(conn, target.Id) + .Where(g => g.Visibility.CanAccess(pctx)) + .OrderBy(g => g.Name, StringComparer.InvariantCultureIgnoreCase) + .ToListAsync(); + + var description = ""; + var msg = ""; + + if (groups.Count == 0) + description = "This member has no groups."; + else + description = string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**")); + + if (pctx == LookupContext.ByOwner) + { + msg += $"\n\nTo add this member to one or more groups, use `pk;m {target.Reference()} group add [group 2] [group 3...]`"; + if (groups.Count > 0) + msg += $"\nTo remove this member from one or more groups, use `pk;m {target.Reference()} group remove [group 2] [group 3...]`"; + } + + await ctx.Reply(msg, embed: (new DiscordEmbedBuilder().WithTitle($"{target.Name}'s groups").WithDescription(description)).Build()); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/MemberProxy.cs b/PluralKit.Bot/Commands/MemberProxy.cs index fb9ef0fd..d09ea4cf 100644 --- a/PluralKit.Bot/Commands/MemberProxy.cs +++ b/PluralKit.Bot/Commands/MemberProxy.cs @@ -20,8 +20,7 @@ namespace PluralKit.Bot public async Task Proxy(Context ctx, PKMember target) { - if (ctx.System == null) throw Errors.NoSystemError; - if (target.System != ctx.System.Id) throw Errors.NotOwnMemberError; + ctx.CheckSystem().CheckOwnMember(target); ProxyTag ParseProxyTags(string exampleProxy) { diff --git a/PluralKit.Bot/Commands/Misc.cs b/PluralKit.Bot/Commands/Misc.cs index da65284c..fb814f84 100644 --- a/PluralKit.Bot/Commands/Misc.cs +++ b/PluralKit.Bot/Commands/Misc.cs @@ -48,7 +48,7 @@ namespace PluralKit.Bot { .Grant(Permissions.ManageWebhooks) .Grant(Permissions.ReadMessageHistory) .Grant(Permissions.SendMessages); - var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot&permissions={(long)permissions}"; + var invite = $"https://discord.com/oauth2/authorize?client_id={clientId}&scope=bot%20applications.commands&permissions={(long)permissions}"; await ctx.Reply($"{Emojis.Success} Use this link to add PluralKit to your server:\n<{invite}>"); } diff --git a/PluralKit.Bot/Commands/Random.cs b/PluralKit.Bot/Commands/Random.cs new file mode 100644 index 00000000..6c154cbc --- /dev/null +++ b/PluralKit.Bot/Commands/Random.cs @@ -0,0 +1,79 @@ +using System.Linq; +using System.Threading.Tasks; + +using PluralKit.Core; + +namespace PluralKit.Bot +{ + public class Random + { + private readonly IDatabase _db; + private readonly ModelRepository _repo; + private readonly EmbedService _embeds; + + private readonly global::System.Random randGen = new global::System.Random(); + + public Random(EmbedService embeds, IDatabase db, ModelRepository repo) + { + _embeds = embeds; + _db = db; + _repo = repo; + } + + // todo: get postgresql to return one random member/group instead of querying all members/groups + + public async Task Member(Context ctx) + { + ctx.CheckSystem(); + + var members = await _db.Execute(c => + { + if (ctx.MatchFlag("all", "a")) + return _repo.GetSystemMembers(c, ctx.System.Id); + return _repo.GetSystemMembers(c, ctx.System.Id) + .Where(m => m.MemberVisibility == PrivacyLevel.Public); + }).ToListAsync(); + + if (members == null || !members.Any()) + throw new PKError("Your system has no members! Please create at least one member before using this command."); + + var randInt = randGen.Next(members.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, members[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); + } + + public async Task Group(Context ctx) + { + ctx.CheckSystem(); + + var groups = await _db.Execute(c => c.QueryGroupList(ctx.System.Id)); + if (!ctx.MatchFlag("all", "a")) + groups = groups.Where(g => g.Visibility == PrivacyLevel.Public); + + if (groups == null || !groups.Any()) + throw new PKError("Your system has no groups! Please create at least one group before using this command."); + + var randInt = randGen.Next(groups.Count()); + await ctx.Reply(embed: await _embeds.CreateGroupEmbed(ctx, ctx.System, groups.ToArray()[randInt])); + } + + public async Task GroupMember(Context ctx, PKGroup group) + { + var opts = ctx.ParseMemberListOptions(ctx.LookupContextFor(group.System)); + opts.GroupFilter = group.Id; + + await using var conn = await _db.Obtain(); + var members = await conn.QueryMemberList(ctx.System.Id, opts.ToQueryOptions()); + + if (members == null || !members.Any()) + throw new PKError("This group has no members! Please add at least one member to this group before using this command."); + + if (!ctx.MatchFlag("all", "a")) + members = members.Where(g => g.MemberVisibility == PrivacyLevel.Public); + + var ms = members.ToList(); + + var randInt = randGen.Next(ms.Count); + await ctx.Reply(embed: await _embeds.CreateMemberEmbed(ctx.System, ms[randInt], ctx.Guild, ctx.LookupContextFor(ctx.System))); + } + } +} \ No newline at end of file diff --git a/PluralKit.Bot/Commands/SystemEdit.cs b/PluralKit.Bot/Commands/SystemEdit.cs index 39676ebd..a4f641af 100644 --- a/PluralKit.Bot/Commands/SystemEdit.cs +++ b/PluralKit.Bot/Commands/SystemEdit.cs @@ -140,8 +140,6 @@ namespace PluralKit.Bot async Task SetIcon(ParsedImage img) { - if (img.Url.Length > Limits.MaxUriLength) - throw Errors.InvalidUrl(img.Url); await AvatarUtils.VerifyAvatarOrThrow(img.Url); await _db.Execute(c => _repo.UpdateSystem(c, ctx.System.Id, new SystemPatch {AvatarUrl = img.Url})); diff --git a/PluralKit.Bot/Errors.cs b/PluralKit.Bot/Errors.cs index 79600f68..4ad5a2d0 100644 --- a/PluralKit.Bot/Errors.cs +++ b/PluralKit.Bot/Errors.cs @@ -45,7 +45,6 @@ namespace PluralKit.Bot { public static PKError MemberNameTooLongError(int length) => new PKError($"Member name too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberPronounsTooLongError(int length) => new PKError($"Member pronouns too long ({length}/{Limits.MaxMemberNameLength} characters)."); public static PKError MemberLimitReachedError(int limit) => new PKError($"System has reached the maximum number of members ({limit}). Please delete unused members first in order to create new ones."); - public static PKError NoMembersError => new PKError("Your system has no members! Please create at least one member before using this command."); public static PKError InvalidColorError(string color) => new PKError($"\"{color}\" is not a valid color. Color must be in 6-digit RGB hex format (eg. #ff0000)."); public static PKError BirthdayParseError(string birthday) => new PKError($"\"{birthday}\" could not be parsed as a valid date. Try a format like \"2016-12-24\" or \"May 3 1996\"."); diff --git a/PluralKit.Bot/Handlers/MessageCreated.cs b/PluralKit.Bot/Handlers/MessageCreated.cs index 31c66075..d51fe0fc 100644 --- a/PluralKit.Bot/Handlers/MessageCreated.cs +++ b/PluralKit.Bot/Handlers/MessageCreated.cs @@ -137,7 +137,7 @@ namespace PluralKit.Bot { try { - return await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: true); + return await _proxy.HandleIncomingMessage(shard, evt.Message, ctx, allowAutoproxy: ctx.AllowAutoproxy); } catch (PKError e) { diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 5841149f..b960a835 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -40,8 +40,10 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); diff --git a/PluralKit.Bot/PluralKit.Bot.csproj b/PluralKit.Bot/PluralKit.Bot.csproj index d23436e1..7063f7de 100644 --- a/PluralKit.Bot/PluralKit.Bot.csproj +++ b/PluralKit.Bot/PluralKit.Bot.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net5.0 diff --git a/PluralKit.Bot/Proxy/ProxyMatcher.cs b/PluralKit.Bot/Proxy/ProxyMatcher.cs index cb01e4a7..e2a3c5e7 100644 --- a/PluralKit.Bot/Proxy/ProxyMatcher.cs +++ b/PluralKit.Bot/Proxy/ProxyMatcher.cs @@ -10,7 +10,7 @@ namespace PluralKit.Bot public class ProxyMatcher { private static readonly char AutoproxyEscapeCharacter = '\\'; - private static readonly Duration LatchExpiryTime = Duration.FromHours(6); + public static readonly Duration DefaultLatchExpiryTime = Duration.FromHours(6); private readonly IClock _clock; private readonly ProxyTagParser _parser; @@ -56,13 +56,13 @@ namespace PluralKit.Bot AutoproxyMode.Front when ctx.LastSwitchMembers.Length > 0 => members.FirstOrDefault(m => m.Id == ctx.LastSwitchMembers[0]), - AutoproxyMode.Latch when ctx.LastMessageMember != null && !IsLatchExpired(ctx.LastMessage) => + AutoproxyMode.Latch when ctx.LastMessageMember != null && !IsLatchExpired(ctx) => members.FirstOrDefault(m => m.Id == ctx.LastMessageMember.Value), _ => null }; - if (member == null) return false; + if (member == null || (ctx.AutoproxyMode != AutoproxyMode.Member && !member.AllowAutoproxy)) return false; match = new ProxyMatch { Content = messageContent, @@ -75,11 +75,17 @@ namespace PluralKit.Bot return true; } - private bool IsLatchExpired(ulong? messageId) + private bool IsLatchExpired(MessageContext ctx) { - if (messageId == null) return true; - var timestamp = DiscordUtils.SnowflakeToInstant(messageId.Value); - return _clock.GetCurrentInstant() - timestamp > LatchExpiryTime; + if (ctx.LastMessage == null) return true; + if (ctx.LatchTimeout == 0) return false; + + var timeout = ctx.LatchTimeout.HasValue + ? Duration.FromSeconds(ctx.LatchTimeout.Value) + : DefaultLatchExpiryTime; + + var timestamp = DiscordUtils.SnowflakeToInstant(ctx.LastMessage.Value); + return _clock.GetCurrentInstant() - timestamp > timeout; } } } \ No newline at end of file diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index 6f77e42b..2e1af75c 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; using App.Metrics; @@ -55,7 +57,9 @@ namespace PluralKit.Bot // Permission check after proxy match so we don't get spammed when not actually proxying if (!await CheckBotPermissionsOrError(message.Channel)) return false; - if (!CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx))) return false; + + // this method throws, so no need to wrap it in an if statement + CheckProxyNameBoundsOrError(match.Member.ProxyName(ctx)); // Check if the sender account can mention everyone/here + embed links // we need to "mirror" these permissions when proxying to prevent exploits @@ -74,7 +78,7 @@ namespace PluralKit.Bot if (ctx.SystemId == null) return false; // Make sure channel is a guild text channel and this is a normal message - if (msg.Channel.Type != ChannelType.Text || msg.MessageType != MessageType.Default) return false; + if ((msg.Channel.Type != ChannelType.Text && msg.Channel.Type != ChannelType.News) || msg.MessageType != MessageType.Default) return false; // Make sure author is a normal user if (msg.Author.IsSystem == true || msg.Author.IsBot || msg.WebhookMessage) return false; @@ -93,16 +97,88 @@ namespace PluralKit.Bot private async Task ExecuteProxy(DiscordClient shard, IPKConnection conn, DiscordMessage trigger, MessageContext ctx, ProxyMatch match, bool allowEveryone, bool allowEmbeds) { + // Create reply embed + var embeds = new List(); + if (trigger.Reference?.Channel?.Id == trigger.ChannelId) + { + var repliedTo = await FetchReplyOriginalMessage(trigger.Reference); + if (repliedTo != null) + { + var embed = CreateReplyEmbed(repliedTo); + if (embed != null) + embeds.Add(embed); + } + + // TODO: have a clean error for when message can't be fetched instead of just being silent + } + // Send the webhook var content = match.ProxyContent; if (!allowEmbeds) content = content.BreakLinkEmbeds(); - var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, match.Member.ProxyName(ctx), + var proxyMessage = await _webhookExecutor.ExecuteWebhook(trigger.Channel, FixSingleCharacterName(match.Member.ProxyName(ctx)), match.Member.ProxyAvatar(ctx), - content, trigger.Attachments, allowEveryone); + content, trigger.Attachments, embeds, allowEveryone); await HandleProxyExecutedActions(shard, conn, ctx, trigger, proxyMessage, match); } + private async Task FetchReplyOriginalMessage(DiscordMessageReference reference) + { + try + { + return await reference.Channel.GetMessageAsync(reference.Message.Id); + } + catch (NotFoundException) + { + _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but it was not found", + reference.Channel.Id, reference.Message.Id); + } + catch (UnauthorizedException) + { + _logger.Warning("Attempted to fetch reply message {ChannelId}/{MessageId} but bot was not allowed to", + reference.Channel.Id, reference.Message.Id); + } + + return null; + } + + private DiscordEmbed CreateReplyEmbed(DiscordMessage original) + { + var content = new StringBuilder(); + + var hasContent = !string.IsNullOrWhiteSpace(original.Content); + if (hasContent) + { + var msg = original.Content; + if (msg.Length > 100) + { + msg = original.Content.Substring(0, 100); + var spoilersInOriginalString = Regex.Matches(original.Content, @"\|\|").Count; + var spoilersInTruncatedString = Regex.Matches(msg, @"\|\|").Count; + if (spoilersInTruncatedString % 2 == 1 && spoilersInOriginalString % 2 == 0) + msg += "||"; + msg += "…"; + } + + content.Append($"**[Reply to:]({original.JumpLink})** "); + content.Append(msg); + if (original.Attachments.Count > 0) + content.Append($" {Emojis.Paperclip}"); + } + else + { + content.Append($"*[(click to see attachment)]({original.JumpLink})*"); + } + + var username = (original.Author as DiscordMember)?.Nickname ?? original.Author.Username; + + return new DiscordEmbedBuilder() + // unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation] + .WithAuthor($"{username}\u2004\u21a9\ufe0f", iconUrl: original.Author.GetAvatarUrl(ImageFormat.Png, 1024)) + .WithDescription(content.ToString()) + .Build(); + } + private async Task HandleProxyExecutedActions(DiscordClient shard, IPKConnection conn, MessageContext ctx, DiscordMessage triggerMessage, DiscordMessage proxyMessage, ProxyMatch match) @@ -185,13 +261,15 @@ namespace PluralKit.Bot return true; } - private bool CheckProxyNameBoundsOrError(string proxyName) + private string FixSingleCharacterName(string proxyName) { - if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName); - if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); + if (proxyName.Length == 1) return proxyName += "\u17b5"; + else return proxyName; + } - // TODO: this never returns false as it throws instead, should this happen? - return true; + private void CheckProxyNameBoundsOrError(string proxyName) + { + if (proxyName.Length > Limits.MaxProxyNameLength) throw Errors.ProxyNameTooLong(proxyName); } } } \ No newline at end of file diff --git a/PluralKit.Bot/Services/EmbedService.cs b/PluralKit.Bot/Services/EmbedService.cs index b34008d1..f9ca05bb 100644 --- a/PluralKit.Bot/Services/EmbedService.cs +++ b/PluralKit.Bot/Services/EmbedService.cs @@ -157,6 +157,42 @@ namespace PluralKit.Bot { return eb.Build(); } + public async Task CreateGroupEmbed(Context ctx, PKSystem system, PKGroup target) + { + await using var conn = await _db.Obtain(); + + var pctx = ctx.LookupContextFor(system); + var memberCount = ctx.MatchPrivateFlag(pctx) ? await _repo.GetGroupMemberCount(conn, target.Id, PrivacyLevel.Public) : await _repo.GetGroupMemberCount(conn, target.Id); + + var nameField = target.Name; + if (system.Name != null) + nameField = $"{nameField} ({system.Name})"; + + var eb = new DiscordEmbedBuilder() + .WithAuthor(nameField, iconUrl: DiscordUtils.WorkaroundForUrlBug(target.IconFor(pctx))) + .WithFooter($"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}"); + + if (target.DisplayName != null) + eb.AddField("Display Name", target.DisplayName); + + if (target.ListPrivacy.CanAccess(pctx)) + { + if (memberCount == 0 && pctx == LookupContext.ByOwner) + // Only suggest the add command if this is actually the owner lol + eb.AddField("Members (0)", $"Add one with `pk;group {target.Reference()} add `!", true); + else + eb.AddField($"Members ({memberCount})", $"(see `pk;group {target.Reference()} list`)", true); + } + + if (target.DescriptionFor(pctx) is {} desc) + eb.AddField("Description", desc); + + if (target.IconFor(pctx) is {} icon) + eb.WithThumbnail(icon); + + return eb.Build(); + } + public async Task CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx) { var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask()); diff --git a/PluralKit.Bot/Services/LoggerCleanService.cs b/PluralKit.Bot/Services/LoggerCleanService.cs index 3da2d743..41ff4fa6 100644 --- a/PluralKit.Bot/Services/LoggerCleanService.cs +++ b/PluralKit.Bot/Services/LoggerCleanService.cs @@ -29,6 +29,7 @@ namespace PluralKit.Bot private static readonly Regex _vanessaRegex = new Regex("Message sent by <@!?(\\d{17,19})> deleted in"); private static readonly Regex _salRegex = new Regex("\\(ID: (\\d{17,19})\\)"); private static readonly Regex _GearBotRegex = new Regex("\\(``(\\d{17,19})``\\) in <#\\d{17,19}> has been removed."); + private static readonly Regex _GiselleRegex = new Regex("\\*\\*Message ID\\*\\*: `(\\d{17,19})`"); private static readonly Dictionary _bots = new[] { @@ -48,7 +49,8 @@ namespace PluralKit.Bot new LoggerBot("UnbelievaBoat", 292953664492929025, ExtractUnbelievaBoat, webhookName: "UnbelievaBoat"), new LoggerBot("Vanessa", 310261055060443136, fuzzyExtractFunc: ExtractVanessa), new LoggerBot("SafetyAtLast", 401549924199694338, fuzzyExtractFunc: ExtractSAL), - new LoggerBot("GearBot", 349977940198555660, fuzzyExtractFunc: ExtractGearBot) + new LoggerBot("GearBot", 349977940198555660, fuzzyExtractFunc: ExtractGearBot), + new LoggerBot("GiselleBot", 356831787445387285, ExtractGiselleBot) }.ToDictionary(b => b.Id); private static readonly Dictionary _botsByWebhookName = _bots.Values @@ -292,6 +294,13 @@ namespace PluralKit.Bot : (FuzzyExtractResult?) null; } + private static ulong? ExtractGiselleBot(DiscordMessage msg) + { + var embed = msg.Embeds.FirstOrDefault(); + if (embed?.Title == null || embed.Title != "🗑 Message Deleted") return null; + var match = _GiselleRegex.Match(embed?.Description); + return match.Success ? ulong.Parse(match.Groups[1].Value) : (ulong?) null; + } public class LoggerBot { diff --git a/PluralKit.Bot/Services/WebhookExecutorService.cs b/PluralKit.Bot/Services/WebhookExecutorService.cs index d0917605..c307562d 100644 --- a/PluralKit.Bot/Services/WebhookExecutorService.cs +++ b/PluralKit.Bot/Services/WebhookExecutorService.cs @@ -42,13 +42,13 @@ namespace PluralKit.Bot _logger = logger.ForContext(); } - public async Task ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList attachments, bool allowEveryone) + public async Task ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList attachments, IReadOnlyList embeds, bool allowEveryone) { _logger.Verbose("Invoking webhook in channel {Channel}", channel.Id); // Get a webhook, execute it var webhook = await _webhookCache.GetWebhook(channel); - var webhookMessage = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments, allowEveryone); + var webhookMessage = await ExecuteWebhookInner(channel, webhook, name, avatarUrl, content, attachments, embeds, allowEveryone); // Log the relevant metrics _metrics.Measure.Meter.Mark(BotMetrics.MessagesProxied); @@ -58,17 +58,19 @@ namespace PluralKit.Bot return webhookMessage; } - private async Task ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, - IReadOnlyList attachments, bool allowEveryone, bool hasRetried = false) + private async Task ExecuteWebhookInner( + DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content, + IReadOnlyList attachments, IReadOnlyList embeds, bool allowEveryone, bool hasRetried = false) { content = content.Truncate(2000); - + var dwb = new DiscordWebhookBuilder(); dwb.WithUsername(FixClyde(name).Truncate(80)); dwb.WithContent(content); dwb.AddMentions(content.ParseAllMentions(allowEveryone, channel.Guild)); if (!string.IsNullOrWhiteSpace(avatarUrl)) dwb.WithAvatarUrl(avatarUrl); + dwb.AddEmbeds(embeds); var attachmentChunks = ChunkAttachmentsOrThrow(attachments, 8 * 1024 * 1024); if (attachmentChunks.Count > 0) @@ -99,7 +101,7 @@ namespace PluralKit.Bot _logger.Warning("Error invoking webhook {Webhook} in channel {Channel}", webhook.Id, webhook.ChannelId); var newWebhook = await _webhookCache.InvalidateAndRefreshWebhook(channel, webhook); - return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, allowEveryone, hasRetried: true); + return await ExecuteWebhookInner(channel, newWebhook, name, avatarUrl, content, attachments, embeds, allowEveryone, hasRetried: true); } throw; diff --git a/PluralKit.Bot/Utils/AvatarUtils.cs b/PluralKit.Bot/Utils/AvatarUtils.cs index 837fc2fc..df4881ab 100644 --- a/PluralKit.Bot/Utils/AvatarUtils.cs +++ b/PluralKit.Bot/Utils/AvatarUtils.cs @@ -11,6 +11,9 @@ namespace PluralKit.Bot { public static class AvatarUtils { public static async Task VerifyAvatarOrThrow(string url) { + if (url.Length > Limits.MaxUriLength) + throw Errors.UrlTooLong(url); + // List of MIME types we consider acceptable var acceptableMimeTypes = new[] { diff --git a/PluralKit.Bot/Utils/MiscUtils.cs b/PluralKit.Bot/Utils/MiscUtils.cs index 4d0c2e22..38a86618 100644 --- a/PluralKit.Bot/Utils/MiscUtils.cs +++ b/PluralKit.Bot/Utils/MiscUtils.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Collections.Generic; using System.Net.Sockets; using System.Threading.Tasks; @@ -17,6 +18,37 @@ namespace PluralKit.Bot public static string ProxyTagsString(this PKMember member, string separator = ", ") => string.Join(separator, member.ProxyTags.Select(t => t.ProxyString.AsCode())); + + private static String entityTerm(int count, bool isTarget) + { + var ret = ""; + ret += isTarget ? "Member" : "Group"; + if (( + (typeof(T) == typeof(GroupId) && !isTarget) || + (typeof(T) == typeof(MemberId) && isTarget) + ) && count > 1) + ret += "s"; + return ret; + } + + public static String GroupAddRemoveResponse(List entityList, List actionedOn, Groups.AddRemoveOperation op) + { + var opStr = op == Groups.AddRemoveOperation.Add ? "added to" : "removed from"; + var inStr = op == Groups.AddRemoveOperation.Add ? "in" : "not in"; + var notActionedOn = entityList.Count - actionedOn.Count; + + var groupNotActionedPosStr = typeof(T) == typeof(GroupId) ? notActionedOn.ToString() + " " : ""; + var memberNotActionedPosStr = typeof(T) == typeof(MemberId) ? notActionedOn.ToString() + " " : ""; + + if (actionedOn.Count == 0) + return $"{Emojis.Error} {entityTerm(notActionedOn, true)} not {opStr} {entityTerm(entityList.Count, false).ToLower()} ({entityTerm(notActionedOn, true).ToLower()} already {inStr} {entityTerm(entityList.Count, false).ToLower()})."; + else + if (notActionedOn == 0) + return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {entityTerm(actionedOn.Count, false).ToLower()}."; + else + return $"{Emojis.Success} {entityTerm(actionedOn.Count, true)} {opStr} {actionedOn.Count} {entityTerm(actionedOn.Count, false).ToLower()} ({memberNotActionedPosStr}{entityTerm(actionedOn.Count, true).ToLower()} already {inStr} {groupNotActionedPosStr}{entityTerm(notActionedOn, false).ToLower()})."; + } + public static bool IsOurProblem(this Exception e) { // This function filters out sporadic errors out of our control from being reported to Sentry @@ -44,7 +76,7 @@ namespace PluralKit.Bot if (e is WebhookExecutionErrorOnDiscordsEnd) return false; // Socket errors are *not our problem* - if (e is SocketException) return false; + if (e.GetBaseException() is SocketException) return false; // Tasks being cancelled for whatver reason are, you guessed it, also not our problem. if (e is TaskCanceledException) return false; diff --git a/PluralKit.Core/Database/Database.cs b/PluralKit.Core/Database/Database.cs index a0436a23..72a8591e 100644 --- a/PluralKit.Core/Database/Database.cs +++ b/PluralKit.Core/Database/Database.cs @@ -19,7 +19,7 @@ namespace PluralKit.Core internal class Database: IDatabase { private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files - private const int TargetSchemaVersion = 11; + private const int TargetSchemaVersion = 12; private readonly CoreConfig _config; private readonly ILogger _logger; diff --git a/PluralKit.Core/Database/Functions/MessageContext.cs b/PluralKit.Core/Database/Functions/MessageContext.cs index febafdaa..a3a6444f 100644 --- a/PluralKit.Core/Database/Functions/MessageContext.cs +++ b/PluralKit.Core/Database/Functions/MessageContext.cs @@ -24,5 +24,7 @@ namespace PluralKit.Core public Instant? LastSwitchTimestamp { get; } public string? SystemTag { get; } public string? SystemAvatar { get; } + public bool AllowAutoproxy { get; } + public int? LatchTimeout { get; } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Functions/ProxyMember.cs b/PluralKit.Core/Database/Functions/ProxyMember.cs index 96becdde..fc18a582 100644 --- a/PluralKit.Core/Database/Functions/ProxyMember.cs +++ b/PluralKit.Core/Database/Functions/ProxyMember.cs @@ -19,6 +19,8 @@ namespace PluralKit.Core public string? ServerAvatar { get; } public string? Avatar { get; } + public bool AllowAutoproxy { get; } + public string ProxyName(MessageContext ctx) => ctx.SystemTag != null ? $"{ServerName ?? DisplayName ?? Name} {ctx.SystemTag}" : ServerName ?? DisplayName ?? Name; diff --git a/PluralKit.Core/Database/Functions/functions.sql b/PluralKit.Core/Database/Functions/functions.sql index 227450ab..f959f447 100644 --- a/PluralKit.Core/Database/Functions/functions.sql +++ b/PluralKit.Core/Database/Functions/functions.sql @@ -14,12 +14,14 @@ last_switch_members int[], last_switch_timestamp timestamp, system_tag text, - system_avatar text + system_avatar text, + allow_autoproxy bool, + latch_timeout integer ) as $$ -- CTEs to query "static" (accessible only through args) data with - system as (select systems.* from accounts inner join systems on systems.id = accounts.system where accounts.uid = account_id), + system as (select systems.*, allow_autoproxy as account_autoproxy from accounts inner join systems on systems.id = accounts.system where accounts.uid = account_id), guild as (select * from servers where id = guild_id), last_message as (select * from messages where messages.guild = guild_id and messages.sender = account_id order by mid desc limit 1) select @@ -37,7 +39,9 @@ as $$ system_last_switch.members as last_switch_members, system_last_switch.timestamp as last_switch_timestamp, system.tag as system_tag, - system.avatar_url as system_avatar + system.avatar_url as system_avatar, + system.account_autoproxy as allow_autoproxy, + system.latch_timeout as latch_timeout -- 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 from (select 1) as _placeholder @@ -62,7 +66,9 @@ create function proxy_members(account_id bigint, guild_id bigint) name text, server_avatar text, - avatar text + avatar text, + + allow_autoproxy bool ) as $$ select @@ -78,7 +84,9 @@ as $$ -- Avatar info member_guild.avatar_url as server_avatar, - members.avatar_url as avatar + members.avatar_url as avatar, + + members.allow_autoproxy as allow_autoproxy from accounts inner join systems on systems.id = accounts.system inner join members on members.system = systems.id diff --git a/PluralKit.Core/Database/Migrations/12.sql b/PluralKit.Core/Database/Migrations/12.sql new file mode 100644 index 00000000..54bf8397 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/12.sql @@ -0,0 +1,10 @@ +-- SCHEMA VERSION 12: 2020-12-08 -- +-- Add disabling front/latch autoproxy per-member -- +-- Add disabling autoproxy per-account -- +-- Add configurable latch timeout -- + +alter table members add column allow_autoproxy bool not null default true; +alter table accounts add column allow_autoproxy bool not null default true; +alter table systems add column latch_timeout int; -- in seconds + +update info set schema_version = 12; \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Account.cs b/PluralKit.Core/Database/Repository/ModelRepository.Account.cs new file mode 100644 index 00000000..98682b0b --- /dev/null +++ b/PluralKit.Core/Database/Repository/ModelRepository.Account.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Data; +using System.Threading.Tasks; + +using Dapper; + +namespace PluralKit.Core +{ + public partial class ModelRepository + { + public async Task UpdateAccount(IPKConnection conn, ulong id, AccountPatch patch) + { + _logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch); + var (query, pms) = patch.Apply(UpdateQueryBuilder.Update("accounts", "uid = @uid")) + .WithConstant("uid", id) + .Build(); + await conn.ExecuteAsync(query, pms); + } + + } +} \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs index fdcc1e5a..7a88dba7 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs @@ -30,11 +30,6 @@ namespace PluralKit.Core return conn.QuerySingleOrDefaultAsync(query.ToString(), new {Id = id, PrivacyFilter = privacyFilter}); } - public IAsyncEnumerable GetMemberGroups(IPKConnection conn, MemberId id) => - conn.QueryStreamAsync( - "select groups.* from group_members inner join groups on group_members.group_id = groups.id where group_members.member_id = @Id", - new {Id = id}); - public async Task CreateGroup(IPKConnection conn, SystemId system, string name) { var group = await conn.QueryFirstAsync( diff --git a/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs new file mode 100644 index 00000000..7ac0d65c --- /dev/null +++ b/PluralKit.Core/Database/Repository/ModelRepository.GroupMember.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using Dapper; + +namespace PluralKit.Core +{ + public partial class ModelRepository + { + public IAsyncEnumerable GetMemberGroups(IPKConnection conn, MemberId id) => + conn.QueryStreamAsync( + "select groups.* from group_members inner join groups on group_members.group_id = groups.id where group_members.member_id = @Id", + new {Id = id}); + + + public async Task AddGroupsToMember(IPKConnection conn, MemberId member, IReadOnlyCollection groups) + { + await using var w = + conn.BeginBinaryImport("copy group_members (group_id, member_id) from stdin (format binary)"); + foreach (var group in groups) + { + await w.StartRowAsync(); + await w.WriteAsync(group.Value); + await w.WriteAsync(member.Value); + } + + await w.CompleteAsync(); + _logger.Information("Added member {MemberId} to groups {GroupIds}", member, groups); + } + + public Task RemoveGroupsFromMember(IPKConnection conn, MemberId member, IReadOnlyCollection groups) + { + _logger.Information("Removed groups from {MemberId}: {GroupIds}", member, groups); + return conn.ExecuteAsync("delete from group_members where member_id = @Member and group_id = any(@Groups)", + new {Member = @member, Groups = groups.ToArray() }); + } + + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKMember.cs b/PluralKit.Core/Models/PKMember.cs index 40a6ad8a..6976ff18 100644 --- a/PluralKit.Core/Models/PKMember.cs +++ b/PluralKit.Core/Models/PKMember.cs @@ -24,6 +24,7 @@ namespace PluralKit.Core { public bool KeepProxy { get; private set; } public Instant Created { get; private set; } public int MessageCount { get; private set; } + public bool AllowAutoproxy { get; private set; } public PrivacyLevel MemberVisibility { get; private set; } public PrivacyLevel DescriptionPrivacy { get; private set; } diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index 9962e75f..436f9cc9 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -18,6 +18,7 @@ namespace PluralKit.Core { public Instant Created { get; } public string UiTz { get; set; } public bool PingsEnabled { get; } + public int? LatchTimeout { get; } public PrivacyLevel DescriptionPrivacy { get; } public PrivacyLevel MemberListPrivacy { get;} public PrivacyLevel FrontPrivacy { get; } diff --git a/PluralKit.Core/Models/Patch/AccountPatch.cs b/PluralKit.Core/Models/Patch/AccountPatch.cs new file mode 100644 index 00000000..4614d4b8 --- /dev/null +++ b/PluralKit.Core/Models/Patch/AccountPatch.cs @@ -0,0 +1,10 @@ +namespace PluralKit.Core +{ + public class AccountPatch: PatchObject + { + public Partial AllowAutoproxy { get; set; } + + public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b + .With("allow_autoproxy", AllowAutoproxy); + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index 51a2b552..645e2b1a 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -16,6 +16,7 @@ namespace PluralKit.Core public Partial ProxyTags { get; set; } public Partial KeepProxy { get; set; } public Partial MessageCount { get; set; } + public Partial AllowAutoproxy { get; set; } public Partial Visibility { get; set; } public Partial NamePrivacy { get; set; } public Partial DescriptionPrivacy { get; set; } @@ -35,6 +36,7 @@ namespace PluralKit.Core .With("proxy_tags", ProxyTags) .With("keep_proxy", KeepProxy) .With("message_count", MessageCount) + .With("allow_autoproxy", AllowAutoproxy) .With("member_visibility", Visibility) .With("name_privacy", NamePrivacy) .With("description_privacy", DescriptionPrivacy) diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index d574e8e2..7c3a9551 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -15,6 +15,7 @@ namespace PluralKit.Core public Partial FrontPrivacy { get; set; } public Partial FrontHistoryPrivacy { get; set; } public Partial PingsEnabled { get; set; } + public Partial LatchTimeout { get; set; } public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b .With("name", Name) @@ -28,6 +29,7 @@ namespace PluralKit.Core .With("group_list_privacy", GroupListPrivacy) .With("front_privacy", FrontPrivacy) .With("front_history_privacy", FrontHistoryPrivacy) - .With("pings_enabled", PingsEnabled); + .With("pings_enabled", PingsEnabled) + .With("latch_timeout", LatchTimeout); } } \ No newline at end of file diff --git a/PluralKit.Core/Models/ProxyTag.cs b/PluralKit.Core/Models/ProxyTag.cs index c00a5e50..c433f0b5 100644 --- a/PluralKit.Core/Models/ProxyTag.cs +++ b/PluralKit.Core/Models/ProxyTag.cs @@ -16,7 +16,7 @@ namespace PluralKit.Core [JsonIgnore] public string ProxyString => $"{Prefix ?? ""}text{Suffix ?? ""}"; - public bool IsEmpty => Prefix == null && Suffix == null; + [JsonIgnore] public bool IsEmpty => Prefix == null && Suffix == null; public bool Equals(ProxyTag other) => Prefix == other.Prefix && Suffix == other.Suffix; @@ -31,4 +31,4 @@ namespace PluralKit.Core } } } -} \ No newline at end of file +} diff --git a/PluralKit.Core/Modules/ConfigModule.cs b/PluralKit.Core/Modules/ConfigModule.cs new file mode 100644 index 00000000..1ba7fa77 --- /dev/null +++ b/PluralKit.Core/Modules/ConfigModule.cs @@ -0,0 +1,28 @@ +using Autofac; + +using Microsoft.Extensions.Configuration; + +namespace PluralKit.Core +{ + public class ConfigModule: Module where T: new() + { + private readonly string _submodule; + + public ConfigModule(string submodule = null) + { + _submodule = submodule; + } + + protected override void Load(ContainerBuilder builder) + { + // We're assuming IConfiguration is already available somehow - it comes from various places (auto-injected in ASP, etc) + + // Register the CoreConfig and where to find it + builder.Register(c => c.Resolve().GetSection("PluralKit").Get() ?? new CoreConfig()).SingleInstance(); + + // Register the submodule config (BotConfig, etc) if specified + if (_submodule != null) + builder.Register(c => c.Resolve().GetSection("PluralKit").GetSection(_submodule).Get() ?? new T()).SingleInstance(); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Modules/DataStoreModule.cs b/PluralKit.Core/Modules/DataStoreModule.cs new file mode 100644 index 00000000..6060cbed --- /dev/null +++ b/PluralKit.Core/Modules/DataStoreModule.cs @@ -0,0 +1,19 @@ +using Autofac; +using Autofac.Extensions.DependencyInjection; + +using Microsoft.Extensions.DependencyInjection; + +namespace PluralKit.Core +{ + public class DataStoreModule: Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + + builder.Populate(new ServiceCollection().AddMemoryCache()); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Modules.cs b/PluralKit.Core/Modules/LoggingModule.cs similarity index 70% rename from PluralKit.Core/Modules.cs rename to PluralKit.Core/Modules/LoggingModule.cs index 08dffd8c..fd52293f 100644 --- a/PluralKit.Core/Modules.cs +++ b/PluralKit.Core/Modules/LoggingModule.cs @@ -1,13 +1,7 @@ -using System; +using System; using System.Globalization; -using App.Metrics; - using Autofac; -using Autofac.Extensions.DependencyInjection; - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using NodaTime; @@ -19,66 +13,6 @@ using Serilog.Sinks.SystemConsole.Themes; namespace PluralKit.Core { - public class DataStoreModule: Module - { - protected override void Load(ContainerBuilder builder) - { - builder.RegisterType().SingleInstance(); - builder.RegisterType().As().SingleInstance(); - builder.RegisterType().AsSelf().SingleInstance(); - - builder.Populate(new ServiceCollection().AddMemoryCache()); - } - } - - public class ConfigModule: Module where T: new() - { - private readonly string _submodule; - - public ConfigModule(string submodule = null) - { - _submodule = submodule; - } - - protected override void Load(ContainerBuilder builder) - { - // We're assuming IConfiguration is already available somehow - it comes from various places (auto-injected in ASP, etc) - - // Register the CoreConfig and where to find it - builder.Register(c => c.Resolve().GetSection("PluralKit").Get() ?? new CoreConfig()).SingleInstance(); - - // Register the submodule config (BotConfig, etc) if specified - if (_submodule != null) - builder.Register(c => c.Resolve().GetSection("PluralKit").GetSection(_submodule).Get() ?? new T()).SingleInstance(); - } - } - - public class MetricsModule: Module - { - private readonly string _onlyContext; - - public MetricsModule(string onlyContext = null) - { - _onlyContext = onlyContext; - } - - protected override void Load(ContainerBuilder builder) - { - builder.Register(c => InitMetrics(c.Resolve())) - .AsSelf().As().SingleInstance(); - } - - private IMetricsRoot InitMetrics(CoreConfig config) - { - var builder = AppMetrics.CreateDefaultBuilder(); - if (config.InfluxUrl != null && config.InfluxDb != null) - builder.Report.ToInfluxDb(config.InfluxUrl, config.InfluxDb); - if (_onlyContext != null) - builder.Filter.ByIncludingOnlyContext(_onlyContext); - return builder.Build(); - } - } - public class LoggingModule: Module { private readonly string _component; @@ -176,7 +110,7 @@ namespace PluralKit.Core return Log.Logger = logCfg.CreateLogger(); } } - + // Serilog why is this necessary for such a simple thing >.> public class UTCTimestampFormatProvider: IFormatProvider { diff --git a/PluralKit.Core/Modules/MetricsModule.cs b/PluralKit.Core/Modules/MetricsModule.cs new file mode 100644 index 00000000..cb650b3f --- /dev/null +++ b/PluralKit.Core/Modules/MetricsModule.cs @@ -0,0 +1,32 @@ +using App.Metrics; + +using Autofac; + +namespace PluralKit.Core +{ + public class MetricsModule: Module + { + private readonly string _onlyContext; + + public MetricsModule(string onlyContext = null) + { + _onlyContext = onlyContext; + } + + protected override void Load(ContainerBuilder builder) + { + builder.Register(c => InitMetrics(c.Resolve())) + .AsSelf().As().SingleInstance(); + } + + private IMetricsRoot InitMetrics(CoreConfig config) + { + var builder = AppMetrics.CreateDefaultBuilder(); + if (config.InfluxUrl != null && config.InfluxDb != null) + builder.Report.ToInfluxDb(config.InfluxUrl, config.InfluxDb); + if (_onlyContext != null) + builder.Filter.ByIncludingOnlyContext(_onlyContext); + return builder.Build(); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/PluralKit.Core.csproj b/PluralKit.Core/PluralKit.Core.csproj index 843b0743..d31fd3fe 100644 --- a/PluralKit.Core/PluralKit.Core.csproj +++ b/PluralKit.Core/PluralKit.Core.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net5.0 diff --git a/PluralKit.Core/Utils/Emojis.cs b/PluralKit.Core/Utils/Emojis.cs index 21acf293..4382cc50 100644 --- a/PluralKit.Core/Utils/Emojis.cs +++ b/PluralKit.Core/Utils/Emojis.cs @@ -7,5 +7,7 @@ public static readonly string ThumbsUp = "\U0001f44d"; public static readonly string RedQuestion = "\u2753"; public static readonly string Bell = "\U0001F514"; + public static readonly string Image = "\U0001F5BC"; + public static readonly string Paperclip = "\U0001F4CE"; } } \ No newline at end of file diff --git a/PluralKit.Tests/PluralKit.Tests.csproj b/PluralKit.Tests/PluralKit.Tests.csproj index 17e0b541..7970b7a9 100644 --- a/PluralKit.Tests/PluralKit.Tests.csproj +++ b/PluralKit.Tests/PluralKit.Tests.csproj @@ -1,7 +1,7 @@ - netcoreapp3.1 + net5.0 false @@ -26,4 +26,8 @@ + + + + diff --git a/README.md b/README.md index 65b03194..cf2935bc 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # PluralKit PluralKit is a Discord bot meant for plural communities. It has features like message proxying through webhooks, switch tracking, system and member profiles, and more. -**Do you just want to add PluralKit to your server? If so, you don't need any of this. Use the bot's invite link: https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904** +**Do you just want to add PluralKit to your server? If so, you don't need any of this. Use the bot's invite link: https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904** PluralKit has a Discord server for support, feedback, and discussion: https://discord.gg/PczBt78 # Requirements -Running the bot requires [.NET Core](https://dotnet.microsoft.com/download) (v3.1) and a PostgreSQL database. It should function on any system where the prerequisites are set up (including Windows). +Running the bot requires [.NET 5](https://dotnet.microsoft.com/download) and a PostgreSQL database. It should function on any system where the prerequisites are set up (including Windows). Optionally, it can integrate with [Sentry](https://sentry.io/welcome/) for error reporting and [InfluxDB](https://www.influxdata.com/products/influxdb-overview/) for aggregate statistics. @@ -47,7 +47,7 @@ $ docker-compose up -d ``` ## Manually -* Install the .NET Core 3.1 SDK (see https://dotnet.microsoft.com/download) +* Install the .NET 5 SDK (see https://dotnet.microsoft.com/download) * Clone this repository: `git clone https://github.com/xSke/PluralKit` * Create and fill in a `pluralkit.conf` file in the same directory as `docker-compose.yml` * Run the bot: `dotnet run --project PluralKit.Bot` diff --git a/docs/content/.vuepress/config.js b/docs/content/.vuepress/config.js index abd2de20..d36c3133 100644 --- a/docs/content/.vuepress/config.js +++ b/docs/content/.vuepress/config.js @@ -1,5 +1,6 @@ module.exports = { title: 'PluralKit', + theme: 'default-prefers-color-scheme', base: "/", head: [ @@ -27,11 +28,11 @@ module.exports = { prevLinks: true, nav: [ { text: "Support server", link: "https://discord.gg/PczBt78" }, - { text: "Invite bot", link: "https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904" } + { text: "Invite bot", link: "https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904" } ], sidebar: [ "/", - ["https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904", "Add to your server"], + ["https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904", "Add to your server"], { title: "Documentation", collapsable: false, diff --git a/docs/content/api-documentation.md b/docs/content/api-documentation.md index 4ef72535..5fefb03b 100644 --- a/docs/content/api-documentation.md +++ b/docs/content/api-documentation.md @@ -12,6 +12,11 @@ Accompanying it is an [OpenAPI v3.0 definition](https://github.com/xSke/PluralKi PluralKit has a basic HTTP REST API for querying and modifying your system. The root endpoint of the API is `https://api.pluralkit.me/v1/`. +#### Authorization header token example +``` +Authorization: z865MC7JNhLtZuSq1NXQYVe+FgZJHBfeBCXOPYYRwH4liDCDrsd7zdOuR45mX257 +``` + Endpoints will always return all fields, using `null` when a value is missing. On `PATCH` endpoints, missing fields from the JSON request will be ignored and preserved as is, but on `POST` endpoints will be set to `null` or cleared. diff --git a/docs/content/command-list.md b/docs/content/command-list.md index 782af0dd..93ba23b9 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -49,12 +49,12 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;member proxy [tags]` - Changes the proxy tags of a member. use below add/remove commands for members with multiple tag pairs. - `pk;member proxy add [tags]` - Adds a proxy tag pair to a member. - `pk;member proxy remove [tags]` - Removes a proxy tag from a member. +- `pk;member autoproxy [on|off]` - Sets whether a member will be autoproxied when autoproxy is set to latch or front mode. - `pk;member keepproxy [on|off]` - Sets whether to include a member's proxy tags in the proxied message. - `pk;member pronouns [pronouns]` - Changes the pronouns of a member. - `pk;member color [color]` - Changes the color of a member. - `pk;member birthdate [birthdate]` - Changes the birthday of a member. - `pk;member delete` - Deletes a member. -- `pk;random` - Shows the member card of a randomly selected member in your system. ## Group commands *Replace `` with a group's name, 5-character ID or display name. For most commands, adding `-clear` will clear/delete the field.* @@ -62,6 +62,7 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;group new ` - Creates a new group. - `pk;group list` - Lists all groups in your system. - `pk;group list` - Lists all members in a group. +- `pk;group random` - Shows the info card of a randomly selected member in a group. - `pk;group rename ` - Renames a group. - `pk;group displayname [display name]` - Shows or changes a group's display name. - `pk;group description [description]` - Shows or changes a group's description. @@ -75,12 +76,18 @@ Words in **\** or **[square brackets]** mean fill-in-the-blank. - `pk;switch [member...]` - Registers a switch with the given members. - `pk;switch move