Merge branch 'PluralKit:main' into main

This commit is contained in:
Tiefseetauchner 2022-08-02 10:33:59 +02:00 committed by GitHub
commit 07959763ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
179 changed files with 8551 additions and 3544 deletions

View file

@ -11,8 +11,11 @@
!.git
!proto
!scripts/run-clustered.sh
!dashboard
!scheduled_tasks
# Re-exclude host build artifact directories
**/bin
**/obj
**/target
**/node_modules

View file

@ -1,14 +0,0 @@
name: "Update Beta Bot"
on:
push:
branches: [dev]
jobs:
update-bot:
runs-on: ubuntu-latest
steps:
- name: "Update Beta Bot"
uses: fjogeleit/http-request-action@master
with:
url: https://api-beta.pluralkit.me/v1/update
bearerToken: ${{ secrets.WATCHTOWER_TOKEN }}

34
.github/workflows/dashboard.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Build dashboard Docker image
on:
push:
branches: [main]
paths:
- 'dashboard/**'
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.CR_PAT }}
- uses: actions/checkout@v2
- run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- uses: docker/build-push-action@v2
with:
# https://github.com/docker/build-push-action/issues/378
context: .
file: dashboard/Dockerfile
push: true
tags: |
ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }}
ghcr.io/pluralkit/dashboard:${{ github.sha }}
ghcr.io/pluralkit/dashboard:latest
cache-from: type=registry,ref=ghcr.io/pluralkit/dashboard:${{ env.BRANCH_NAME }}
cache-to: type=inline

View file

@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'xSke/PluralKit'
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
with:
@ -22,8 +22,8 @@ jobs:
context: .
push: true
tags: |
ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }}
ghcr.io/xske/pluralkit:${{ github.sha }}
ghcr.io/xske/pluralkit:latest
cache-from: type=registry,ref=ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }}
ghcr.io/pluralkit/pluralkit:${{ env.BRANCH_NAME }}
ghcr.io/pluralkit/pluralkit:${{ github.sha }}
ghcr.io/pluralkit/pluralkit:latest
cache-from: type=registry,ref=ghcr.io/pluralkit/pluralkit:${{ env.BRANCH_NAME }}
cache-to: type=inline

View file

@ -4,15 +4,15 @@ on:
push:
branches: [main]
paths:
- 'gateway/'
- 'proto/'
- 'gateway/**'
- 'proto/**'
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'xSke/PluralKit'
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
with:
@ -25,7 +25,7 @@ jobs:
with:
# https://github.com/docker/build-push-action/issues/378
context: .
file: Dockerfile.gateway
file: gateway/Dockerfile
push: true
tags: |
ghcr.io/pluralkit/gateway:${{ env.BRANCH_NAME }}

33
.github/workflows/scheduled_tasks.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Build scheduled tasks runner Docker image
on:
push:
branches: [main]
paths:
- 'services/scheduled_tasks/**'
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
packages: write
if: github.repository == 'PluralKit/PluralKit'
steps:
- uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.CR_PAT }}
- uses: actions/checkout@v2
- run: echo "BRANCH_NAME=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- uses: docker/build-push-action@v2
with:
# https://github.com/docker/build-push-action/issues/378
context: services/scheduled_tasks/
push: true
tags: |
ghcr.io/pluralkit/scheduled_tasks:${{ env.BRANCH_NAME }}
ghcr.io/pluralkit/scheduled_tasks:${{ github.sha }}
ghcr.io/pluralkit/scheduled_tasks:latest
cache-from: type=registry,ref=ghcr.io/pluralkit/scheduledtasks:${{ env.BRANCH_NAME }}
cache-to: type=inline

1
.gitignore vendored
View file

@ -12,6 +12,7 @@ target/
tags/
.DS_Store
mono_crash*
.DotSettings
# Dependencies
node_modules/

View file

@ -8,7 +8,6 @@ COPY Myriad/Myriad.csproj /app/Myriad/
COPY PluralKit.API/PluralKit.API.csproj /app/PluralKit.API/
COPY PluralKit.Bot/PluralKit.Bot.csproj /app/PluralKit.Bot/
COPY PluralKit.Core/PluralKit.Core.csproj /app/PluralKit.Core/
COPY PluralKit.ScheduledTasks/PluralKit.ScheduledTasks.csproj /app/PluralKit.ScheduledTasks/
COPY PluralKit.Tests/PluralKit.Tests.csproj /app/PluralKit.Tests/
COPY .git/ /app/.git
COPY proto/ /app/proto
@ -20,7 +19,7 @@ RUN dotnet build -c Release -o bin
# Build runtime stage (doesn't include SDK)
FROM mcr.microsoft.com/dotnet/aspnet:6.0
LABEL org.opencontainers.image.source = "https://github.com/xSke/PluralKit"
LABEL org.opencontainers.image.source = "https://github.com/PluralKit/PluralKit"
WORKDIR /app
COPY --from=build /app ./

View file

@ -23,7 +23,7 @@
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.0" />
<PackageReference Include="Grpc.Tools" Version="2.37.0" PrivateAssets="All"/>
<PackageReference Include="Grpc.Tools" Version="2.47.0" PrivateAssets="all" />
<PackageReference Include="Polly" Version="7.2.1"/>
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1"/>
<PackageReference Include="Serilog" Version="2.10.0"/>

View file

@ -10,7 +10,7 @@ namespace Myriad.Rest;
public class DiscordApiClient
{
public const string UserAgent = "DiscordBot (https://github.com/xSke/PluralKit/tree/main/Myriad/, v1)";
public const string UserAgent = "DiscordBot (https://github.com/PluralKit/PluralKit/tree/main/Myriad/, v1)";
private const string DefaultApiBaseUrl = "https://discord.com/api/v10";
private readonly BaseRestClient _client;

View file

@ -22,8 +22,8 @@ public record Channel
public ulong? GuildId { get; init; }
public int? Position { get; init; }
public string? Name { get; init; }
public string? Topic { get; init; }
public bool? Nsfw { get; init; }
// public string? Topic { get; init; }
// public bool? Nsfw { get; init; }
public ulong? ParentId { get; init; }
public Overwrite[]? PermissionOverwrites { get; init; }
public User[]? Recipients { get; init; } // NOTE: this may be null for stub channel objects

View file

@ -3,7 +3,7 @@ namespace Myriad.Types;
public record MessageComponent
{
public ComponentType Type { get; init; }
public ButtonStyle? Style { get; init; }
public ButtonStyle? Style { get; set; }
public string? Label { get; init; }
public Emoji? Emoji { get; init; }
public string? CustomId { get; init; }

View file

@ -11,8 +11,8 @@ public record Embed
public EmbedFooter? Footer { get; init; }
public EmbedImage? Image { get; init; }
public EmbedThumbnail? Thumbnail { get; init; }
public EmbedVideo? Video { get; init; }
public EmbedProvider? Provider { get; init; }
// public EmbedVideo? Video { get; init; }
// public EmbedProvider? Provider { get; init; }
public EmbedAuthor? Author { get; init; }
public Field[]? Fields { get; init; }

View file

@ -4,5 +4,5 @@ public record Emoji
{
public ulong? Id { get; init; }
public string? Name { get; init; }
public bool? Animated { get; init; }
// public bool? Animated { get; init; }
}

View file

@ -12,19 +12,19 @@ public record Guild
{
public ulong Id { get; init; }
public string Name { get; init; }
public string? Icon { get; init; }
public string? Splash { get; init; }
public string? DiscoverySplash { get; init; }
public bool? Owner { get; init; }
// public string? Icon { get; init; }
// public string? Splash { get; init; }
// public string? DiscoverySplash { get; init; }
// public bool? Owner { get; init; }
public ulong OwnerId { get; init; }
public string Region { get; init; }
public ulong? AfkChannelId { get; init; }
public int AfkTimeout { get; init; }
public bool? WidgetEnabled { get; init; }
public ulong? WidgetChannelId { get; init; }
public int VerificationLevel { get; init; }
// public string Region { get; init; }
// public ulong? AfkChannelId { get; init; }
// public int AfkTimeout { get; init; }
// public bool? WidgetEnabled { get; init; }
// public ulong? WidgetChannelId { get; init; }
// public int VerificationLevel { get; init; }
public PremiumTier PremiumTier { get; init; }
public Role[] Roles { get; init; }
public string[] Features { get; init; }
// public string[] Features { get; init; }
}

View file

@ -10,5 +10,5 @@ public record GuildMemberPartial
public string? Avatar { get; init; }
public string? Nick { get; init; }
public ulong[] Roles { get; init; }
public string JoinedAt { get; init; }
// public string JoinedAt { get; init; }
}

View file

@ -46,29 +46,30 @@ public record Message
public MessageActivity? Activity { get; init; }
public User Author { get; init; }
public string? Content { get; init; }
public string? Timestamp { get; init; }
public string? EditedTimestamp { get; init; }
public bool Tts { get; init; }
public bool MentionEveryone { get; init; }
// public string? Timestamp { get; init; }
// public string? EditedTimestamp { get; init; }
// public bool Tts { get; init; }
// public bool MentionEveryone { get; init; }
public User.Extra[] Mentions { get; init; }
public ulong[] MentionRoles { get; init; }
// public ulong[] MentionRoles { get; init; }
public MessageComponent[]? Components { get; init; }
public Attachment[] Attachments { get; init; }
public Embed[]? Embeds { get; init; }
public Sticker[]? StickerItems { get; init; }
public Sticker[]? Stickers { get; init; }
public Reaction[] Reactions { get; init; }
public bool Pinned { get; init; }
// public Sticker[]? Stickers { get; init; }
// public Reaction[] Reactions { get; init; }
// public bool Pinned { get; init; }
public ulong? WebhookId { get; init; }
public ulong? ApplicationId { get; init; }
public MessageType Type { get; init; }
public Reference? MessageReference { get; set; }
public MessageFlags Flags { get; init; }
// public MessageFlags Flags { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Optional<Message?> ReferencedMessage { get; init; }
public MessageComponent[]? Components { get; init; }
// public MessageComponent[]? Components { get; init; }
public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId);
@ -82,8 +83,8 @@ public record Message
public int Size { get; init; }
public string Url { get; init; }
public string ProxyUrl { get; init; }
public int? Width { get; init; }
public int? Height { get; init; }
// public int? Width { get; init; }
// public int? Height { get; init; }
}
public record Reaction

View file

@ -4,10 +4,10 @@ public record Role
{
public ulong Id { get; init; }
public string Name { get; init; }
public uint Color { get; init; }
public bool Hoist { get; init; }
// public uint Color { get; init; }
// public bool Hoist { get; init; }
public int Position { get; init; }
public PermissionSet Permissions { get; init; }
public bool Managed { get; init; }
// public bool Managed { get; init; }
public bool Mentionable { get; init; }
}

View file

@ -26,10 +26,10 @@ public record User
public string? Avatar { get; init; }
public bool Bot { get; init; }
public bool? System { get; init; }
public Flags PublicFlags { get; init; }
// public Flags PublicFlags { get; init; }
public record Extra: User
{
public GuildMemberPartial? Member { get; init; }
// public GuildMemberPartial? Member { get; init; }
}
}

View file

@ -24,9 +24,9 @@
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.37.0, )",
"resolved": "2.37.0",
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
"requested": "[2.47.0, )",
"resolved": "2.47.0",
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
},
"Polly": {
"type": "Direct",

View file

@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -25,6 +27,25 @@ public static class APIJsonExt
return o;
}
public static JObject EmbedJson(string title, string type)
{
var o = new JObject();
o.Add("type", "rich");
o.Add("provider_name", "PluralKit " + type);
o.Add("provider_url", "https://pluralkit.me");
o.Add("title", title);
return o;
}
public static async Task WriteJSON(this HttpResponse resp, int statusCode, string jsonText)
{
resp.StatusCode = statusCode;
resp.Headers.Add("content-type", "application/json");
await resp.WriteAsync(jsonText);
}
}
public struct FrontersReturnNew

View file

@ -135,6 +135,9 @@ public class PrivateController: PKControllerBase
// guilds.Select(g => new HashEntry(g.Value<string>("id"), true)).ToArray()
// );
if (system.Token == null)
system = await _repo.UpdateSystem(system.Id, new SystemPatch { Token = StringUtils.GenerateToken() });
var o = new JObject();
o.Add("system", system.ToJson(LookupContext.ByOwner));

View file

@ -0,0 +1,77 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using PluralKit.Core;
namespace PluralKit.API;
[ApiController]
[Route("v2")]
public class AutoproxyControllerV2: PKControllerBase
{
public AutoproxyControllerV2(IServiceProvider svc) : base(svc) { }
// asp.net why
[HttpGet("systems/{systemRef}/autoproxy")]
public Task<IActionResult> GetWrapper([FromRoute] string systemRef, [FromQuery] ulong? guild_id, [FromQuery] ulong? channel_id)
=> Entrypoint(systemRef, guild_id, channel_id, null);
[HttpPatch("systems/{systemRef}/autoproxy")]
public Task<IActionResult> PatchWrapper([FromRoute] string systemRef, [FromQuery] ulong? guild_id, [FromQuery] ulong? channel_id, [FromBody] JObject? data)
=> Entrypoint(systemRef, guild_id, channel_id, data);
public async Task<IActionResult> Entrypoint(string systemRef, ulong? guild_id, ulong? channel_id, JObject? data)
{
var system = await ResolveSystem(systemRef);
if (ContextFor(system) != LookupContext.ByOwner)
throw Errors.GenericMissingPermissions;
if (guild_id == null || channel_id != null)
throw Errors.Unimplemented;
var settings = await _repo.GetAutoproxySettings(system.Id, guild_id, channel_id);
if (settings == null)
return NotFound();
if (HttpContext.Request.Method == "GET")
return await Get(settings);
else if (HttpContext.Request.Method == "PATCH")
return await Patch(system, guild_id, channel_id, data, settings);
else return StatusCode(415);
}
private async Task<IActionResult> Get(AutoproxySettings settings)
{
string hid = null;
if (settings.AutoproxyMember != null)
hid = (await _repo.GetMember(settings.AutoproxyMember.Value))?.Hid;
return Ok(settings.ToJson(hid));
}
private async Task<IActionResult> Patch(PKSystem system, ulong? guildId, ulong? channelId, JObject data, AutoproxySettings oldData)
{
var updateMember = data.ContainsKey("autoproxy_member");
PKMember? member = null;
if (updateMember)
member = await ResolveMember(data.Value<string>("autoproxy_member"));
var patch = AutoproxyPatch.FromJson(data, member?.Id);
patch.AssertIsValid();
if (updateMember && member == null)
patch.Errors.Add(new("autoproxy_member", "Member not found."));
if (updateMember && ((patch.AutoproxyMode.IsPresent && patch.AutoproxyMode.Value == AutoproxyMode.Latch) || oldData.AutoproxyMode == AutoproxyMode.Latch))
patch.Errors.Add(new("autoproxy_member", "Cannot update autoproxy member if autoproxy mode is set to latch"));
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);
var res = await _repo.UpdateAutoproxy(system.Id, guildId, channelId, patch);
if (!updateMember && oldData.AutoproxyMember != null)
member = await _repo.GetMember(oldData.AutoproxyMember.Value);
return Ok(res.ToJson(member?.Hid));
}
}

