Merge pull request #1 from xSke/main

Update Repository
This commit is contained in:
Tiefseetauchner 2021-02-03 10:37:00 +01:00 committed by GitHub
commit f9c5b8682b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 979 additions and 274 deletions

View file

@ -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

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<!-- This enables XML generation for Swashbuckle -->

View file

@ -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<List<PKGroup>> ParseGroupList(this Context ctx, SystemId? restrictToSystem)
{
var groups = new List<PKGroup>();
// 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;
}
}
}

View file

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

View file

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

View file

@ -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 <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 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 [<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 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");
@ -39,11 +41,15 @@ namespace PluralKit.Bot
public static Command MemberProxy = new Command("member proxy", "member <member> proxy [add|remove] [example proxy]", "Changes, adds, or removes a member's proxy tags");
public static Command MemberDelete = new Command("member delete", "member <member> delete", "Deletes a member");
public static Command MemberAvatar = new Command("member avatar", "member <member> avatar [url|@mention]", "Changes a member's avatar");
public static Command MemberGroups = new Command("member group", "member <member> group", "Shows the groups a member is in");
public static Command MemberGroupAdd = new Command("member group", "member <member> group add <group> [group 2] [group 3...]", "Adds a member to one or more groups");
public static Command MemberGroupRemove = new Command("member group", "member <member> group remove <group> [group 2] [group 3...]", "Removes a member from one or more groups");
public static Command MemberServerAvatar = new Command("member serveravatar", "member <member> serveravatar [url|@mention]", "Changes a member's avatar in the current server");
public static Command MemberDisplayName = new Command("member displayname", "member <member> displayname [display name]", "Changes a member's display name");
public static Command MemberServerName = new Command("member servername", "member <member> servername [server name]", "Changes a member's display name in the current server");
public static Command MemberAutoproxy = new Command("member autoproxy", "member <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 <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 <member> privacy <name|description|birthday|pronouns|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
public static Command GroupInfo = new Command("group", "group <name>", "Looks up information about a group");
public static Command GroupNew = new Command("group new", "group new <name>", "Creates a new group");
@ -57,10 +63,13 @@ namespace PluralKit.Bot
public static Command GroupPrivacy = new Command("group privacy", "group <group> privacy <description|icon|visibility|all> <public|private>", "Changes a group's privacy settings");
public static Command GroupIcon = new Command("group icon", "group <group> icon [url|@mention]", "Changes a group's icon");
public static Command GroupDelete = new Command("group delete", "group <group> delete", "Deletes a group");
public static Command GroupMemberRandom = new Command("group random", "group <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> [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 <date/time>", "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 <account>", "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 <id|link>", "Looks up a proxied message");
public static Command LogChannel = new Command("log channel", "log channel <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> [channel 2] [channel 3...]", "Enables message logging in certain channels");
public static Command LogDisable = new Command("log disable", "log disable all|<channel> [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>(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>(SystemList, m => m.MemberList(ctx, ctx.System));
if (ctx.Match("f", "find", "search", "query", "fd"))
return ctx.Execute<SystemList>(SystemFind, m => m.MemberList(ctx, ctx.System));
if (ctx.Match("link"))
return ctx.Execute<SystemLink>(Link, m => m.LinkSystem(ctx));
if (ctx.Match("unlink"))
@ -152,8 +166,6 @@ namespace PluralKit.Bot
else return ctx.Execute<Help>(Help, m => m.HelpRoot(ctx));
if (ctx.Match("explain"))
return ctx.Execute<Help>(Explain, m => m.Explain(ctx));
if (ctx.Match("commands"))
return ctx.Reply("For the list of commands, see the website: <https://pluralkit.me/commands>");
if (ctx.Match("message", "msg"))
return ctx.Execute<Misc>(Message, m => m.GetMessage(ctx));
if (ctx.Match("log"))
@ -163,6 +175,8 @@ namespace PluralKit.Bot
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true));
else if (ctx.Match("disable", "off"))
return ctx.Execute<ServerConfig>(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<ServerConfig>(LogClean, m => m.SetLogCleanup(ctx));
@ -173,7 +187,9 @@ namespace PluralKit.Bot
return ctx.Execute<ServerConfig>(BlacklistRemove, m => m.SetBlacklisted(ctx, false));
else if (ctx.Match("list", "show"))
return ctx.Execute<ServerConfig>(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<SystemEdit>(SystemProxy, m => m.SystemProxy(ctx));
if (ctx.Match("invite")) return ctx.Execute<Misc>(Invite, m => m.Invite(ctx));
@ -187,7 +203,10 @@ namespace PluralKit.Bot
if (ctx.Match("permcheck"))
return ctx.Execute<Misc>(PermCheck, m => m.PermCheckGuild(ctx));
if (ctx.Match("random", "r"))
return ctx.Execute<Member>(MemberRandom, m => m.MemberRandom(ctx));
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
return ctx.Execute<Random>(GroupRandom, r => r.Group(ctx));
else
return ctx.Execute<Random>(MemberRandom, m => m.Member(ctx));
// remove compiler warning
return ctx.Reply(
@ -322,12 +341,21 @@ namespace PluralKit.Bot
await ctx.Execute<MemberEdit>(MemberDelete, m => m.Delete(ctx, target));
else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic"))
await ctx.Execute<MemberAvatar>(MemberAvatar, m => m.Avatar(ctx, target));
else if (ctx.Match("group", "groups"))
if (ctx.Match("add", "a"))
await ctx.Execute<MemberGroup>(MemberGroupAdd, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem"))
await ctx.Execute<MemberGroup>(MemberGroupRemove, m => m.AddRemove(ctx, target, Groups.AddRemoveOperation.Remove));
else
await ctx.Execute<MemberGroup>(MemberGroups, m => m.List(ctx, target));
else if (ctx.Match("serveravatar", "servericon", "serverimage", "serverpfp", "serverpic", "savatar", "spic", "guildavatar", "guildpic", "guildicon", "sicon"))
await ctx.Execute<MemberAvatar>(MemberServerAvatar, m => m.ServerAvatar(ctx, target));
else if (ctx.Match("displayname", "dn", "dname", "nick", "nickname", "dispname"))
await ctx.Execute<MemberEdit>(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<MemberEdit>(MemberServerName, m => m.ServerName(ctx, target));
else if (ctx.Match("autoproxy", "ap"))
await ctx.Execute<MemberEdit>(MemberAutoproxy, m => m.MemberAutoproxy(ctx, target));
else if (ctx.Match("keepproxy", "keeptags", "showtags"))
await ctx.Execute<MemberEdit>(MemberKeepProxy, m => m.KeepProxy(ctx, target));
else if (ctx.Match("privacy"))
@ -336,6 +364,8 @@ namespace PluralKit.Bot
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("public", "shown", "show"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("soulscream"))
await ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx, target));
else if (!ctx.HasNext()) // Bare command
await ctx.Execute<Member>(MemberInfo, m => m.ViewMember(ctx, target));
else
@ -366,6 +396,8 @@ namespace PluralKit.Bot
await ctx.Execute<Groups>(GroupRemove, g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove));
else if (ctx.Match("members", "list", "ms", "l"))
await ctx.Execute<Groups>(GroupMemberList, g => g.ListGroupMembers(ctx, target));
else if (ctx.Match("random"))
await ctx.Execute<Random>(GroupMemberRandom, r => r.GroupMember(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<Groups>(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: <https://pluralkit.me/commands>");
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: <https://pluralkit.me/commands>");
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<Autoproxy>(AutoproxyAccount, m => m.AutoproxyAccount(ctx));
else if (ctx.Match("timeout", "tm"))
return ctx.Execute<Autoproxy>(AutoproxyTimeout, m => m.AutoproxyTimeout(ctx));
else
return ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx));
}
private async Task PrintCommandNotFoundError(Context ctx, params Command[] potentialCommands)
{
var commandListStr = CreatePotentialCommandList(potentialCommands);

View file

@ -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 <member>`!", 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<MemberId> 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<MemberId>(members, toAction, op));
}
public async Task ListGroupMembers(Context ctx, PKGroup target)

View file

@ -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<string>();
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());
}
}
}

View file

@ -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)
{

View file

@ -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<string>.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<string>.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<string>.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<LocalDate?>.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<string>.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<bool>.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;

View file

@ -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<GroupId> 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<GroupId>(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> [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> [group 2] [group 3...]`";
}
await ctx.Reply(msg, embed: (new DiscordEmbedBuilder().WithTitle($"{target.Name}'s groups").WithDescription(description)).Build());
}
}
}