View file

@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase
return Ok(group.ToJson(ContextFor(group), system.Hid));
}
[HttpGet("groups/{groupRef}/oembed.json")]
public async Task<IActionResult> GroupEmbed(string groupRef)
{
var group = await ResolveGroup(groupRef);
if (group == null)
throw Errors.GroupNotFound;
var system = await _repo.GetSystem(group.System);
var name = group.DisplayName ?? group.Name;
if (system.Name != null)
name += $" ({system.Name})";
return Ok(APIJsonExt.EmbedJson(name, "Group"));
}
[HttpPatch("groups/{groupRef}")]
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
{

View file

@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase
return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid));
}
[HttpGet("members/{memberRef}/oembed.json")]
public async Task<IActionResult> MemberEmbed(string memberRef)
{
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFound;
var system = await _repo.GetSystem(member.System);
var name = member.DisplayName ?? member.Name;
if (system.Name != null)
name += $" ({system.Name})";
return Ok(APIJsonExt.EmbedJson(name, "Member"));
}
[HttpPatch("members/{memberRef}")]
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
{

View file

@ -183,7 +183,7 @@ public class SwitchControllerV2: PKControllerBase
if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value))
throw Errors.SameSwitchTimestampError;
await _repo.MoveSwitch(sw.Id, value);
sw = await _repo.MoveSwitch(sw.Id, value);
var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync();
return Ok(new FrontersReturnNew

View file

@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase
return Ok(system.ToJson(ContextFor(system)));
}
[HttpGet("{systemRef}/oembed.json")]
public async Task<IActionResult> SystemEmbed(string systemRef)
{
var system = await ResolveSystem(systemRef);
if (system == null)
throw Errors.SystemNotFound;
return Ok(APIJsonExt.EmbedJson(system.Name ?? $"System with ID `{system.Hid}`", "System"));
}
[HttpPatch("{systemRef}")]
public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data)
{

View file

@ -32,7 +32,7 @@
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
<PackageReference Include="App.Metrics.Reporting.Console" Version="4.3.0" />
<PackageReference Include="Google.Protobuf" Version="3.13.0" />
<PackageReference Include="Grpc.Tools" Version="2.37.0" PrivateAssets="All" />
<PackageReference Include="Grpc.Tools" Version="2.47.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="4.2.0" />

View file

@ -1,4 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=controllers/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=utils/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -107,31 +107,19 @@ public class Startup
// handle common ISEs that are generated by invalid user input
if (exc.Error.IsUserError())
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}");
}
await ctx.Response.WriteJSON(400, "{\"message\":\"400: Bad Request\",\"code\":0}");
else if (exc.Error is not PKError)
{
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("{\"message\":\"500: Internal Server Error\",\"code\":0}");
}
await ctx.Response.WriteJSON(500, "{\"message\":\"500: Internal Server Error\",\"code\":0}");
// for some reason, if we don't specifically cast to ModelParseError, it uses the base's ToJson method
else if (exc.Error is ModelParseError fe)
{
ctx.Response.StatusCode = fe.ResponseCode;
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(fe.ToJson()));
}
await ctx.Response.WriteJSON(fe.ResponseCode, JsonConvert.SerializeObject(fe.ToJson()));
else
{
var err = (PKError)exc.Error;
ctx.Response.StatusCode = err.ResponseCode;
var json = JsonConvert.SerializeObject(err.ToJson());
await ctx.Response.WriteAsync(json);
await ctx.Response.WriteJSON(err.ResponseCode, JsonConvert.SerializeObject(err.ToJson()));
}
await ctx.Response.CompleteAsync();
@ -145,7 +133,15 @@ public class Startup
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => endpoints.MapControllers());
app.UseEndpoints(endpoints =>
{
// register base / legacy routes
endpoints.MapMethods("", new string[] { }, (context) => { context.Response.Redirect("https://pluralkit.me/api"); return Task.CompletedTask; });
endpoints.MapMethods("v1/{*_}", new string[] { }, (context) => context.Response.WriteJSON(410, "{\"message\":\"Unsupported API version\",\"code\":0}"));
// register controllers
endpoints.MapControllers();
});
// metrics
app.UseMetricsAllMiddleware();

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<runtime>
<gcServer enabled="true"/>
</runtime>
</configuration>

View file

@ -1,853 +0,0 @@
# Draft version. Refer to the official API documentation for more accurate information.
openapi: 3.0.0
info:
title: PluralKit
version: "1.1"
description: |
This is the API for [PluralKit](https://pluralkit.me/)! :)
The API itself is stable, but this document (the OpenAPI description) is still subject to change, and may be updated, corrected or restructured in the future (as long as it's still coherent with the real API).
# Authentication
Authentication is handled using a "system token". At the moment, the only way
to obtain a system token is to use the `pk;token` command through the Discord bot.
This will generate an opaque string you must pass as the `Authorization` header to API requests.
Many API endpoints are available anonymously, but most of them will hide information from
unauthenticated requests to align with the relevant privacy settings.
# Errors
Errors are just returned as HTTP response codes. Most error responses include a human-readable
error message as the body, but this should not be relied on. Just read the response codes :)
# OpenAPI version history
- **1.1**: Granular member privacy
- **1.0**: (initial definition version)
license:
name: GNU Affero General Public License, Version 3
url: https://www.gnu.org/licenses/agpl-3.0.en.html
externalDocs:
url: https://pluralkit.me/api
description: For more information, see the official PluralKit API documentation on the website.
servers:
- url: https://api.pluralkit.me/v1
description: Primary API server (v1)
paths:
/s:
get:
summary: Returns your own system.
description: Requires authentication, and will returns the system the token belongs to.
tags: [Systems]
operationId: GetOwnSystem
security:
- TokenAuth: []
responses:
"200":
$ref: "#/components/responses/SystemResponse"
"401":
$ref: "#/components/responses/UnauthorizedError"
patch:
summary: Updates an existing system.
description: Requires authentication, and will update the system the token belongs to.
tags: [Systems]
operationId: UpdateSystem
security:
- TokenAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/System"
responses:
"200":
description: The system was updated. Returns the updated system object.
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"401":
$ref: "#/components/responses/UnauthorizedError"
/s/{id}:
parameters:
- $ref: "#/components/parameters/SystemID"
get:
summary: Gets a system by its ID.
tags: [Systems]
operationId: GetSystem
description: Partial information may be returned if not authenticated with this system's token.
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"404":
description: System with the given ID not found.
/s/{id}/members:
parameters:
- $ref: "#/components/parameters/SystemID"
get:
summary: Gets a system's members.
description: |
If the API token does not belong to this system, this list may exclude any private members in the system.
tags: [Systems, Members]
operationId: GetSystemMembers
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/System"
"403":
description: The system's member list is private, and the given token either missing, invalid, or does not correspond to the system.
"404":
description: System with the given ID not found.
/s/{id}/fronters:
# TODO: rename to "latest switch" or something?
parameters:
- $ref: "#/components/parameters/SystemID"
get:
summary: Gets a system's current fronters.
tags: [Systems, Switches]
operationId: GetSystemFronters
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/FullSwitch"
"403":
description: The system's current fronters are private, and the given token is either missing, invalid, or does not correspond to the system.
"404":
description: System with the given ID was either not found, or does not have any switches registered.
/s/{id}/switches:
parameters:
- $ref: "#/components/parameters/SystemID"
get:
summary: Gets a system's switch history.
description: |
Will return the system's switch history, up to 100 entries at a time, in reverse-chronological (latest first) order.
For pagination, see the `before` query parameter.
tags: [Systems, Switches]
operationId: GetSystemSwitches
parameters:
- in: query
name: before
schema:
type: string
format: date-time
description: |
If provided, will only return switches that happened *before* (and not including) this timestamp.
This can be used for pagination by calling the endpoint again with the timestamp of the last switch of the previous response.
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
maxItems: 100
items:
$ref: "#/components/schemas/Switch"
"403":
description: The system's switch history is private, and the given token is either missing, invalid, or does not correspond to the system.
"404":
description: System with the given ID not found.
/s/switches:
post:
summary: Registers a new switch.
tags: [Switches]
operationId: RegisterSwitch
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Switch"
security:
- TokenAuth: []
responses:
"204":
description: The switch was logged.
"401":
$ref: "#/components/responses/UnauthorizedError"
/m:
post:
summary: Creates a new member in your system.
tags: [Members]
operationId: CreateMember
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Member"
security:
- TokenAuth: []
responses:
"200":
description: The member was created. Returns the newly created member object.
content:
application/json:
schema:
$ref: "#/components/schemas/Member"
"401":
$ref: "#/components/responses/UnauthorizedError"
/m/{id}:
parameters:
- $ref: "#/components/parameters/MemberID"
get:
summary: Gets a member by their ID.
tags: [Members]
operationId: GetMember
security:
- {}
- TokenAuth: []
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Member"
"404":
$ref: "#/components/responses/MemberNotFoundError"
patch:
summary: Updates a member.
tags: [Members]
operationId: UpdateMember
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/Member"
security:
- TokenAuth: []
responses:
"200":
description: The member was updated. Returns trhe updated member object.
content:
application/json:
schema:
$ref: "#/components/schemas/Member"
"401":
$ref: "#/components/responses/MemberAuthError"
"404":
$ref: "#/components/responses/MemberNotFoundError"
delete:
summary: Deletes a member.
tags: [Members]
operationId: DeleteMember
security:
- TokenAuth: []
responses:
"200": # TODO: should be 204 (but *is* 200)
description: Member successfully deleted.
"401":
$ref: "#/components/responses/MemberAuthError"
"404":
$ref: "#/components/responses/MemberNotFoundError"
/a/{id}:
parameters:
- in: path
name: id
required: true
description: A Discord user ID.
schema:
$ref: "#/components/schemas/Snowflake"
get:
summary: Gets a system by (one of) its associated Discord accounts.
description: |
Note that it's currently not possible to get a system's registered accounts given a system ID through the API.
Consider this endpoint "one-way".
tags: [Systems, Accounts]
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/System"
"404":
description: The given user ID does not correspond to any systems.
/msg/{id}:
parameters:
- in: path
name: id
required: true
description: |
A Discord message ID.
This may refer to either the original "trigger message" posted by the user,
or to the resulting webhook message posted by PluralKit.
The former may be useful for eg. logging bot integration.
schema:
$ref: "#/components/schemas/Snowflake"
get:
summary: Gets information about a proxied message by its message ID.
tags: [Proxying]
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
"404":
description: The given message ID does not correspond to a message proxied by PluralKit.
components:
responses:
UnauthorizedError:
description: System token is missing or invalid.
NotOwnSystemError:
description: The given system does not correspond with this token.
MemberAuthError:
# TODO (relevant, ish): This is always returned as 401 but should be split into "invalid token" (401) and "not own member" (403) responses
description: System token is missing, invalid, or the corresponding system does not own the given member.
MemberNotFoundError:
description: A member by the given ID was not found.
SystemResponse:
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/System"
MemberListResponse:
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Member"
parameters:
SystemID:
in: path
name: id
required: true
description: The ID of the system in question.
schema:
$ref: "#/components/schemas/ID"
MemberID:
in: path
name: id
required: true
description: The ID of the member in question.
schema:
$ref: "#/components/schemas/ID"
schemas:
ID:
title: ID (system or member)
type: string
readOnly: true
minLength: 5
maxLength: 5
pattern: "^[a-z]{5}$"
example: "abcde"
description: |
A unique identifier for a system or a member, as a randomly generated string of five lowercase letters.
These IDs are guaranteed to be globally unique for the given model type (a system can have the same ID as a member, but two systems or two members can never share an ID).
The IDs can on rare occasions change - eg. if a profane word is generated and later regenerated, or as a potential future Patreon perk. However, it's still reasonable to assume that the IDs are constant, and that tey won't change without the user's knowledge, so it's safe to store in things like settings menus and config files.
System:
properties:
id:
$ref: "#/components/schemas/ID"
name:
type: string
nullable: true
maxLength: 100
description: The user-provided name of the system.
example: "Boxes of Foxes"
description:
type: string
nullable: true
maxLength: 1000
description: |
The user-provided system description.
May contain rich text in Markdown, including standard Markdown-formatted links, or Discord-formatted emoji/user/channel references.
example: |-
This system is very cool.
It has cool people.
tag:
type: string
maxLength: 78
description: |
The system tag, which is appended to the names (or display names, if set) of members when proxying through the bot.
A space will automatically be inserted between the name and the tag, so no need to include one at the start.
The character limit here is 78, as Discord's name length limit for webhooks is 80 characters. A 78-character system tag is thus the longest tag that can still accomodate a single-letter member name and the separating space.
nullable: true
example: "| BoF"
avatar_url:
type: string
format: url
nullable: true
maxLength: 256
example: "https://i.imgur.com/Abcdefg.png"
description: |
A link to the avatar/icon of the system.
If used for proxying, the image must be at most 1000 pixels in width *and* height, and at most 1 MiB in size. This restriction is on Discord's end and is not verified through the API (it's simply stored as a string).
tz:
type: string
format: timezone
nullable: true
default: UTC
example: America/New_York
description: |
The system's registered time zone as an Olson time zone ID.
This is used in the bot to convert various time stamps in commands on behalf of this system.
created:
type: string
format: date-time
readOnly: true
description: The creation timestamp of the system.
description_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The system's description privacy setting, either "public" or "private".
If this is set to "private", the field `description` will be returned as `null` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between a private description and a `null` description - this is intentional.
example: public
member_list_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The system's member list privacy setting, either "public" or "private".
If this is set to "private", this system's member list can not be queried without proper authorization.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
example: public
front_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The system's current fronter privacy setting, either "public" or "private".
If this is set to "private", this system's current fronter can not be queried without proper authorization.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
example: public
front_history_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The system's front history privacy setting, either "public" or "private".
If this is set to "private", this system's front/switch history can not be queried without proper authorization.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
example: public
Member:
properties:
id:
$ref: "#/components/schemas/ID"
name:
type: string
maxLength: 100
description: The user-provided name of the member.
example: "Myriad Kit"
display_name:
type: string
maxLength: 100
description: The member's "display name", which will override the member's normal name when proxying.
nullable: true
example: Myriad 'Big Boss' Kit
description:
type: string
maxLength: 1000
description: |
The user-provided description of the member.
May contain rich text in Markdown, including standard Markdown-formatted links, or Discord-formatted emoji/user/channel references.
If this member is private, and the request is not authorized with the member's system token, this field will always be returned as `null`.
example: Myriad is very cool and rad, and they love snuggling.
color:
type: string
format: color
minLength: 6
maxLength: 6
nullable: true
pattern: "^[0-9A-Fa-f]{6}$"
description: |
The member's "color", displayed on member cards, as a 6-character hexadecimal color code (no leading #).
If this member is private, and the request is not authorized with the member's system token, this field will always be returned as `null`.
example: "FF0000"
birthday:
type: string
format: date
example: "2018-07-11"
nullable: true
description: |
The user-provided birthdate of the member.
"Year-less" birthdays are supported. In this case, the year should be set to `0004`, and that specific year should be special-cased and hidden from the user. Previous versions used the year `0001` for the same purpose, and this value may still be both read and written with the API and should be treated the same as `0004`.
The year `0004` was chosen because it is a leap year in the Gregorian calendar, and thus the date `0004-02-29` can be properly represented.
If this member is private, and the request is not authorized with the member's system token, this field will always be returned as `null`.
pronouns:
type: string
maxLength: 100
example: "they/them or xe/xem"
nullable: true
description: |
The user-provided pronouns of the member.
There is no specific schema, just a freeform text field.
If this member is private, and the request is not authorized with the member's system token, this field will always be returned as `null`.
avatar_url:
type: string
format: url
nullable: true
maxLength: 256
example: "https://i.imgur.com/Abcdefg.png"
description: |
A link to the avatar/icon of the member.
If used for proxying, the image must be at most 1000 pixels in width *and* height, and at most 1 MiB in size. This restriction is on Discord's end and is not verified through the API (it's simply stored as a string).
privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
This field is deprecated.
At the moment, it's still included in member objects, with a value that mirrors the `visibility` field. Writing to this field will set *every* privacy setting to the written value.
example: public
deprecated: true
visibility:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current visibility, either "public" or "private".
If this is set to "private", the member will not appear in public member lists. It can still be looked up using its 5-character member ID, but will return limited information.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
example: public
name_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current name privacy setting, either "public" or "private".
If this is set to "private", the member's returned `name` will be replaced by their `display_name` when applicable.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between a private name being replaced by the display name, and an empty display name with the name set - this is intentional.
example: public
description_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current description privacy setting, either "public" or "private".
If this is set to "private", the field `description` will be returned as `null` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between a private description and a `null` description - this is intentional.
example: public
avatar_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current avatar privacy setting, either "public" or "private".
If this is set to "private", the field `avatar_url` will be returned as `null` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between a private avatar and a `null` avatar - this is intentional.
example: public
pronouns_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current pronouns privacy setting, either "public" or "private".
If this is set to "private", the field `pronouns` will be returned as `null` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between private pronouns and `null` pronouns - this is intentional.
example: public
birthday_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current birthday privacy setting, either "public" or "private".
If this is set to "private", the field `birthday` will be returned as `null` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
Because of this, there is no way for an unauthorized user to tell the difference between a private birthday and a `null` birthday - this is intentional.
example: public
metadata_privacy:
allOf:
- $ref: "#/components/schemas/PrivacySetting"
- description: |
The member's current metadata privacy setting, either "public" or "private".
If this is set to "private", the field `created` on all requests not authorized with this system's token.
In addition, this field will be returned as `null` if the request is not authorized with this system's token.
example: public
proxy_tags:
type: array
items:
$ref: "#/components/schemas/ProxyTag"
description: |
An unordered list of the member's proxy tag pairs.
It is valid for a member to have any number of proxy tags, including none at all.
prefix:
type: string
nullable: true
description: |
Previous versions of the API only supported a single proxy tag pair per member.
This field will contain the prefix of the first proxy tag registered, or `null` if missing.
Setting it will write to the first proxy tag's prefix, creating it if not present.
This field is deprecated and will be removed in API v2.
deprecated: true
example: "{{"
suffix:
type: string
nullable: true
description: |
Previous versions of the API only supported a single proxy tag pair per member.
This field will contain the suffix of the first proxy tag registered, or `null` if missing.
Setting it will write to the first proxy tag's suffix, creating it if not present.
This field is deprecated and will be removed in API v2.
deprecated: true
example: "}}"
keep_proxy:
type: boolean
default: false
description: |
Whether or not to include the used proxy tags in proxied messages.
created:
type: string
format: date-time
readOnly: true
description: The creation timestamp of the member. May be returned as `null` depending on the value of `metadata_privacy` and the request authorization.
nullable: true
PrivacySetting:
title: Privacy Setting
type: string
nullable: true
description: A privacy setting for systems or members, either public or private. May occasionally be `null` in cases where you are not authorized to view the privacy setting's state.
enum: [public, private]
example: public
ProxyTag:
title: Proxy Tag
description: |
Represents a proxy tag to match messages on.
A "proxy tag" consists of a prefix and a suffix, and a given proxy tag set matches a string
if that string begins with the prefix and ends with the suffix.
It's often represented to the user with the word `text` between them, eg. `[text]`, `{{text`, and so on.
For example, the proxy tag pair "[" and "]" will match any string \[in square brackets\].
Either the prefix or the suffix may be missing (or both may be present), but it is invalid for
both values to be null.
properties:
prefix:
type: string
nullable: true
description: |
The proxy tag prefix. This is the string that goes *before* the inner text.
An empty prefix is represented as `null` and an empty string will be converted as such.
example: "{{"
maxLength: 100
suffix:
type: string
nullable: true
description: |
The proxy tag suffix. This is the string that goes *after* the inner text.
An empty suffix is represented as `null` and an empty string will be converted as such.
example: "}}"
maxLength: 100
Switch:
title: Logged Switch
properties:
timestamp:
type: string
format: date-time
description: The timestamp the switch was logged.
readOnly: true
members:
type: array
description: |
A list of the IDs of the members in this switch. The order is significant. It is valid for the switch to have no members at all.
items:
$ref: "#/components/schemas/ID"
FullSwitch:
title: Logged Switch (with full Member object)
properties:
timestamp:
type: string
format: date-time
description: The timestamp the switch was logged.
members:
type: array
description: |
A list of the members in this switch. The order is significant. It is valid for the switch to have no members at all.
items:
$ref: "#/components/schemas/Member"
Snowflake:
title: Snowflake (Discord ID)
description: |
A unique identifier used by Discord for its objects (accounts, guilds, channels, messages).
This is internally stored as a 64-bit unsigned integer, but is represented as a string in the API
to accomodate languages that store numbers as floating point values (and thus would not be able to represent it losslessly).
type: string
pattern: "^[0-9]{17,19}"
minLength: 17
maxLength: 19
example: "466378653216014359" # PK's account ID :3
Message:
title: Message Info
description: |
An object containing information about a proxied message, including message, user and channel IDs, as well as the system and member it's related to.
For privacy and performance reasons, this endpoint does not return the *contents* of the original message. This data isn't stored in the database either way - but given the channel and message ID, it can be fetched from Discord's own API.
properties:
timestamp:
type: string
format: date-time
readOnly: true
description: The time the message was proxied.
id:
allOf:
- $ref: "#/components/schemas/Snowflake"
- description: "The ID of the proxied webhook message posted by PluralKit."
example: "123456789012345678"
original:
allOf:
- $ref: "#/components/schemas/Snowflake"
- description: "The ID of the original (now-deleted) trigger message containing the proxy tags."
example: "123456789012345678"
sender:
allOf:
- $ref: "#/components/schemas/Snowflake"
- description: "The ID of the Discord user that sent the trigger message."
example: "123456789012345678"
channel:
allOf:
- $ref: "#/components/schemas/Snowflake"
- description: "The ID of the Discord channel the relevant messages were posted in."
example: "123456789012345678"
system:
allOf:
- $ref: "#/components/schemas/System"
- description: "The system that proxied the message."
member:
allOf:
- $ref: "#/components/schemas/Member"
- description: "The member that proxied the message."
securitySchemes:
TokenAuth:
type: apiKey
in: header
name: Authorization
description: A system token obtained from the `pk;proxy` command in the Discord bot.

View file

@ -53,9 +53,9 @@
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.37.0, )",
"resolved": "2.37.0",
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
"requested": "[2.47.0, )",
"resolved": "2.47.0",
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
},
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
"type": "Direct",

View file

@ -73,7 +73,7 @@ public class Bot
}
};
_services.Resolve<RedisGatewayService>().OnEventReceived += (e) => OnEventReceivedInner(e.Item1, e.Item2);
_services.Resolve<RedisGatewayService>().OnEventReceived += (e) => OnEventReceived(e.Item1, e.Item2);
// Init the shard stuff
_services.Resolve<ShardInfoService>().Init();

View file

@ -22,6 +22,20 @@ public static class BotMetrics
Context = "Bot"
};
public static MeterOptions DatabaseDMCacheHits => new()
{
Name = "Database DM Cache Hits",
MeasurementUnit = Unit.Calls,
Context = "Bot"
};
public static MeterOptions DMCacheMisses => new()
{
Name = "DM Cache Misses",
MeasurementUnit = Unit.Calls,
Context = "Bot"
};
public static MeterOptions CommandsRun => new()
{
Name = "Commands run",

View file

@ -92,6 +92,7 @@ public partial class CommandTree
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 LogShow = new Command("log show", "log show", "Displays the current list of channels where logging is disabled");
public static Command LogClean = new Command("logclean", "logclean [on|off]", "Toggles whether to clean up other bots' log channels");
public static Command BlacklistShow = new Command("blacklist show", "blacklist show", "Displays the current proxy blacklist");
public static Command BlacklistAdd = new Command("blacklist add", "blacklist add all|<channel> [channel 2] [channel 3...]", "Adds certain channels to the proxy blacklist");
@ -142,7 +143,7 @@ public partial class CommandTree
AutoproxyOff, AutoproxyFront, AutoproxyLatch, AutoproxyMember
};
public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable };
public static Command[] LogCommands = { LogChannel, LogChannelClear, LogEnable, LogDisable, LogShow };
public static Command[] BlacklistCommands = { BlacklistAdd, BlacklistRemove, BlacklistShow };
}

View file

@ -48,7 +48,7 @@ public partial class CommandTree
return ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx));
if (ctx.Match("edit", "e"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx));
if (ctx.Match("reproxy", "rp"))
if (ctx.Match("reproxy", "rp", "crimes"))
return ctx.Execute<ProxiedMessage>(MessageReproxy, m => m.ReproxyMessage(ctx));
if (ctx.Match("log"))
if (ctx.Match("channel"))
@ -57,6 +57,8 @@ public partial class CommandTree
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("list", "show"))
return ctx.Execute<ServerConfig>(LogShow, m => m.ShowLogDisabledChannels(ctx));
else if (ctx.Match("commands"))
return PrintCommandList(ctx, "message logging", LogCommands);
else return PrintCommandExpectedError(ctx, LogCommands);

View file

@ -28,8 +28,8 @@ public class Context
private Command? _currentCommand;
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
PKSystem senderSystem, SystemConfig config, MessageContext messageContext)
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
int commandParseOffset, PKSystem senderSystem, SystemConfig config)
{
Message = (Message)message;
ShardId = shardId;
@ -37,7 +37,6 @@ public class Context
Channel = channel;
System = senderSystem;
Config = config;
MessageContext = messageContext;
Cache = provider.Resolve<IDiscordCache>();
Database = provider.Resolve<IDatabase>();
Repository = provider.Resolve<ModelRepository>();
@ -61,7 +60,6 @@ public class Context
public readonly Guild Guild;
public readonly int ShardId;
public readonly Cluster Cluster;
public readonly MessageContext MessageContext;
public Task<PermissionSet> BotPermissions => Cache.PermissionsIn(Channel.Id);
public Task<PermissionSet> UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message);
@ -96,12 +94,12 @@ public class Context
AllowedMentions = mentions ?? new AllowedMentions()
});
if (embed != null)
{
// if (embed != null)
// {
// Sensitive information that might want to be deleted by :x: reaction is typically in an embed format (member cards, for example)
// This may need to be changed at some point but works well enough for now
// but since we can, we just store all sent messages for possible deletion
await _commandMessageService.RegisterMessage(msg.Id, msg.ChannelId, Author.Id);
}
// }
return msg;
}
@ -110,6 +108,12 @@ public class Context
{
_currentCommand = commandDef;
if (deprecated && commandDef != null)
{
await Reply($"{Emojis.Warn} This command has been removed. please use `pk;{commandDef.Key}` instead.");
return;
}
try
{
using (_metrics.Measure.Timer.Time(BotMetrics.CommandTime, new MetricTags("Command", commandDef?.Key ?? "null")))
@ -130,9 +134,6 @@ public class Context
// Got a complaint the old error was a bit too patronizing. Hopefully this is better?
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
}
if (deprecated && commandDef != null)
await Reply($"{Emojis.Warn} This command is deprecated and will be removed soon. In the future, please use `pk;{commandDef.Key}`.");
}
/// <summary>

View file

@ -103,6 +103,13 @@ public static class ContextArgumentsExt
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
{
var value = ctx.MatchToggleOrNull(defaultValue);
if (value == null) throw new PKError("You must pass either \"on\" or \"off\" to this command.");
return value.Value;
}
public static bool? MatchToggleOrNull(this Context ctx, bool? defaultValue = null)
{
if (defaultValue != null && ctx.MatchClearInner())
return defaultValue.Value;
@ -114,8 +121,7 @@ public static class ContextArgumentsExt
return true;
else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles))
return false;
else
throw new PKError("You must pass either \"on\" or \"off\" to this command.");
else return null;
}
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)

View file