View file

@ -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)
{

View file

@ -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}>");
}

View file

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

View file

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

View file

@ -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\".");

View file

@ -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)
{

View file

@ -40,8 +40,10 @@ namespace PluralKit.Bot
builder.RegisterType<Member>().AsSelf();
builder.RegisterType<MemberAvatar>().AsSelf();
builder.RegisterType<MemberEdit>().AsSelf();
builder.RegisterType<MemberGroup>().AsSelf();
builder.RegisterType<MemberProxy>().AsSelf();
builder.RegisterType<Misc>().AsSelf();
builder.RegisterType<Random>().AsSelf();
builder.RegisterType<ServerConfig>().AsSelf();
builder.RegisterType<Switch>().AsSelf();
builder.RegisterType<System>().AsSelf();

View file

@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">

View file

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

View file

@ -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<DiscordEmbed>();
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<DiscordMessage> 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);
}
}
}

View file

@ -157,6 +157,42 @@ namespace PluralKit.Bot {
return eb.Build();
}
public async Task<DiscordEmbed> 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 <member>`!", 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<DiscordEmbed> CreateFronterEmbed(PKSwitch sw, DateTimeZone zone, LookupContext ctx)
{
var members = await _db.Execute(c => _repo.GetSwitchMembers(c, sw.Id).ToListAsync().AsTask());

View file

@ -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<ulong, LoggerBot> _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<string, LoggerBot> _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
{

View file

@ -42,13 +42,13 @@ namespace PluralKit.Bot
_logger = logger.ForContext<WebhookExecutorService>();
}
public async Task<DiscordMessage> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> attachments, bool allowEveryone)
public async Task<DiscordMessage> ExecuteWebhook(DiscordChannel channel, string name, string avatarUrl, string content, IReadOnlyList<DiscordAttachment> attachments, IReadOnlyList<DiscordEmbed> 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<DiscordMessage> ExecuteWebhookInner(DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
IReadOnlyList<DiscordAttachment> attachments, bool allowEveryone, bool hasRetried = false)
private async Task<DiscordMessage> ExecuteWebhookInner(
DiscordChannel channel, DiscordWebhook webhook, string name, string avatarUrl, string content,
IReadOnlyList<DiscordAttachment> attachments, IReadOnlyList<DiscordEmbed> 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;

View file

@ -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[]
{

View file

@ -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<T>(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<T>(List<T> entityList, List<T> 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<T>(notActionedOn, true)} not {opStr} {entityTerm<T>(entityList.Count, false).ToLower()} ({entityTerm<T>(notActionedOn, true).ToLower()} already {inStr} {entityTerm<T>(entityList.Count, false).ToLower()}).";
else
if (notActionedOn == 0)
return $"{Emojis.Success} {entityTerm<T>(actionedOn.Count, true)} {opStr} {entityTerm<T>(actionedOn.Count, false).ToLower()}.";
else
return $"{Emojis.Success} {entityTerm<T>(actionedOn.Count, true)} {opStr} {actionedOn.Count} {entityTerm<T>(actionedOn.Count, false).ToLower()} ({memberNotActionedPosStr}{entityTerm<T>(actionedOn.Count, true).ToLower()} already {inStr} {groupNotActionedPosStr}{entityTerm<T>(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;

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

View file

@ -30,11 +30,6 @@ namespace PluralKit.Core
return conn.QuerySingleOrDefaultAsync<int>(query.ToString(), new {Id = id, PrivacyFilter = privacyFilter});
}
public IAsyncEnumerable<PKGroup> GetMemberGroups(IPKConnection conn, MemberId id) =>
conn.QueryStreamAsync<PKGroup>(
"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<PKGroup> CreateGroup(IPKConnection conn, SystemId system, string name)
{
var group = await conn.QueryFirstAsync<PKGroup>(

View file

@ -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<PKGroup> GetMemberGroups(IPKConnection conn, MemberId id) =>
conn.QueryStreamAsync<PKGroup>(
"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<GroupId> 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<GroupId> 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() });
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,10 @@
namespace PluralKit.Core
{
public class AccountPatch: PatchObject
{
public Partial<bool> AllowAutoproxy { get; set; }
public override UpdateQueryBuilder Apply(UpdateQueryBuilder b) => b
.With("allow_autoproxy", AllowAutoproxy);
}
}

View file

@ -16,6 +16,7 @@ namespace PluralKit.Core
public Partial<ProxyTag[]> ProxyTags { get; set; }
public Partial<bool> KeepProxy { get; set; }
public Partial<int> MessageCount { get; set; }
public Partial<bool> AllowAutoproxy { get; set; }
public Partial<PrivacyLevel> Visibility { get; set; }
public Partial<PrivacyLevel> NamePrivacy { get; set; }
public Partial<PrivacyLevel> 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)

View file

@ -15,6 +15,7 @@ namespace PluralKit.Core
public Partial<PrivacyLevel> FrontPrivacy { get; set; }
public Partial<PrivacyLevel> FrontHistoryPrivacy { get; set; }
public Partial<bool> PingsEnabled { get; set; }
public Partial<int?> 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);
}
}

View file

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

View file

@ -0,0 +1,28 @@
using Autofac;
using Microsoft.Extensions.Configuration;
namespace PluralKit.Core
{
public class ConfigModule<T>: 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<IConfiguration>().GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()).SingleInstance();
// Register the submodule config (BotConfig, etc) if specified
if (_submodule != null)
builder.Register(c => c.Resolve<IConfiguration>().GetSection("PluralKit").GetSection(_submodule).Get<T>() ?? new T()).SingleInstance();
}
}
}

View file

@ -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<DbConnectionCountHolder>().SingleInstance();
builder.RegisterType<Database>().As<IDatabase>().SingleInstance();
builder.RegisterType<ModelRepository>().AsSelf().SingleInstance();
builder.Populate(new ServiceCollection().AddMemoryCache());
}
}
}

View file

@ -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<DbConnectionCountHolder>().SingleInstance();
builder.RegisterType<Database>().As<IDatabase>().SingleInstance();
builder.RegisterType<ModelRepository>().AsSelf().SingleInstance();
builder.Populate(new ServiceCollection().AddMemoryCache());
}
}
public class ConfigModule<T>: 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<IConfiguration>().GetSection("PluralKit").Get<CoreConfig>() ?? new CoreConfig()).SingleInstance();
// Register the submodule config (BotConfig, etc) if specified
if (_submodule != null)
builder.Register(c => c.Resolve<IConfiguration>().GetSection("PluralKit").GetSection(_submodule).Get<T>() ?? 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<CoreConfig>()))
.AsSelf().As<IMetrics>().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
{

View file

@ -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<CoreConfig>()))
.AsSelf().As<IMetrics>().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();
}
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">

View file

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

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
@ -26,4 +26,8 @@
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="API" />
</ItemGroup>
</Project>

View file

@ -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`