@ -89,10 +89,12 @@ public class Autoproxy
var eb = new EmbedBuilder()
.Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
var fronters = ctx.MessageContext.LastSwitchMembers;
var sw = await ctx.Repository.GetLatestSwitch(ctx.System.Id);
var fronters = sw == null ? new() : await ctx.Database.Execute(c => ctx.Repository.GetSwitchMembers(c, sw.Id)).ToListAsync();
var relevantMember = settings.AutoproxyMode switch
{
AutoproxyMode.Front => fronters.Length > 0 ? await ctx.Repository.GetMember(fronters[0]) : null,
AutoproxyMode.Front => fronters.Count > 0 ? fronters[0] : null,
AutoproxyMode.Member when settings.AutoproxyMember.HasValue => await ctx.Repository.GetMember(settings.AutoproxyMember.Value),
_ => null
};
@ -104,7 +106,7 @@ public class Autoproxy
break;
case AutoproxyMode.Front:
{
if (fronters.Length == 0)
if (fronters.Count == 0)
{
eb.Description("Autoproxy is currently set to **front mode** in this server, but there are currently no fronters registered. Use the `pk;switch` command to log a switch.");
}
@ -135,7 +137,8 @@ public class Autoproxy
default: throw new ArgumentOutOfRangeException();
}
if (!ctx.MessageContext.AllowAutoproxy)
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
if (!allowAutoproxy)
eb.Field(new Embed.Field("\u200b", $"{Emojis.Note} Autoproxy is currently **disabled** for your account (<@{ctx.Author.Id}>). To enable it, use `pk;autoproxy account enable`."));
return eb.Build();

View file

@ -268,8 +268,7 @@ public class Checks
try
{
_proxy.ShouldProxy(channel, msg, context);
_matcher.TryMatch(context, autoproxySettings, members, out var match, msg.Content, msg.Attachments.Length > 0,
context.AllowAutoproxy);
_matcher.TryMatch(context, autoproxySettings, members, out var match, msg.Content, msg.Attachments.Length > 0, true);
await ctx.Reply("I'm not sure why this message was not proxied, sorry.");
}

View file

@ -17,10 +17,12 @@ public class Config
{
var items = new List<PaginatedConfigItem>();
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
items.Add(new(
"autoproxy account",
"Whether autoproxy is enabled for the current account",
EnabledDisabled(ctx.MessageContext.AllowAutoproxy),
EnabledDisabled(allowAutoproxy),
"enabled"
));
@ -122,16 +124,18 @@ public class Config
public async Task AutoproxyAccount(Context ctx)
{
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
if (!ctx.HasNext())
{
await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(ctx.MessageContext.AllowAutoproxy)}** for account <@{ctx.Author.Id}>.");
await ctx.Reply($"Autoproxy is currently **{EnabledDisabled(allowAutoproxy)}** for account <@{ctx.Author.Id}>.");
return;
}
var allow = ctx.MatchToggle(true);
var statusString = EnabledDisabled(allow);
if (ctx.MessageContext.AllowAutoproxy == allow)
if (allowAutoproxy == allow)
{
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
return;

View file

@ -374,38 +374,39 @@ public class Groups
public async Task GroupColor(Context ctx, PKGroup target)
{
var color = ctx.RemainderOrNull();
if (await ctx.MatchClear())
{
ctx.CheckOwnGroup(target);
var isOwnSystem = ctx.System?.Id == target.System;
var matchedRaw = ctx.MatchRaw();
var matchedClear = await ctx.MatchClear();
var patch = new GroupPatch { Color = Partial<string>.Null() };
await ctx.Repository.UpdateGroup(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Group color cleared.");
}
else if (!ctx.HasNext())
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
{
if (target.Color == null)
if (ctx.System?.Id == target.System)
await ctx.Reply(
$"This group does not have a color set. To set one, type `pk;group {target.Reference(ctx)} color <color>`.");
else
await ctx.Reply("This group does not have a color set.");
"This group does not have a color set." + (isOwnSystem ? $" To set one, type `pk;group {target.Reference(ctx)} color <color>`." : ""));
else if (matchedRaw)
await ctx.Reply("```\n#" + target.Color + "\n```");
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
.Description($"This group's color is **#{target.Color}**."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;group {target.Reference(ctx)} color -clear`."
: ""))
+ (isOwnSystem ? $" To clear it, type `pk;group {target.Reference(ctx)} color -clear`." : ""))
.Build());
return;
}
ctx.CheckSystem().CheckOwnGroup(target);
if (matchedClear)
{
await ctx.Repository.UpdateGroup(target.Id, new() { Color = Partial<string>.Null() });
await ctx.Reply($"{Emojis.Success} Group color cleared.");
}
else
{
ctx.CheckOwnGroup(target);
var color = ctx.RemainderOrNull();
if (color.StartsWith("#")) color = color.Substring(1);
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);

View file

@ -1,5 +1,5 @@
using Myriad.Builders;
using Myriad.Types;
using Myriad.Rest.Types.Requests;
using PluralKit.Core;
@ -10,10 +10,18 @@ public class Help
private static Embed helpEmbed = new()
{
Title = "PluralKit",
Description = "PluralKit is a bot designed for plural communities on Discord. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.",
Fields = new[]
Description = "PluralKit is a bot designed for plural communities on Discord, and is open for anyone to use. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.",
Footer = new("By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"),
Color = DiscordUtils.Blue,
};
private static Dictionary<string, Embed.Field[]> helpEmbedPages = new Dictionary<string, Embed.Field[]>
{
new Embed.Field
{
"basicinfo",
new Embed.Field[]
{
new
(
"What is this for? What are systems?",
"This bot detects messages with certain tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using webhooks."
@ -24,6 +32,12 @@ public class Help
"Why are people's names saying [BOT] next to them?",
"These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."
),
}
},
{
"gettingstarted",
new Embed.Field[]
{
new
(
"How do I get started?",
@ -38,6 +52,12 @@ public class Help
"\nSee [the Getting Started guide](https://pluralkit.me/start) for more information."
})
),
}
},
{
"usefultips",
new Embed.Field[]
{
new
(
"Useful tips",
@ -48,26 +68,101 @@ public class Help
"Type **`pk;invite`** to get a link to invite this bot to your own server!"
})
),
}
},
{
"moreinfo",
new Embed.Field[]
{
new
(
"More information",
String.Join("\n", new[] {
"For a full list of commands, see [the command list](https://pluralkit.me/commands).",
"For a full list of commands, see [the command list](https://pluralkit.me/commands), or type `pk;commands`.",
"For a more in-depth explanation of message proxying, see [the documentation](https://pluralkit.me/guide#proxying).",
"If you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there."
"If you're an existing user of Tupperbox, type `pk;import` and attach a Tupperbox export file (from `tul!export`) to import your data from there.",
"We also have a [web dashboard](https://dash.pluralkit.me) to edit your system info online."
})
),
new
(
"Support server",
"We also have a Discord server for support, discussion, suggestions, announcements, etc: https://discord.gg/PczBt78"
)
},
Footer = new("By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/xSke/PluralKit/ | Website: https://pluralkit.me/"),
Color = DiscordUtils.Blue,
),
}
}
};
public Task HelpRoot(Context ctx) => ctx.Reply(embed: helpEmbed);
private static MessageComponent helpPageButtons(ulong userId) => new MessageComponent
{
Type = ComponentType.ActionRow,
Components = new[]
{
new MessageComponent
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Label = "Basic Info",
CustomId = $"help-menu-basicinfo-{userId}",
Emoji = new() { Name = "\u2139" },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Label = "Getting Started",
CustomId = $"help-menu-gettingstarted-{userId}",
Emoji = new() { Name = "\u2753", },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Label = "Useful Tips",
CustomId = $"help-menu-usefultips-{userId}",
Emoji = new() { Name = "\U0001f4a1", },
},
new()
{
Type = ComponentType.Button,
Style = ButtonStyle.Secondary,
Label = "More Info",
CustomId = $"help-menu-moreinfo-{userId}",
Emoji = new() { Id = 986379675066593330, },
}
}
};
public Task HelpRoot(Context ctx)
=> ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
{
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description + "\n\n**Use the buttons below to see more info!**" } },
Components = new[] { helpPageButtons(ctx.Author.Id) },
});
public static Task ButtonClick(InteractionContext ctx)
{
if (!ctx.CustomId.Contains(ctx.User.Id.ToString()))
return ctx.Ignore();
var buttons = helpPageButtons(ctx.User.Id);
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description + "\n\n**Use the buttons below to see more info!**" } },
Components = new[] { buttons }
});
buttons.Components.Where(x => x.CustomId == ctx.CustomId).First().Style = ButtonStyle.Primary;
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault(ctx.CustomId.Split("-")[2]) } },
Components = new[] { buttons }
});
}
private static string explanation = String.Join("\n\n", new[]
{

View file

@ -225,41 +225,39 @@ public class MemberEdit
public async Task Color(Context ctx, PKMember target)
{
var color = ctx.RemainderOrNull();
if (await ctx.MatchClear())
var isOwnSystem = ctx.System?.Id == target.System;
var matchedRaw = ctx.MatchRaw();
var matchedClear = await ctx.MatchClear();
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
{
ctx.CheckOwnMember(target);
var patch = new MemberPatch { Color = Partial<string>.Null() };
await ctx.Repository.UpdateMember(target.Id, patch);
await ctx.Reply($"{Emojis.Success} Member color cleared.");
}
else if (!ctx.HasNext())
{
// if (!target.ColorPrivacy.CanAccess(ctx.LookupContextFor(target.System)))
// throw Errors.LookupNotAllowed;
if (target.Color == null)
if (ctx.System?.Id == target.System)
await ctx.Reply(
$"This member does not have a color set. To set one, type `pk;member {target.Reference(ctx)} color <color>`.");
else
await ctx.Reply("This member does not have a color set.");
"This member does not have a color set." + (isOwnSystem ? $" To set one, type `pk;member {target.Reference(ctx)} color <color>`." : ""));
else if (matchedRaw)
await ctx.Reply("```\n#" + target.Color + "\n```");
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Member color")
.Color(target.Color.ToDiscordColor())
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
.Description($"This member's color is **#{target.Color}**."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;member {target.Reference(ctx)} color -clear`."
: ""))
+ (isOwnSystem ? $" To clear it, type `pk;member {target.Reference(ctx)} color -clear`." : ""))
.Build());
return;
}
ctx.CheckSystem().CheckOwnMember(target);
if (matchedClear)
{
await ctx.Repository.UpdateMember(target.Id, new() { Color = Partial<string>.Null() });
await ctx.Reply($"{Emojis.Success} Member color cleared.");
}
else
{
ctx.CheckOwnMember(target);
var color = ctx.RemainderOrNull();
if (color.StartsWith("#")) color = color.Substring(1);
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);

View file

@ -2,6 +2,8 @@
using System.Text;
using System.Text.RegularExpressions;
using Autofac;
using Myriad.Builders;
using Myriad.Cache;
using Myriad.Extensions;
@ -61,7 +63,7 @@ public class ProxiedMessage
throw new PKError("Could not find a member to reproxy the message with.");
// Fetch members and get the ProxyMember for `target`
List <ProxyMember> members;
List<ProxyMember> members;
using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime))
members = (await _repo.GetProxyMembers(ctx.Author.Id, msg.Message.Guild!.Value)).ToList();
var match = members.Find(x => x.Id == target.Id);
@ -70,7 +72,7 @@ public class ProxiedMessage
try
{
await _proxy.ExecuteReproxy(ctx.Message, msg.Message, match);
await _proxy.ExecuteReproxy(ctx.Message, msg.Message, members, match);
if (ctx.Guild == null)
await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success });
@ -126,8 +128,7 @@ public class ProxiedMessage
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
await _logChannel.LogMessage(ctx.MessageContext, msg.Message, ctx.Message, editedMsg,
originalMsg!.Content!);
await _logChannel.LogMessage(msg.Message, ctx.Message, editedMsg, originalMsg!.Content!);
}
catch (NotFoundException)
{
@ -140,13 +141,12 @@ public class ProxiedMessage
var editType = isReproxy ? "reproxy" : "edit";
var editTypeAction = isReproxy ? "reproxied" : "edited";
// todo: is it correct to get a connection here?
await using var conn = await ctx.Database.Obtain();
FullMessage? msg = null;
var (referencedMessage, _) = ctx.MatchMessage(false);
if (referencedMessage != null)
{
await using var conn = await ctx.Database.Obtain();
msg = await ctx.Repository.GetMessage(conn, referencedMessage.Value);
if (msg == null)
throw new PKError("This is not a message proxied by PluralKit.");
@ -161,6 +161,7 @@ public class ProxiedMessage
if (recent == null)
throw new PKSyntaxError($"Could not find a recent message to {editType}.");
await using var conn = await ctx.Database.Obtain();
msg = await ctx.Repository.GetMessage(conn, recent.Mid);
if (msg == null)
throw new PKSyntaxError($"Could not find a recent message to {editType}.");
@ -305,14 +306,14 @@ public class ProxiedMessage
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
{
var message = await ctx.Repository.GetCommandMessage(messageId);
if (message == null)
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
if (authorId == null)
throw Errors.MessageNotFound(messageId);
if (message.AuthorId != ctx.Author.Id)
if (authorId != ctx.Author.Id)
throw new PKError("You can only delete command messages queried by this account.");
await ctx.Rest.DeleteMessage(message.ChannelId, message.MessageId);
await ctx.Rest.DeleteMessage(channelId!.Value, messageId);
if (ctx.Guild != null)
await ctx.Rest.DeleteMessage(ctx.Message);

View file

@ -127,7 +127,7 @@ public class Misc
.Footer(new(String.Join(" \u2022 ", new[] {
$"PluralKit {BuildInfoService.Version}",
(isCluster ? $"Cluster {_botConfig.Cluster.NodeIndex}" : ""),
"https://github.com/xSke/PluralKit",
"https://github.com/PluralKit/PluralKit",
"Last restarted:",
})))
.Timestamp(process.StartTime.ToString("O"));

View file

@ -146,6 +146,57 @@ public class ServerConfig
});
}
public async Task ShowLogDisabledChannels(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
// Resolve all channels from the cache and order by position
var channels = (await Task.WhenAll(config.LogBlacklist
.Select(id => _cache.TryGetChannel(id))))
.Where(c => c != null)
.OrderBy(c => c.Position)
.ToList();
if (channels.Count == 0)
{
await ctx.Reply("This server has no channels where logging is disabled.");
return;
}
await ctx.Paginate(channels.ToAsyncEnumerable(), channels.Count, 25,
$"Channels where logging is disabled for {ctx.Guild.Name}",
null,
async (eb, l) =>
{
async Task<string> CategoryName(ulong? id) =>
id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)";
ulong? lastCategory = null;
var fieldValue = new StringBuilder();
foreach (var channel in l)
{
if (lastCategory != channel!.ParentId && fieldValue.Length > 0)
{
eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString()));
fieldValue.Clear();
}
else
{
fieldValue.Append("\n");
}
fieldValue.Append(channel.Mention());
lastCategory = channel.ParentId;
}
eb.Field(new Embed.Field(await CategoryName(lastCategory), fieldValue.ToString()));
});
}
public async Task SetBlacklisted(Context ctx, bool shouldAdd)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
@ -180,27 +231,26 @@ public class ServerConfig
public async Task SetLogCleanup(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
bool newValue;
if (ctx.Match("enable", "on", "yes"))
{
newValue = true;
}
else if (ctx.Match("disable", "off", "no"))
{
newValue = false;
}
else
{
var eb = new EmbedBuilder()
.Title("Log cleanup settings")
.Field(new Embed.Field("Supported bots", botList));
if (ctx.Guild == null)
{
eb.Description("Run this command in a server to enable/disable log cleanup.");
await ctx.Reply(embed: eb.Build());
return;
}
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
bool? newValue = ctx.MatchToggleOrNull();
if (newValue == null)
{
var guildCfg = await ctx.Repository.GetGuild(ctx.Guild.Id);
if (guildCfg.LogCleanupEnabled)
eb.Description(
@ -212,9 +262,9 @@ public class ServerConfig
return;
}
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue });
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogCleanupEnabled = newValue.Value });
if (newValue)
if (newValue.Value)
await ctx.Reply(
$"{Emojis.Success} Log cleanup has been **enabled** for this server. Messages deleted by PluralKit will now be cleaned up from logging channels managed by the following bots:\n- **{botList}**\n\n{Emojis.Note} Make sure PluralKit has the **Manage Messages** permission in the channels in question.\n{Emojis.Note} Also, make sure to blacklist the logging channel itself from the bots in question to prevent conflicts.");
else

View file

@ -132,12 +132,16 @@ public class SystemEdit
public async Task Color(Context ctx, PKSystem target)
{
var isOwnSystem = ctx.System?.Id == target.Id;
var matchedRaw = ctx.MatchRaw();
var matchedClear = await ctx.MatchClear();
if (!isOwnSystem || !ctx.HasNext(false))
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
{
if (target.Color == null)
await ctx.Reply(
"This system does not have a color set." + (isOwnSystem ? " To set one, type `pk;system color <color>`." : ""));
else if (matchedRaw)
await ctx.Reply("```\n#" + target.Color + "\n```");
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("System color")
@ -151,7 +155,7 @@ public class SystemEdit
ctx.CheckSystem().CheckOwnSystem(target);
if (await ctx.MatchClear())
if (matchedClear)
{
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Color = Partial<string>.Null() });
@ -273,7 +277,7 @@ public class SystemEdit
await ctx.Reply(
$"{Emojis.Success} System server tag changed. Member names will now end with {newTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'.");
if (!ctx.MessageContext.TagEnabled)
if (!settings.TagEnabled)
await ctx.Reply(setDisabledWarning);
}
@ -284,7 +288,7 @@ public class SystemEdit
await ctx.Reply(
$"{Emojis.Success} System server tag cleared. Member names will now end with the global system tag, if there is one set.");
if (!ctx.MessageContext.TagEnabled)
if (!settings.TagEnabled)
await ctx.Reply(setDisabledWarning);
}
@ -293,7 +297,7 @@ public class SystemEdit
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id,
new SystemGuildPatch { TagEnabled = newValue });
await ctx.Reply(PrintEnableDisableResult(newValue, newValue != ctx.MessageContext.TagEnabled));
await ctx.Reply(PrintEnableDisableResult(newValue, newValue != settings.TagEnabled));
}
string PrintEnableDisableResult(bool newValue, bool changedValue)
@ -308,20 +312,20 @@ public class SystemEdit
if (newValue)
{
if (ctx.MessageContext.TagEnabled)
if (settings.TagEnabled)
{
if (ctx.MessageContext.SystemGuildTag == null)
if (settings.Tag == null)
str +=
" However, you do not have a system tag specific to this server. Messages will be proxied using your global system tag, if there is one set.";
else
str +=
$" Your current system tag in '{ctx.Guild.Name}' is {ctx.MessageContext.SystemGuildTag.AsCode()}.";
$" Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}.";
}
else
{
if (ctx.MessageContext.SystemGuildTag != null)
if (settings.Tag != null)
str +=
$" Member names will now end with the server-specific tag {ctx.MessageContext.SystemGuildTag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'.";
$" Member names will now end with the server-specific tag {settings.Tag.AsCode()} when proxied in the current server '{ctx.Guild.Name}'.";
else
str +=
" Member names will now end with the global system tag when proxied in the current server, if there is one set.";
@ -529,8 +533,8 @@ public class SystemEdit
await ctx.Reply(
$"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{target.Hid}`).\n"
+$"**Note: this action is permanent,** but you will get a copy of your system's data that can be re-imported into PluralKit at a later date sent to you in DMs."
+" If you don't want this to happen, use `pk;s delete -no-export` instead.");
+ $"**Note: this action is permanent,** but you will get a copy of your system's data that can be re-imported into PluralKit at a later date sent to you in DMs."
+ " If you don't want this to happen, use `pk;s delete -no-export` instead.");
if (!await ctx.ConfirmWithReply(target.Hid))
throw new PKError(
$"System deletion cancelled. Note that you must reply with your system ID (`{target.Hid}`) *verbatim*.");

View file

@ -32,7 +32,7 @@ public class SystemLink
ulong id;
if (!ctx.MatchUserRaw(out id))
throw new PKSyntaxError("You must pass an account to link with (either ID or @mention).");
throw new PKSyntaxError("You must pass an account to unlink from (either ID or @mention).");
var accountIds = (await ctx.Repository.GetSystemAccounts(ctx.System.Id)).ToList();
if (!accountIds.Contains(id)) throw Errors.AccountNotLinked;

View file

@ -21,11 +21,14 @@ public class InteractionCreated: IEventHandler<InteractionCreateEvent>
if (evt.Type == Interaction.InteractionType.MessageComponent)
{
var customId = evt.Data?.CustomId;
if (customId != null)
{
if (customId == null) return;
var ctx = new InteractionContext(evt, _services);
if (customId.Contains("help-menu"))
await Help.ButtonClick(ctx);
else
await _interactionDispatch.Dispatch(customId, ctx);
}
}
}
}

View file

@ -63,7 +63,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return;
if (IsDuplicateMessage(evt)) return;
if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.SendMessages)) return;
var botPermissions = await _cache.PermissionsIn(evt.ChannelId);
if (!botPermissions.HasFlag(PermissionSet.SendMessages)) return;
// spawn off saving the private channel into another thread
// it is not a fatal error if this fails, and it shouldn't block message processing
@ -77,36 +78,33 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
_lastMessageCache.AddMessage(evt);
// Get message context from DB (tracking w/ metrics)
MessageContext ctx;
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel.Id);
// if the message was not sent by an user account, only try running log cleanup
if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true)
{
await TryHandleLogClean(channel, evt);
return;
}
// Try each handler until we find one that succeeds
if (await TryHandleLogClean(evt, ctx))
if (await TryHandleCommand(shardId, evt, guild, channel))
return;
// Only do command/proxy handling if it's a user account
if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true)
return;
if (await TryHandleCommand(shardId, evt, guild, channel, ctx))
return;
await TryHandleProxy(evt, guild, channel, ctx);
await TryHandleProxy(evt, guild, channel, rootChannel.Id, botPermissions);
}
private async ValueTask<bool> TryHandleLogClean(MessageCreateEvent evt, MessageContext ctx)
private async Task TryHandleLogClean(Channel channel, MessageCreateEvent evt)
{
var channel = await _cache.GetChannel(evt.ChannelId);
if (!evt.Author.Bot || channel.Type != Channel.ChannelType.GuildText ||
!ctx.LogCleanupEnabled) return false;
if (evt.GuildId == null) return;
if (channel.Type != Channel.ChannelType.GuildText) return;
var guildSettings = await _repo.GetGuild(evt.GuildId!.Value);
if (guildSettings.LogCleanupEnabled)
await _loggerClean.HandleLoggerBotCleanup(evt);
return true;
}
private async ValueTask<bool> TryHandleCommand(int shardId, MessageCreateEvent evt, Guild? guild,
Channel channel, MessageContext ctx)
private async ValueTask<bool> TryHandleCommand(int shardId, MessageCreateEvent evt, Guild? guild, Channel channel)
{
var content = evt.Content;
if (content == null) return false;
@ -117,17 +115,6 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (!HasCommandPrefix(content, ourUserId, out var cmdStart) || cmdStart == content.Length)
return false;
if (ctx.IsDeleting)
{
await _rest.CreateMessage(evt.ChannelId, new()
{
Content = $"{Emojis.Error} Your system is currently being deleted."
+ " Due to database issues, it is not possible to use commands while a system is being deleted. Please wait a few minutes and try again.",
MessageReference = new(guild?.Id, channel.Id, evt.Id)
});
return true;
}
// Trim leading whitespace from command without actually modifying the string
// This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string
var trimStartLengthDiff =
@ -136,9 +123,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
try
{
var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null;
var config = ctx.SystemId != null ? await _repo.GetSystemConfig(ctx.SystemId.Value) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, ctx));
var system = await _repo.GetSystemByAccount(evt.Author.Id);
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config));
}
catch (PKError)
{
@ -169,17 +156,16 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
return false;
}
private async ValueTask<bool> TryHandleProxy(MessageCreateEvent evt, Guild guild, Channel channel,
MessageContext ctx)
private async ValueTask<bool> TryHandleProxy(MessageCreateEvent evt, Guild guild, Channel channel, ulong rootChannel, PermissionSet botPermissions)
{
if (ctx.IsDeleting) return false;
var botPermissions = await _cache.PermissionsIn(channel.Id);
// Get message context from DB (tracking w/ metrics)
MessageContext ctx;
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel);
try
{
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, ctx.AllowAutoproxy,
botPermissions);
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, true, botPermissions);
}
// Catch any failed proxy checks so they get ignored in the global error handler

View file