View file

@ -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,

View file

@ -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.

View file

@ -49,12 +49,12 @@ Words in **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
- `pk;member <name> proxy [tags]` - Changes the proxy tags of a member. use below add/remove commands for members with multiple tag pairs.
- `pk;member <name> proxy add [tags]` - Adds a proxy tag pair to a member.
- `pk;member <name> proxy remove [tags]` - Removes a proxy tag from a member.
- `pk;member <name> autoproxy [on|off]` - Sets whether a member will be autoproxied when autoproxy is set to latch or front mode.
- `pk;member <name> keepproxy [on|off]` - Sets whether to include a member's proxy tags in the proxied message.
- `pk;member <name> pronouns [pronouns]` - Changes the pronouns of a member.
- `pk;member <name> color [color]` - Changes the color of a member.
- `pk;member <name> birthdate [birthdate]` - Changes the birthday of a member.
- `pk;member <name> delete` - Deletes a member.
- `pk;random` - Shows the member card of a randomly selected member in your system.
## Group commands
*Replace `<name>` 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 **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
- `pk;group new <name>` - Creates a new group.
- `pk;group list` - Lists all groups in your system.
- `pk;group <group> list` - Lists all members in a group.
- `pk;group <group> random` - Shows the info card of a randomly selected member in a group.
- `pk;group <group> rename <new name>` - Renames a group.
- `pk;group <group> displayname [display name]` - Shows or changes a group's display name.
- `pk;group <group> description [description]` - Shows or changes a group's description.
@ -75,12 +76,18 @@ Words in **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
- `pk;switch [member...]` - Registers a switch with the given members.
- `pk;switch move <time>` - Moves the latest switch backwards in time.
- `pk;switch delete` - Deletes the latest switch.
- `pk;switch delete all` - Deletes every logged switch.
- `pk;switch delete all` - Deletes all logged switches.
- `pk;switch out` - Registers a 'switch-out' - a switch with no associated members.
## Autoproxy commands
- `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.
## Server owner commands
*(all commands here require Manage Server permission)*
- `pk;log channel <channel>` - Sets the given channel to log all proxied messages.
- `pk;log channel -clear` - Clears the currently set log channel.
- `pk;log disable <#channel> [#channel...]` - Disables logging messages posted in the given channel(s) (useful for staff channels and such).
- `pk;log enable <#channel> [#channel...]` - Re-enables logging messages posted in the given channel(s).
- `pk;logclean <on/off>` - Enables or disables [log cleanup](./staff/compatibility.md#log-cleanup).
@ -88,6 +95,7 @@ Words in **\<angle brackets>** or **[square brackets]** mean fill-in-the-blank.
- `pk;blacklist remove <#channel> [#channel...]` - Removes the given channel(s) from the proxy blacklist.
## Utility
- `pk;random [-group]` - Shows the info card of a randomly selected member [or group] in your system.
- `pk;message <message id / message link>` - Looks up information about a proxied message by its message ID or link.
- `pk;invite` - Sends the bot invite link for PluralKit.
- `pk;import` - Imports a data file from PluralKit or Tupperbox.

View file

@ -11,4 +11,4 @@ This bot detects messages with certain tags associated with a profile, then repl
#### for example...
![demonstration of PluralKit](./assets/demo.gif)
For more information, see the links to the left, or click [here](https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot&permissions=536995904) to invite the bot to your server!
For more information, see the links to the left, or click [here](https://discord.com/oauth2/authorize?client_id=466378653216014359&scope=bot%20applications.commands&permissions=536995904) to invite the bot to your server!

View file

@ -1,9 +1,9 @@
# Proxy logging
If you want to log every proxied message to a separate channel for moderation purposes, you can use the `pk;log` command with the channel name.For example:
If you want to log every proxied message to a separate channel for moderation purposes, you can use the `pk;log channel` command with the channel name.For example:
pk;log #proxy-log
pk;log channel #proxy-log
This requires you to have the *Manage Server* permission on the server. To disable logging, use the `pk;log` command with no channel name.
This requires you to have the *Manage Server* permission on the server. To disable logging, use the `pk;log channel` command with the `-clear` flag as the channel name.
Log messages have the following format:

View file

@ -315,17 +315,23 @@ Since the messages will be posted by PluralKit's webhook, there's no way to dele
To delete a PluralKit-proxied message, you can react to it with the :x: emoji. Note that this only works if the message has
been sent from your own account.
### Autoproxying
## Autoproxy
The bot's *autoproxy* feature allows you to have messages be proxied without directly including the proxy tags. Autoproxy can be set up in various ways. There are three autoproxy modes currently implemented:
To see your system's current autoproxy settings, simply use the command:
pk;autoproxy
To disable autoproxying for the current server, use the command:
pk;autoproxy off
*(hint: `pk;autoproxy` can be shortened to `pk;ap` in all related commands)*
::: tip
To disable autoproxy for a single message, add a backslash (`\`) to the beginning of your message.
:::
#### Front mode
This autoproxy mode will proxy messages as the current *first* fronter of the system. If you register a switch with `Alice` and `Bob`, messages without proxy tags will be autoproxied as `Alice`.
To enable front-mode autoproxying for a given server, use the following command:
@ -347,6 +353,47 @@ To enable member-mode autoproxying for a given server, use the following command
pk;autoproxy <member>
### Changing the latch timeout duration
By default, latch mode times out after 6 hours. It is possible to change this:
pk;autoproxy timeout <new duration>
To reset the duration, use the following command:
pk;autoproxy timeout reset
To disable timeout (never timeout), use the following command:
pk;autoproxy timeout disable
### Disabling front/latch autoproxy on a per-member basis
If a system uses front or latch mode autoproxy, but one member prefers to send messages through the account (and not proxy), you can disable the front and latch modes for that specific member.
pk;member <name> autoproxy off
To re-enable front / latch modes for that member, use the following command:
pk;member <name> autoproxy on
This will *not* disable member mode autoproxy. If you do not wish to autoproxy, please turn off autoproxy instead of setting autoproxy to a specific member.
### Disabling autoproxy per-account
It is possible to fully disable autoproxy for a certain account linked to your system. For example, you might want to do this if a specific member's name is shown on the account.
To disable autoproxy for the current account, use the following command:
pk;autoproxy account disable
To re-enable autoproxy for the current account, use the following command:
pk;autoproxy account enable
::: tip
This subcommand can also be run in DMs.
:::
## Managing switches
PluralKit allows you to log member switches through the bot.
Essentially, this means you can mark one or more members as *the current fronter(s)* for the duration until the next switch.

View file

@ -14,5 +14,8 @@
"vuepress": "^1.3.1",
"vuepress-plugin-clean-urls": "^1.1.1",
"vuepress-plugin-dehydrate": "^1.1.4"
},
"dependencies": {
"vuepress-theme-default-prefers-color-scheme": "^1.1.1"
}
}

View file

@ -2480,6 +2480,13 @@ css-parse@~2.0.0:
dependencies:
css "^2.0.0"
css-prefers-color-scheme@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4"
integrity sha512-MTu6+tMs9S3EUqzmqLXEcgNRbNkkD/TGFvowpeoWJn5Vfq7FMgsmRQs9X5NXAURiOBmOxm/lLjsDNXDE6k9bhg==
dependencies:
postcss "^7.0.5"
css-select-base-adapter@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz#3b2ff4972cc362ab88561507a95408a1432135d7"
@ -7478,6 +7485,13 @@ vuepress-plugin-smooth-scroll@^0.0.3:
dependencies:
smoothscroll-polyfill "^0.4.3"
vuepress-theme-default-prefers-color-scheme@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/vuepress-theme-default-prefers-color-scheme/-/vuepress-theme-default-prefers-color-scheme-1.1.1.tgz#11389abba0f1c15f2dbea724e80b60937bda70f8"
integrity sha512-aLWYuFRk5EFcE4bAGzokAoOD92T/daodnZnuZnzF46jOl/ZtYHFV83uwXlbBUerdQE/IAxgtfuYRELXY5sUIKA==
dependencies:
css-prefers-color-scheme "^3.1.1"
vuepress@^1.3.1:
version "1.5.2"
resolved "https://registry.yarnpkg.com/vuepress/-/vuepress-1.5.2.tgz#b79e84bfaade55ba3ddb59c3a937220913f0599b"