@ -73,10 +73,10 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
return;
}
var commandMsg = await _commandMessageService.GetCommandMessage(evt.MessageId);
if (commandMsg != null)
var (authorId, _) = await _commandMessageService.GetCommandMessage(evt.MessageId);
if (authorId != null)
{
await HandleCommandDeleteReaction(evt, commandMsg);
await HandleCommandDeleteReaction(evt, authorId.Value);
return;
}
}
@ -141,11 +141,11 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
await _repo.DeleteMessage(evt.MessageId);
}
private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEvent evt, CommandMessage? msg)
private async ValueTask HandleCommandDeleteReaction(MessageReactionAddEvent evt, ulong? authorId)
{
// Can only delete your own message
// (except in DMs, where msg will be null)
if (msg != null && msg.AuthorId != evt.UserId)
if (authorId != null && authorId != evt.UserId)
return;
// todo: don't try to delete the user's own messages in DMs

View file

@ -20,6 +20,10 @@ public class Init
{
private static async Task Main(string[] args)
{
// set cluster config from Nomad node index env variable
if (Environment.GetEnvironmentVariable("NOMAD_ALLOC_INDEX") is { } nodeIndex)
Environment.SetEnvironmentVariable("PluralKit__Bot__Cluster__NodeName", $"pluralkit-{nodeIndex}");
// Load configuration and run global init stuff
var config = InitUtils.BuildConfiguration(args).Build();
InitUtils.InitStatic();

View file

@ -24,7 +24,7 @@
<ItemGroup>
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.0" />
<PackageReference Include="Grpc.Tools" Version="2.37.0" PrivateAssets="All"/>
<PackageReference Include="Grpc.Tools" Version="2.47.0" PrivateAssets="all" />
<PackageReference Include="Humanizer.Core" Version="2.8.26"/>
<PackageReference Include="Sentry" Version="3.11.1"/>
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2"/>

View file

@ -1,6 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commands/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=commandsystem/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=handlers/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=utils/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -43,6 +43,10 @@ public class ProxyMatcher
{
match = default;
if (!ctx.AllowAutoproxy)
throw new ProxyService.ProxyChecksFailedException(
"Autoproxy is disabled for your account. Type `pk;cfg autoproxy account enable` to re-enable it.");
// Skip autoproxy match if we hit the escape character
if (messageContent.StartsWith(AutoproxyEscapeCharacter))
throw new ProxyService.ProxyChecksFailedException(

View file

@ -198,7 +198,7 @@ public class ProxyService
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
}
public async Task ExecuteReproxy(Message trigger, PKMessage msg, ProxyMember member)
public async Task ExecuteReproxy(Message trigger, PKMessage msg, List<ProxyMember> members, ProxyMember member)
{
var originalMsg = await _rest.GetMessageOrNull(msg.Channel, msg.Mid);
if (originalMsg == null)
@ -213,24 +213,34 @@ public class ProxyService
throw new ProxyChecksFailedException(
"Proxying was disabled in this channel by a server administrator (via the proxy blacklist).");
var autoproxySettings = await _repo.GetAutoproxySettings(ctx.SystemId.Value, msg.Guild!.Value, null);
var prevMatched = _matcher.TryMatch(ctx, autoproxySettings, members, out var prevMatch, originalMsg.Content,
originalMsg.Attachments.Length > 0, false);
var match = new ProxyMatch
{
Member = member,
Content = prevMatched ? prevMatch.Content : originalMsg.Content,
ProxyTags = member.ProxyTags.FirstOrDefault(),
};
var messageChannel = await _rest.GetChannelOrNull(msg.Channel!);
var rootChannel = await _rest.GetChannelOrNull(messageChannel.IsThread() ? messageChannel.ParentId!.Value : messageChannel.Id);
var rootChannel = messageChannel.IsThread() ? await _rest.GetChannelOrNull(messageChannel.ParentId!.Value) : messageChannel;
var threadId = messageChannel.IsThread() ? messageChannel.Id : (ulong?)null;
var guild = await _rest.GetGuildOrNull(msg.Guild!.Value);
var guildMember = await _rest.GetGuildMember(msg.Guild!.Value, trigger.Author.Id);
// Grab user permissions
var senderPermissions = PermissionExtensions.PermissionsFor(guild, rootChannel, trigger.Author.Id, null);
var senderPermissions = PermissionExtensions.PermissionsFor(guild, rootChannel, trigger.Author.Id, guildMember);
var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone);
// Make sure user has permissions to send messages
if (!senderPermissions.HasFlag(PermissionSet.SendMessages))
throw new PKError("You don't have permission to send messages in the channel that message is in.");
// Mangle embeds (for reply embed color changing)
var mangledEmbeds = originalMsg.Embeds!.Select(embed => MangleReproxyEmbed(embed, member)).Where(embed => embed != null).ToArray();
// Send the reproxied webhook
var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest
{
@ -239,15 +249,15 @@ public class ProxyService
ThreadId = threadId,
Name = match.Member.ProxyName(ctx),
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
Content = originalMsg.Content!,
Content = match.ProxyContent!,
Attachments = originalMsg.Attachments!,
FileSizeLimit = guild.FileSizeLimit(),
Embeds = originalMsg.Embeds!.ToArray(),
Embeds = mangledEmbeds,
Stickers = originalMsg.StickerItems!,
AllowEveryone = allowEveryone
});
var autoproxySettings = await _repo.GetAutoproxySettings(ctx.SystemId.Value, msg.Guild!.Value, null);
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match, deletePrevious: false);
await _rest.DeleteMessage(originalMsg.ChannelId!, originalMsg.Id!);
}
@ -271,6 +281,34 @@ public class ProxyService
}
}
private Embed? MangleReproxyEmbed(Embed embed, ProxyMember member)
{
// XXX: This is a naïve implementation of detecting reply embeds: looking for the same Unicode
// characters as used in the reply embed generation, since we don't _really_ have a good way
// to detect whether an embed is a PluralKit reply embed right now, whether a message is in
// reply to another message isn't currently stored anywhere in the database.
//
// unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation]
if (embed.Author != null && embed.Author!.Name.EndsWith("\u2004\u21a9\ufe0f"))
{
return new Embed
{
Type = "rich",
Author = embed.Author!,
Description = embed.Description!,
Color = member.Color?.ToDiscordColor()
};
}
// XXX: remove non-rich embeds as including them breaks link embeds completely
else if (embed.Type != "rich")
{
return null;
}
return embed;
}
private Embed CreateReplyEmbed(ProxyMatch match, Message trigger, Message repliedTo, string? nickname,
string? avatar)
{
@ -377,8 +415,8 @@ public class ProxyService
{
var sentMessage = new PKMessage
{
Channel = triggerMessage.ChannelId,
Guild = triggerMessage.GuildId,
Channel = proxyMessage.ChannelId,
Guild = proxyMessage.GuildId,
Member = match.Member.Id,
Mid = proxyMessage.Id,
OriginalMid = triggerMessage.Id,
@ -389,7 +427,7 @@ public class ProxyService
=> _repo.AddMessage(sentMessage);
Task LogMessageToChannel() =>
_logChannel.LogMessage(ctx, sentMessage, triggerMessage, proxyMessage).AsTask();
_logChannel.LogMessage(sentMessage, triggerMessage, proxyMessage).AsTask();
Task SaveLatchAutoproxy() => autoproxySettings.AutoproxyMode == AutoproxyMode.Latch
? _repo.UpdateAutoproxy(ctx.SystemId.Value, triggerMessage.GuildId, null, new()

View file

@ -78,7 +78,7 @@ public class ProxyTagParser
// We got a match, extract inner text
inner = input.Substring(prefix.Length, input.Length - prefix.Length - suffix.Length);
// (see https://github.com/xSke/PluralKit/pull/181)
// (see https://github.com/PluralKit/PluralKit/pull/181)
return inner.Trim() != "\U0000fe0f";
}

View file

@ -8,28 +8,36 @@ namespace PluralKit.Bot;
public class CommandMessageService
{
private readonly IClock _clock;
private readonly IDatabase _db;
private readonly RedisService _redis;
private readonly ILogger _logger;
private readonly ModelRepository _repo;
private static readonly TimeSpan CommandMessageRetention = TimeSpan.FromHours(24);
public CommandMessageService(IDatabase db, ModelRepository repo, IClock clock, ILogger logger)
public CommandMessageService(RedisService redis, IClock clock, ILogger logger)
{
_db = db;
_repo = repo;
_clock = clock;
_redis = redis;
_logger = logger.ForContext<CommandMessageService>();
}
public async Task RegisterMessage(ulong messageId, ulong channelId, ulong authorId)
{
if (_redis.Connection == null) return;
_logger.Debug(
"Registering command response {MessageId} from author {AuthorId} in {ChannelId}",
messageId, authorId, channelId
);
await _repo.SaveCommandMessage(messageId, channelId, authorId);
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}", expiry: CommandMessageRetention);
}
public async Task<CommandMessage?> GetCommandMessage(ulong messageId) =>
await _repo.GetCommandMessage(messageId);
public async Task<(ulong?, ulong?)> GetCommandMessage(ulong messageId)
{
var str = await _redis.Connection.GetDatabase().StringGetAsync(messageId.ToString());
if (str.HasValue)
{
var split = ((string)str).Split("-");
return (ulong.Parse(split[0]), ulong.Parse(split[1]));
}
return (null, null);
}
}

View file

@ -72,7 +72,8 @@ public class EmbedService
.Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl()))
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(color);
.Color(color)
.Url($"https://dash.pluralkit.me/profile/s/{system.Hid}");
if (system.DescriptionPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(system.BannerImage));
@ -143,6 +144,9 @@ public class EmbedService
$"System ID: {systemHid} | Member ID: {member.Hid} | Sender: {triggerMessage.Author.Username}#{triggerMessage.Author.Discriminator} ({triggerMessage.Author.Id}) | Message ID: {proxiedMessage.Id} | Original Message ID: {triggerMessage.Id}"))
.Timestamp(timestamp.ToDateTimeOffset().ToString("O"));
if (oldContent == "")
oldContent = "*no message content*";
if (oldContent != null)
embed.Field(new Embed.Field("Old message", oldContent?.NormalizeLineEndSpacing().Truncate(1000)));
@ -179,8 +183,7 @@ public class EmbedService
.ToListAsync();
var eb = new EmbedBuilder()
// TODO: add URL of website when that's up
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl()))
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
.Color(color)
.Footer(new Embed.EmbedFooter(
@ -264,7 +267,7 @@ public class EmbedService
}
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx)))
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
.Color(color);
eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));

View file

@ -34,17 +34,16 @@ public class LogChannelService
_logger = logger.ForContext<LogChannelService>();
}
public async ValueTask LogMessage(MessageContext ctx, PKMessage proxiedMessage, Message trigger,
Message hookMessage, string oldContent = null)
public async ValueTask LogMessage(PKMessage proxiedMessage, Message trigger, Message hookMessage, string oldContent = null)
{
var logChannelId = await GetAndCheckLogChannel(ctx, trigger, proxiedMessage);
var logChannelId = await GetAndCheckLogChannel(trigger, proxiedMessage);
if (logChannelId == null)
return;
var triggerChannel = await _cache.GetChannel(proxiedMessage.Channel);
var system = await _repo.GetSystem(ctx.SystemId.Value);
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
var system = await _repo.GetSystem(member.System);
// Send embed!
var embed = _embed.CreateLoggedMessageEmbed(trigger, hookMessage, system.Hid, member, triggerChannel.Name,
@ -54,8 +53,7 @@ public class LogChannelService
await _rest.CreateMessage(logChannelId.Value, new MessageRequest { Content = url, Embeds = new[] { embed } });
}
private async Task<ulong?> GetAndCheckLogChannel(MessageContext ctx, Message trigger,
PKMessage proxiedMessage)
private async Task<ulong?> GetAndCheckLogChannel(Message trigger, PKMessage proxiedMessage)
{
if (proxiedMessage.Guild == null && proxiedMessage.Channel != trigger.ChannelId)
// a very old message is being edited outside of its original channel
@ -63,18 +61,15 @@ public class LogChannelService
return null;
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
var logChannelId = ctx.LogChannel;
var isBlacklisted = ctx.InLogBlacklist;
if (proxiedMessage.Guild != trigger.GuildId)
{
// we're editing a message from a different server, get log channel info from the database
var guild = await _repo.GetGuild(proxiedMessage.Guild.Value);
logChannelId = guild.LogChannel;
isBlacklisted = guild.LogBlacklist.Any(x => x == trigger.ChannelId);
}
// get log channel info from the database
var guild = await _repo.GetGuild(guildId);
var logChannelId = guild.LogChannel;
var isBlacklisted = guild.LogBlacklist.Any(x => x == trigger.ChannelId);
if (ctx.SystemId == null || logChannelId == null || isBlacklisted) return null;
// if (ctx.SystemId == null ||
// removed the above, there shouldn't be a way to get to this code path if you don't have a system registered
if (logChannelId == null || isBlacklisted) return null;
// Find log channel and check if valid
var logChannel = await FindLogChannel(guildId, logChannelId.Value);
@ -86,7 +81,7 @@ public class LogChannelService
{
_logger.Information(
"Does not have permission to log proxy, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})",
logChannel.Id, trigger.GuildId!.Value, perms);
logChannel.Id, guildId, perms);
return null;
}

View file

@ -1,3 +1,5 @@
using App.Metrics;
using Serilog;
using Myriad.Gateway;
@ -9,13 +11,13 @@ namespace PluralKit.Bot;
public class PrivateChannelService
{
private static readonly Dictionary<ulong, ulong> _channelsCache = new();
private readonly IMetrics _metrics;
private readonly ILogger _logger;
private readonly ModelRepository _repo;
private readonly DiscordApiClient _rest;
public PrivateChannelService(ILogger logger, ModelRepository repo, DiscordApiClient rest)
public PrivateChannelService(IMetrics metrics, ILogger logger, ModelRepository repo, DiscordApiClient rest)
{
_metrics = metrics;
_logger = logger;
_repo = repo;
_rest = rest;
@ -23,35 +25,32 @@ public class PrivateChannelService
public async Task TrySavePrivateChannel(MessageCreateEvent evt)
{
if (evt.GuildId != null) return;
if (_channelsCache.TryGetValue(evt.Author.Id, out _)) return;
await SaveDmChannel(evt.Author.Id, evt.ChannelId);
if (evt.GuildId == null) await SaveDmChannel(evt.Author.Id, evt.ChannelId);
}
public async Task<ulong> GetOrCreateDmChannel(ulong userId)
{
if (_channelsCache.TryGetValue(userId, out var cachedChannelId))
return cachedChannelId;
var channelId = await _repo.GetDmChannel(userId);
if (channelId == null)
if (channelId != null)
{
var channel = await _rest.CreateDm(userId);
channelId = channel.Id;
_metrics.Measure.Meter.Mark(BotMetrics.DatabaseDMCacheHits);
return channelId.Value;
}
// spawn off saving the channel as to not block the current thread
_ = SaveDmChannel(userId, channelId.Value);
_metrics.Measure.Meter.Mark(BotMetrics.DMCacheMisses);
return channelId.Value;
var channel = await _rest.CreateDm(userId);
// spawn off saving the channel as to not block the current thread
_ = SaveDmChannel(userId, channel.Id);
return channel.Id;
}
private async Task SaveDmChannel(ulong userId, ulong channelId)
{
try
{
_channelsCache.Add(userId, channelId);
await _repo.UpdateAccount(userId, new() { DmChannel = channelId });
}
catch (Exception e)

View file

@ -174,7 +174,8 @@ public class WebhookExecutorService
// We don't care about whether the sending succeeds, and we don't want to *wait* for it, so we just fork it off
var _ = TrySendRemainingAttachments(webhook, req.Name, req.AvatarUrl, attachmentChunks, req.ThreadId);
return webhookMessage;
// for some reason discord may(?) return a null guildid here???
return webhookMessage with { GuildId = webhookMessage.GuildId ?? req.GuildId };
}
private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl,

View file

@ -36,7 +36,7 @@ public class InteractionContext
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
new InteractionApplicationCommandCallbackData
{
// Components = _evt.Message.Components
Components = Event.Message.Components
});
}

View file

@ -17,10 +17,12 @@ public class SentryEnricher:
ISentryEnricher<MessageReactionAddEvent>
{
private readonly Bot _bot;
private readonly BotConfig _config;
public SentryEnricher(Bot bot)
public SentryEnricher(Bot bot, BotConfig config)
{
_bot = bot;
_config = config;
}
// TODO: should this class take the Scope by dependency injection instead?
@ -37,6 +39,8 @@ public class SentryEnricher:
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shardId.ToString());
if (_config.Cluster != null)
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
// Also report information about the bot's permissions in the channel
// We get a lot of permission errors so this'll be useful for determining problems
@ -56,6 +60,8 @@ public class SentryEnricher:
{"messages", string.Join(",", evt.Ids)}
});
scope.SetTag("shard", shardId.ToString());
if (_config.Cluster != null)
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
}
public void Enrich(Scope scope, int shardId, MessageUpdateEvent evt)
@ -68,6 +74,8 @@ public class SentryEnricher:
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shardId.ToString());
if (_config.Cluster != null)
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
}
public void Enrich(Scope scope, int shardId, MessageDeleteEvent evt)
@ -80,6 +88,8 @@ public class SentryEnricher:
{"message", evt.Id.ToString()}
});
scope.SetTag("shard", shardId.ToString());
if (_config.Cluster != null)
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
}
public void Enrich(Scope scope, int shardId, MessageReactionAddEvent evt)
@ -94,5 +104,7 @@ public class SentryEnricher:
{"reaction", evt.Emoji.Name}
});
scope.SetTag("shard", shardId.ToString());
if (_config.Cluster != null)
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
}
}

View file

@ -24,9 +24,9 @@
},
"Grpc.Tools": {
"type": "Direct",
"requested": "[2.37.0, )",
"resolved": "2.37.0",
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
"requested": "[2.47.0, )",
"resolved": "2.47.0",
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
},
"Humanizer.Core": {
"type": "Direct",

View file

@ -5,6 +5,7 @@ namespace PluralKit.Core;
public class CoreConfig
{
public string Database { get; set; }
public string? DatabasePassword { get; set; }
public string RedisAddr { get; set; }
public bool UseRedisMetrics { get; set; } = false;
public string SentryUrl { get; set; }

View file

@ -35,7 +35,7 @@ internal partial class Database: IDatabase
_migrator = migrator;
_logger = logger.ForContext<Database>();
_connectionString = new NpgsqlConnectionStringBuilder(_config.Database)
var connectionString = new NpgsqlConnectionStringBuilder(_config.Database)
{
Pooling = true,
Enlist = false,
@ -43,7 +43,12 @@ internal partial class Database: IDatabase
// Lower timeout than default (15s -> 2s), should ideally fail-fast instead of hanging
Timeout = 2
}.ConnectionString;
};
if (_config.DatabasePassword != null)
connectionString.Password = _config.DatabasePassword;
_connectionString = connectionString.ConnectionString;
}
private static readonly PostgresCompiler _compiler = new();

View file

@ -14,7 +14,6 @@ public class MessageContext
/// <summary>
/// Whether a system is being deleted (no actions should be taken, or commands ran)
/// </summary>
public bool IsDeleting { get; }
public ulong? LogChannel { get; }
public bool InBlacklist { get; }
public bool InLogBlacklist { get; }

View file

@ -1,7 +1,6 @@
create function message_context(account_id bigint, guild_id bigint, channel_id bigint)
returns table (
system_id int,
is_deleting bool,
log_channel bigint,
in_blacklist bool,
in_log_blacklist bool,
@ -28,7 +27,6 @@ as $$
guild as (select * from servers where id = guild_id)
select
system.id as system_id,
system.is_deleting,
guild.log_channel,
(channel_id = any (guild.blacklist)) as in_blacklist,
(channel_id = any (guild.log_blacklist)) as in_log_blacklist,

View file

@ -9,6 +9,9 @@ public partial class ModelRepository
public async Task<ulong?> GetDmChannel(ulong id)
=> await _db.Execute(c => c.QueryFirstOrDefaultAsync<ulong?>("select dm_channel from accounts where uid = @id", new { id = id }));
public async Task<bool> GetAutoproxyEnabled(ulong id)
=> await _db.QueryFirst<bool>(new Query("accounts").Select("allow_autoproxy").Where("uid", id));
public async Task UpdateAccount(ulong id, AccountPatch patch)
{
_logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch);

View file

@ -6,7 +6,7 @@ namespace PluralKit.Core;
public partial class ModelRepository
{
public async Task UpdateAutoproxy(SystemId system, ulong? guildId, ulong? channelId, AutoproxyPatch patch)
public Task<AutoproxySettings> UpdateAutoproxy(SystemId system, ulong? guildId, ulong? channelId, AutoproxyPatch patch)
{
var locationStr = guildId != null ? "guild" : (channelId != null ? "channel" : "global");
_logger.Information("Updated autoproxy for {SystemId} in location {location}: {@AutoproxyPatch}", system, locationStr, patch);
@ -17,7 +17,7 @@ public partial class ModelRepository
.Where("channel_id", channelId ?? 0)
);
_ = _dispatch.Dispatch(system, guildId, channelId, patch);
await _db.ExecuteQuery(query);
return _db.QueryFirst<AutoproxySettings>(query, "returning *");
}
// todo: this might break with differently scoped autoproxy

View file

@ -1,36 +0,0 @@
using SqlKata;
namespace PluralKit.Core;
public partial class ModelRepository
{
public Task SaveCommandMessage(ulong messageId, ulong channelId, ulong authorId)
{
var query = new Query("command_messages").AsInsert(new
{
message_id = messageId,
channel_id = channelId,
author_id = authorId,
});
return _db.ExecuteQuery(query);
}
public Task<CommandMessage?> GetCommandMessage(ulong messageId)
{
var query = new Query("command_messages").Where("message_id", messageId);
return _db.QueryFirst<CommandMessage?>(query);
}
public Task<int> DeleteCommandMessagesBefore(ulong messageIdThreshold)
{
var query = new Query("command_messages").AsDelete().Where("message_id", "<", messageIdThreshold);
return _db.QueryFirst<int>(query);
}
}
public class CommandMessage
{
public ulong AuthorId { get; set; }
public ulong MessageId { get; set; }
public ulong ChannelId { get; set; }
}

View file

@ -4,20 +4,6 @@ namespace PluralKit.Core;
public partial class ModelRepository
{
public async Task UpdateStats()
{
await _db.Execute(conn =>
conn.ExecuteAsync("update info set system_count = (select count(*) from systems)"));
await _db.Execute(conn =>
conn.ExecuteAsync("update info set member_count = (select count(*) from members)"));
await _db.Execute(conn =>
conn.ExecuteAsync("update info set group_count = (select count(*) from groups)"));
await _db.Execute(conn =>
conn.ExecuteAsync("update info set switch_count = (select count(*) from switches)"));
// await _db.Execute(conn =>
// conn.ExecuteAsync("update info set message_count = (select count(*) from messages)"));
}
public Task<Counts> GetStats()
=> _db.Execute(conn => conn.QuerySingleAsync<Counts>("select * from info"));

View file

@ -92,11 +92,11 @@ public partial class ModelRepository
_logger.Information("Updated {SwitchId} members: {Members}", switchId, members);
}
public async Task MoveSwitch(SwitchId id, Instant time)
public async Task<PKSwitch> MoveSwitch(SwitchId id, Instant time)
{
_logger.Information("Updated {SwitchId} timestamp: {SwitchTimestamp}", id, time);
var query = new Query("switches").AsUpdate(new { timestamp = time }).Where("id", id);
await _db.ExecuteQuery(query);
var ret = await _db.QueryFirst<PKSwitch>(query, extraSql: "returning *");
_ = _dispatch.Dispatch(id, new UpdateDispatchData
{
Event = DispatchEvent.UPDATE_SWITCH,
@ -105,6 +105,7 @@ public partial class ModelRepository
timestamp = time.FormatExport(),
}),
});
return ret;
}
public async Task DeleteSwitch(SwitchId id)

View file

@ -144,7 +144,6 @@ public partial class ModelRepository
public async Task DeleteSystem(SystemId id)
{
await _db.Execute(c => c.QueryAsync("update systems set is_deleting = true where id = @id", new { id = id }));
var query = new Query("systems").AsDelete().Where("id", id);
await _db.ExecuteQuery(query);
_logger.Information("Deleted {SystemId}", id);

View file

@ -16,7 +16,7 @@ public class AutoproxySettings
{
public AutoproxyMode AutoproxyMode { get; }
public MemberId? AutoproxyMember { get; }
public Instant LastLatchTimestamp { get; }
public Instant? LastLatchTimestamp { get; }
}
public static class AutoproxyExt
@ -27,7 +27,8 @@ public static class AutoproxyExt
// tbd
o.Add("autoproxy_mode", settings.AutoproxyMode.ToString().ToLower());
o.Add("autoproxy_member", memberHid);
o.Add("autoproxy_member", settings.AutoproxyMode == AutoproxyMode.Front ? null : memberHid);
o.Add("last_latch_timestamp", settings.LastLatchTimestamp?.FormatExport());
return o;
}

View file

@ -1,3 +1,5 @@
using Newtonsoft.Json.Linq;
using NodaTime;
using SqlKata;
@ -16,4 +18,30 @@ public class AutoproxyPatch: PatchObject
.With("autoproxy_member", AutoproxyMember)
.With("last_latch_timestamp", LastLatchTimestamp)
);
public new void AssertIsValid()
{
// this is checked in FromJson
// not really the best way to do this, maybe fix at some point?
if ((int?)AutoproxyMode.Value == -1)
Errors.Add(new("autoproxy_mode"));
}
public static AutoproxyPatch FromJson(JObject o, MemberId? autoproxyMember = null)
{
var p = new AutoproxyPatch();
if (o.ContainsKey("autoproxy_mode"))
{
var (autoproxyMode, error) = o.Value<JToken>("autoproxy_mode").ParseAutoproxyMode();
if (error != null)
p.AutoproxyMode = Partial<AutoproxyMode>.Present((AutoproxyMode)(-1));
else
p.AutoproxyMode = autoproxyMode.Value;
}
p.AutoproxyMember = autoproxyMember ?? Partial<MemberId?>.Absent;
return p;
}
}

View file

@ -1,5 +0,0 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=migrations/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=models/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=schema/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=services/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -30,10 +30,9 @@ public partial class BulkImporter
await _repo.UpdateSystem(_system.Id, patch, _conn);
var configPatch = new SystemConfigPatch();
if (importFile.ContainsKey("config"))
configPatch = SystemConfigPatch.FromJson(importFile.Value<JObject>("config"));
{
var configPatch = SystemConfigPatch.FromJson(importFile.Value<JObject>("config"));
if (importFile.ContainsKey("timezone"))
configPatch.UiTz = importFile.Value<string>("timezone");
@ -43,10 +42,11 @@ public partial class BulkImporter
throw new ImportException($"Field config.{patch.Errors[0].Key} in export file is invalid.");
await _repo.UpdateSystemConfig(_system.Id, configPatch, _conn);
}
var members = importFile.Value<JArray>("members");
var groups = importFile.Value<JArray>("groups");
var switches = importFile.Value<JArray>("switches");
var groups = importFile.Value<JArray>("groups");
var newMembers = members.Count(m =>
{

View file

@ -1,25 +0,0 @@
using App.Metrics;
using App.Metrics.Gauge;
public static class Metrics
{
public static GaugeOptions Guilds => new()
{
Name = "Guilds",
MeasurementUnit = Unit.None,
Context = "Bot"
};
public static GaugeOptions Channels => new()
{
Name = "Channels",
MeasurementUnit = Unit.None,
Context = "Bot"
};
public static GaugeOptions WebhookCacheSize => new()
{
Name = "Webhook Cache Size",
Context = "Bot",
MeasurementUnit = Unit.Items
};
}

View file

@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PluralKit.Core\PluralKit.Core.csproj"/>
</ItemGroup>
<PropertyGroup>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>

View file

@ -1,45 +0,0 @@
using System.Threading.Tasks;
using Autofac;
using Microsoft.Extensions.Configuration;
using PluralKit.Core;
namespace PluralKit.ScheduledTasks;
internal class Startup
{
private static async Task Main(string[] args)
{
// Load configuration and run global init stuff
var config = InitUtils.BuildConfiguration(args).Build();
InitUtils.InitStatic();
await BuildInfoService.LoadVersion();
var services = BuildContainer(config);
var cfg = services.Resolve<CoreConfig>();
if (cfg.UseRedisMetrics)
await services.Resolve<RedisService>().InitAsync(cfg);
services.Resolve<TaskHandler>().Run();
await Task.Delay(-1);
}
private static IContainer BuildContainer(IConfiguration config)
{
var builder = new ContainerBuilder();
builder.RegisterInstance(config);
builder.RegisterModule(new ConfigModule<CoreConfig>());
builder.RegisterModule(new LoggingModule("ScheduledTasks"));
builder.RegisterModule(new MetricsModule());
builder.RegisterModule<DataStoreModule>();
builder.RegisterType<TaskHandler>().AsSelf().SingleInstance();
return builder.Build();
}
}

View file

@ -1,126 +0,0 @@
using System;
using System.Linq;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using App.Metrics;
using NodaTime;
using NodaTime.Extensions;
using Newtonsoft.Json;
using PluralKit.Core;
using Serilog;
namespace PluralKit.ScheduledTasks;
public class TaskHandler
{
private static readonly Duration CommandMessageRetention = Duration.FromHours(24);
private readonly IDatabase _db;
private readonly RedisService _redis;
private readonly bool _useRedisMetrics;
private readonly ILogger _logger;
private readonly IMetrics _metrics;
private readonly ModelRepository _repo;
private Timer _periodicTask;
public TaskHandler(ILogger logger, IMetrics metrics, CoreConfig config, IDatabase db, RedisService redis, ModelRepository repo)
{
_logger = logger;
_metrics = metrics;
_db = db;
_redis = redis;
_repo = repo;
_useRedisMetrics = config.UseRedisMetrics;
}
public void Run()
{
_logger.Information("Starting scheduled task runner...");
var timeNow = SystemClock.Instance.GetCurrentInstant();
var timeTillNextWholeMinute =
TimeSpan.FromMilliseconds(60000 - timeNow.ToUnixTimeMilliseconds() % 60000 + 250);
_periodicTask = new Timer(_ =>
{
var __ = UpdatePeriodic();
}, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1));
}
private async Task UpdatePeriodic()
{
_logger.Information("Running per-minute scheduled tasks.");
var stopwatch = new Stopwatch();
stopwatch.Start();
_logger.Information("Updating database stats...");
await _repo.UpdateStats();
// Collect bot cluster statistics from Redis (if it's enabled)
if (_useRedisMetrics)
await CollectBotStats();
// Clean up message cache in postgres
await CleanupOldMessages();
stopwatch.Stop();
_logger.Information("Ran scheduled tasks in {Time}", stopwatch.ElapsedDuration());
}
private async Task CollectBotStats()
{
var redisStats = await _redis.Connection.GetDatabase().HashGetAllAsync("pluralkit:cluster_stats");
var stats = redisStats.Select(v => JsonConvert.DeserializeObject<ClusterMetricInfo>(v.Value));
_metrics.Measure.Gauge.SetValue(Metrics.Guilds, stats.Sum(x => x.GuildCount));
_metrics.Measure.Gauge.SetValue(Metrics.Channels, stats.Sum(x => x.ChannelCount));
// Aggregate DB stats
// just fetching from database here - actual updating of the data is done elsewiere
var counts = await _repo.GetStats();
_metrics.Measure.Gauge.SetValue(CoreMetrics.SystemCount, counts.SystemCount);
_metrics.Measure.Gauge.SetValue(CoreMetrics.MemberCount, counts.MemberCount);
_metrics.Measure.Gauge.SetValue(CoreMetrics.GroupCount, counts.GroupCount);
_metrics.Measure.Gauge.SetValue(CoreMetrics.SwitchCount, counts.SwitchCount);
_metrics.Measure.Gauge.SetValue(CoreMetrics.MessageCount, counts.MessageCount);
// Database info
// this is pretty much always inaccurate but oh well
_metrics.Measure.Gauge.SetValue(CoreMetrics.DatabaseConnections, stats.Sum(x => x.DatabaseConnectionCount));
foreach (var stat in redisStats)
_metrics.Measure.Gauge.SetValue(
CoreMetrics.DatabaseConnectionsByCluster,
new MetricTags("cluster_id", stat.Name),
JsonConvert.DeserializeObject<ClusterMetricInfo>(stat.Value).DatabaseConnectionCount
);
// Other shiz
_metrics.Measure.Gauge.SetValue(Metrics.WebhookCacheSize, stats.Sum(x => x.WebhookCacheSize));
await Task.WhenAll(((IMetricsRoot)_metrics).ReportRunner.RunAllAsync());
_logger.Debug("Submitted metrics to backend");
}
private async Task CleanupOldMessages()
{
var deleteThresholdInstant = SystemClock.Instance.GetCurrentInstant() - CommandMessageRetention;
var deleteThresholdSnowflake = InstantToSnowflake(deleteThresholdInstant);
var deletedRows = await _repo.DeleteCommandMessagesBefore(deleteThresholdSnowflake);
_logger.Information(
"Pruned {DeletedRows} command messages older than retention {Retention} (older than {DeleteThresholdInstant} / {DeleteThresholdSnowflake})",
deletedRows, CommandMessageRetention, deleteThresholdInstant, deleteThresholdSnowflake);
}
// we don't have access to PluralKit.Bot here, so this needs to be vendored
public static ulong InstantToSnowflake(Instant time) =>
(ulong)(time - Instant.FromUtc(2015, 1, 1, 0, 0, 0)).TotalMilliseconds << 22;
}

File diff suppressed because it is too large Load diff

View file

@ -11,8 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Tests", "PluralKi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Myriad", "Myriad\Myriad.csproj", "{ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.ScheduledTasks", "PluralKit.ScheduledTasks\PluralKit.ScheduledTasks.csproj", "{374A8EB3-655D-4230-982B-459AE3553991}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

View file

@ -11,7 +11,7 @@ Running the bot requires [.NET 5](https://dotnet.microsoft.com/download) and a P
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.
# Configuration
Configuring the bot is done through a JSON configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/xSke/PluralKit/blob/master/pluralkit.conf.example).
Configuring the bot is done through a JSON configuration file. An example of the configuration format can be seen in [`pluralkit.conf.example`](https://github.com/PluralKit/PluralKit/blob/master/pluralkit.conf.example).
The configuration file needs to be placed in the bot's working directory (usually the repository root) and must be called `pluralkit.conf`.
The configuration file is in JSON format (albeit with a `.conf` extension). The following keys are available (using `.` to indicate a nested object level), bolded key names are required:
@ -31,7 +31,7 @@ The bot can also take configuration from environment variables, which will overr
## Docker
The easiest way to get the bot running is with Docker. The repository contains a `docker-compose.yml` file ready to use.
* Clone this repository: `git clone https://github.com/xSke/PluralKit`
* Clone this repository: `git clone https://github.com/PluralKit/PluralKit`
* Create a `pluralkit.conf` file in the same directory as `docker-compose.yml` containing at least a `PluralKit.Bot.Token` field
* (`PluralKit.Database` is overridden in `docker-compose.yml` to point to the Postgres container)
* Build the bot: `docker-compose build`
@ -39,7 +39,7 @@ The easiest way to get the bot running is with Docker. The repository contains a
In other words:
```
$ git clone https://github.com/xSke/PluralKit
$ git clone https://github.com/PluralKit/PluralKit
$ cd PluralKit
$ cp pluralkit.conf.example pluralkit.conf
$ nano pluralkit.conf # (or vim, or whatever)
@ -48,7 +48,7 @@ $ docker-compose up -d
## Manually
* Install the .NET 6 SDK (see https://dotnet.microsoft.com/download)
* Clone this repository: `git clone https://github.com/xSke/PluralKit`
* Clone this repository: `git clone https://github.com/PluralKit/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`
* Alternatively, `dotnet build -c Release -o build/`, then `dotnet build/PluralKit.Bot.dll`

3
dashboard/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
dist/
node_modules/
dashboard

19
dashboard/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM alpine:latest as builder
RUN apk add nodejs-current yarn go git
COPY dashboard/ /build
COPY .git/ /build/.git
WORKDIR /build
RUN yarn install --frozen-lockfile
RUN yarn build
RUN sh -c 'go build -ldflags "-X main.version=$(git rev-parse HEAD)"'
FROM alpine:latest
COPY --from=builder /build/dashboard /bin/dashboard
ENTRYPOINT /bin/dashboard

12
dashboard/README.md Normal file
View file

@ -0,0 +1,12 @@
# PluralKit Dashboard
This project is built using [Vite](https://vitejs.dev/), using the svelte-ts template.
Some of the other stuff used to get this working:
* sveltestrap (https://sveltestrap.js.org/)
* svelte-navigator (https://github.com/mefechoel/svelte-navigator)
* svelte-toggle (https://github.com/metonym/svelte-toggle)
* svelecte (https://mskocik.github.io/svelecte/)
* svelte-icons (https://github.com/Introvertuous/svelte-icons)
* discord-markdown (https://github.com/brussell98/discord-markdown)
* moment (https://momentjs.com/)

5
dashboard/go.mod Normal file
View file

@ -0,0 +1,5 @@
module dashboard
go 1.18
require github.com/go-chi/chi v1.5.4

2
dashboard/go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=

16
dashboard/index.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./myriad.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PluralKit | home</title>
<!-- extra data -->
<link rel="stylesheet" href="/styles/themes.scss" />
<script defer data-domain="dash.pluralkit.me" src="https://plausible.pluralkit.me/js/plausible.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

145
dashboard/main.go Normal file
View file

@ -0,0 +1,145 @@
package main
import (
"embed"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strings"
"github.com/go-chi/chi"
)
//go:embed dist/*
var fs embed.FS
type entity struct {
AvatarURL *string `json:"avatar_url"`
IconURL *string `json:"icon_url"`
Description *string `json:"description"`
Color *string `json:"color"`
}
var baseURL = "https://api.pluralkit.me/v2"
var version = "dev"
var versionJS string
const defaultEmbed = `<meta property="og:title" content="PluralKit | web dashboard" /> <meta name="theme-color" content="#da9317">`
func main() {
versionJS = "<script>window.pluralkitVersion = '" + version + "'</script>"
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("X-PluralKit-Version", version)
next.ServeHTTP(rw, r)
})
})
r.NotFound(notFoundHandler)
r.Get("/profile/{type}/{id}", func(rw http.ResponseWriter, r *http.Request) {
defer func() {
if a := recover(); a != nil {
notFoundHandler(rw, r)
return
}
}()
createEmbed(rw, r)
})
http.ListenAndServe(":8080", r)
}
func notFoundHandler(rw http.ResponseWriter, r *http.Request) {
var data []byte
var err error
// lol
if strings.HasSuffix(r.URL.Path, ".js") {
data, err = fs.ReadFile("dist" + r.URL.Path)
rw.Header().Add("content-type", "application/javascript")
} else if strings.HasSuffix(r.URL.Path, ".css") {
data, err = fs.ReadFile("dist" + r.URL.Path)
rw.Header().Add("content-type", "text/css")
} else if strings.HasSuffix(r.URL.Path, ".map") {
data, err = fs.ReadFile("dist" + r.URL.Path)
} else {
data, err = fs.ReadFile("dist/index.html")
rw.Header().Add("content-type", "text/html")
data = []byte(strings.Replace(string(data), `<!-- extra data -->`, defaultEmbed+versionJS, 1))
}
if err != nil {
panic(err)
}
rw.Write(data)
}
// explanation for createEmbed:
// we don't care about errors, we just want to return a HTML page as soon as possible
// `panic(nil)` is caught by upstream, which then returns the raw HTML page
func createEmbed(rw http.ResponseWriter, r *http.Request) {
entityType := chi.URLParam(r, "type")
id := chi.URLParam(r, "id")
var path string
switch entityType {
case "s":
path = "/systems/" + id
case "m":
path = "/members/" + id
case "g":
path = "/groups/" + id
default:
panic(nil)
}
res, err := http.Get(baseURL + path)
if err != nil {
panic(nil)
}
if res.StatusCode != 200 {
panic(nil)
}
var data entity
body, _ := io.ReadAll(res.Body)
err = json.Unmarshal(body, &data)
if err != nil {
panic(nil)
}
text := fmt.Sprintf(`<link type="application/json+oembed" href="%s/%s/oembed.json" />%s`, baseURL, path, "\n")
if data.AvatarURL != nil {
text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, html.EscapeString(*data.AvatarURL), "\n")
} else if data.IconURL != nil {
text += fmt.Sprintf(`<meta content='%s' property='og:image'>%s`, html.EscapeString(*data.IconURL), "\n")
}
if data.Description != nil {
text += fmt.Sprintf(`<meta content="%s" property="og:description">%s`, html.EscapeString(*data.Description), "\n")
}
if data.Color != nil {
text += fmt.Sprintf(`<meta name="theme-color" content="#%s">%s`, html.EscapeString(*data.Color), "\n")
}
html, err := fs.ReadFile("dist/index.html")
if err != nil {
panic(nil)
}
html = []byte(strings.Replace(string(html), `<!-- extra data -->`, text+versionJS, 1))
rw.Header().Add("content-type", "text/html")
rw.Write(html)
}

42
dashboard/package.json Normal file
View file

@ -0,0 +1,42 @@
{
"name": "pluralkit-dashboard",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.30",
"@tsconfig/svelte": "^2.0.1",
"svelte": "^3.44.0",
"svelte-check": "^2.2.7",
"svelte-toggle": "^3.1.0",
"tslib": "^2.3.1",
"typescript": "^4.4.4",
"vite": "^2.7.0"
},
"dependencies": {
"@sentry/browser": "^6.19.5",
"@sentry/tracing": "^6.19.5",
"@types/twemoji": "^12.1.2",
"axios": "^0.24.0",
"bootstrap": "^5.1.3",
"bootstrap-dark-5": "^1.1.3",
"core-js-pure": "^3.23.4",
"discord-markdown": "^2.5.1",
"gh-pages": "^3.2.3",
"import": "^0.0.6",
"moment": "^2.29.1",
"sass": "^1.52.2",
"svelecte": "^3.4.5",
"svelte-autosize": "^1.0.1",
"svelte-icons": "^2.1.0",
"svelte-navigator": "^3.1.5",
"svelte-preprocess": "^4.10.6",
"sveltestrap": "^5.6.3",
"twemoji": "^13.1.0"
}
}

BIN
dashboard/public/myriad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 664 KiB

85
dashboard/src/App.svelte Normal file
View file

@ -0,0 +1,85 @@
<script lang="ts">
import { Router, Route } from "svelte-navigator";
import Navigation from "./lib/Navigation.svelte";
import Dash from "./pages/Dash.svelte";
import Home from "./pages/Home.svelte";
import Settings from './pages/Settings.svelte';
import Public from "./pages/Public.svelte";
import Main from "./pages/Profile.svelte";
import Status from './pages/status.svelte';
import Member from './pages/Member.svelte';
import Group from './pages/Group.svelte';
import PageNotFound from './pages/PageNotFound.svelte';
import { Alert } from 'sveltestrap';
import DiscordLogin from "./pages/DiscordLogin.svelte";
import { onMount } from 'svelte';
import BulkGroupPrivacy from "./pages/BulkGroupPrivacy.svelte";
import BulkMemberPrivacy from "./pages/BulkMemberPrivacy.svelte";
import Random from './pages/Random.svelte';
// theme cdns (I might make some myself too)
// if there's a style already set, retrieve it
let style = localStorage.getItem("pk-style") && localStorage.getItem("pk-style");
// this automatically applies the style every time it is updated
$: setStyle(style);
// not sure if there's a better way to handle this
function setStyle(style) {
switch (style) {
case "light": document.documentElement.className = "light";
localStorage.setItem("pk-style", "light");
break;
case "dark": document.documentElement.className = "dark";
localStorage.setItem("pk-style", "dark");
break;
default: document.documentElement.className = "dark";
localStorage.setItem("pk-style", "dark");
break;
};
};
onMount(() => {
let settings = JSON.parse(localStorage.getItem("pk-settings"));
if (settings && settings.accessibility && settings.accessibility.opendyslexic === true) {
document.getElementById("app").classList.add("dyslexic");
}
});
</script>
<Router>
<Navigation bind:style={style}/>
<Route path="/"><Home /></Route>
<Route path="/login/discord"><DiscordLogin /></Route>
<Route path="dash"><Dash /></Route>
<Route path="dash/m/:id"><Member isPublic={false}/></Route>
<Route path = "dash/g/:id"><Group isPublic={false}/></Route>
<Route path="dash/random"><Random isPublic={false} type={"member"}/></Route>
<Route path="dash/random/m"><Random isPublic={false} type={"member"}/></Route>
<Route path="dash/random/g"><Random isPublic={false} type={"group"}/></Route>
<Route path="dash/g/:groupId/random"><Random isPublic={false} type={"member"} pickFromGroup={true}/></Route>
<Route path="dash/bulk-member-privacy"><BulkMemberPrivacy/></Route>
<Route path="dash/bulk-group-privacy"><BulkGroupPrivacy/></Route>
<Route path="settings"><Settings /></Route>
<Route path="profile"><Public /></Route>
<Route path = "profile/s/:id"><Main /></Route>
<Route path = "profile/s">
<Alert color="danger">Please provide a system ID in the URL.</Alert>
</Route>
<Route path="profile/s/:id/random"><Random isPublic={true} type={"member"}/></Route>
<Route path="profile/s/:id/random/m"><Random isPublic={true} type={"member"}/></Route>
<Route path="profile/s/:id/random/g"><Random isPublic={true} type={"group"}/></Route>
<Route path = "profile/m/:id"><Member/></Route>
<Route path = "profile/m">
<Alert color="danger">Please provide a member ID in the URL.</Alert>
</Route>
<Route path = "profile/g/:id"><Group/></Route>
<Route path="profile/g/:groupId/random"><Random isPublic={true} type={"member"} pickFromGroup={true}/></Route>
<Route path = "profile/g">
<Alert color="danger">Please provide a group ID in the URL.</Alert>
</Route>
<Route path="status"><Status /></Route>
<Route component={PageNotFound}/>
</Router>

View file

@ -0,0 +1,28 @@
enum ErrorType {
Unknown = 0,
InvalidToken = 401,
NotFound = 404,
InternalServerError = 500,
}
interface ApiError {
code: number,
type: ErrorType,
message?: string,
data?: any,
}
export function parse(code: number, data?: any): ApiError {
var type = ErrorType[ErrorType[code]] ?? ErrorType.Unknown;
if (code >= 500) type = ErrorType.InternalServerError;
var err: ApiError = { code, type };
if (data) {
var d = data;
err.message = d.message;
err.data = d;
}
return err;
}

Some files were not shown because too many files have changed in this diff Show more