mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-04 04:56:49 +00:00
Merge branch 'PluralKit:main' into main
This commit is contained in:
commit
07959763ea
179 changed files with 8551 additions and 3544 deletions
|
|
@ -11,8 +11,11 @@
|
||||||
!.git
|
!.git
|
||||||
!proto
|
!proto
|
||||||
!scripts/run-clustered.sh
|
!scripts/run-clustered.sh
|
||||||
|
!dashboard
|
||||||
|
!scheduled_tasks
|
||||||
|
|
||||||
# Re-exclude host build artifact directories
|
# Re-exclude host build artifact directories
|
||||||
**/bin
|
**/bin
|
||||||
**/obj
|
**/obj
|
||||||
**/target
|
**/target
|
||||||
|
**/node_modules
|
||||||
14
.github/workflows/beta-bot.yml
vendored
14
.github/workflows/beta-bot.yml
vendored
|
|
@ -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
34
.github/workflows/dashboard.yml
vendored
Normal 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
|
||||||
10
.github/workflows/docker.yml
vendored
10
.github/workflows/docker.yml
vendored
|
|
@ -7,7 +7,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
if: github.repository == 'xSke/PluralKit'
|
if: github.repository == 'PluralKit/PluralKit'
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v1
|
- uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
|
|
@ -22,8 +22,8 @@ jobs:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }}
|
ghcr.io/pluralkit/pluralkit:${{ env.BRANCH_NAME }}
|
||||||
ghcr.io/xske/pluralkit:${{ github.sha }}
|
ghcr.io/pluralkit/pluralkit:${{ github.sha }}
|
||||||
ghcr.io/xske/pluralkit:latest
|
ghcr.io/pluralkit/pluralkit:latest
|
||||||
cache-from: type=registry,ref=ghcr.io/xske/pluralkit:${{ env.BRANCH_NAME }}
|
cache-from: type=registry,ref=ghcr.io/pluralkit/pluralkit:${{ env.BRANCH_NAME }}
|
||||||
cache-to: type=inline
|
cache-to: type=inline
|
||||||
|
|
|
||||||
8
.github/workflows/gateway.yml
vendored
8
.github/workflows/gateway.yml
vendored
|
|
@ -4,15 +4,15 @@ on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'gateway/'
|
- 'gateway/**'
|
||||||
- 'proto/'
|
- 'proto/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
if: github.repository == 'xSke/PluralKit'
|
if: github.repository == 'PluralKit/PluralKit'
|
||||||
steps:
|
steps:
|
||||||
- uses: docker/login-action@v1
|
- uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
# https://github.com/docker/build-push-action/issues/378
|
# https://github.com/docker/build-push-action/issues/378
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile.gateway
|
file: gateway/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/pluralkit/gateway:${{ env.BRANCH_NAME }}
|
ghcr.io/pluralkit/gateway:${{ env.BRANCH_NAME }}
|
||||||
|
|
|
||||||
33
.github/workflows/scheduled_tasks.yml
vendored
Normal file
33
.github/workflows/scheduled_tasks.yml
vendored
Normal 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
1
.gitignore
vendored
|
|
@ -12,6 +12,7 @@ target/
|
||||||
tags/
|
tags/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
mono_crash*
|
mono_crash*
|
||||||
|
.DotSettings
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ COPY Myriad/Myriad.csproj /app/Myriad/
|
||||||
COPY PluralKit.API/PluralKit.API.csproj /app/PluralKit.API/
|
COPY PluralKit.API/PluralKit.API.csproj /app/PluralKit.API/
|
||||||
COPY PluralKit.Bot/PluralKit.Bot.csproj /app/PluralKit.Bot/
|
COPY PluralKit.Bot/PluralKit.Bot.csproj /app/PluralKit.Bot/
|
||||||
COPY PluralKit.Core/PluralKit.Core.csproj /app/PluralKit.Core/
|
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 PluralKit.Tests/PluralKit.Tests.csproj /app/PluralKit.Tests/
|
||||||
COPY .git/ /app/.git
|
COPY .git/ /app/.git
|
||||||
COPY proto/ /app/proto
|
COPY proto/ /app/proto
|
||||||
|
|
@ -20,7 +19,7 @@ RUN dotnet build -c Release -o bin
|
||||||
|
|
||||||
# Build runtime stage (doesn't include SDK)
|
# Build runtime stage (doesn't include SDK)
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:6.0
|
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
|
WORKDIR /app
|
||||||
COPY --from=build /app ./
|
COPY --from=build /app ./
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
|
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
|
||||||
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.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" Version="7.2.1"/>
|
||||||
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1"/>
|
<PackageReference Include="Polly.Contrib.WaitAndRetry" Version="1.1.1"/>
|
||||||
<PackageReference Include="Serilog" Version="2.10.0"/>
|
<PackageReference Include="Serilog" Version="2.10.0"/>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ namespace Myriad.Rest;
|
||||||
|
|
||||||
public class DiscordApiClient
|
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 const string DefaultApiBaseUrl = "https://discord.com/api/v10";
|
||||||
private readonly BaseRestClient _client;
|
private readonly BaseRestClient _client;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ public record Channel
|
||||||
public ulong? GuildId { get; init; }
|
public ulong? GuildId { get; init; }
|
||||||
public int? Position { get; init; }
|
public int? Position { get; init; }
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
public string? Topic { get; init; }
|
// public string? Topic { get; init; }
|
||||||
public bool? Nsfw { get; init; }
|
// public bool? Nsfw { get; init; }
|
||||||
public ulong? ParentId { get; init; }
|
public ulong? ParentId { get; init; }
|
||||||
public Overwrite[]? PermissionOverwrites { get; init; }
|
public Overwrite[]? PermissionOverwrites { get; init; }
|
||||||
public User[]? Recipients { get; init; } // NOTE: this may be null for stub channel objects
|
public User[]? Recipients { get; init; } // NOTE: this may be null for stub channel objects
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ namespace Myriad.Types;
|
||||||
public record MessageComponent
|
public record MessageComponent
|
||||||
{
|
{
|
||||||
public ComponentType Type { get; init; }
|
public ComponentType Type { get; init; }
|
||||||
public ButtonStyle? Style { get; init; }
|
public ButtonStyle? Style { get; set; }
|
||||||
public string? Label { get; init; }
|
public string? Label { get; init; }
|
||||||
public Emoji? Emoji { get; init; }
|
public Emoji? Emoji { get; init; }
|
||||||
public string? CustomId { get; init; }
|
public string? CustomId { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ public record Embed
|
||||||
public EmbedFooter? Footer { get; init; }
|
public EmbedFooter? Footer { get; init; }
|
||||||
public EmbedImage? Image { get; init; }
|
public EmbedImage? Image { get; init; }
|
||||||
public EmbedThumbnail? Thumbnail { get; init; }
|
public EmbedThumbnail? Thumbnail { get; init; }
|
||||||
public EmbedVideo? Video { get; init; }
|
// public EmbedVideo? Video { get; init; }
|
||||||
public EmbedProvider? Provider { get; init; }
|
// public EmbedProvider? Provider { get; init; }
|
||||||
public EmbedAuthor? Author { get; init; }
|
public EmbedAuthor? Author { get; init; }
|
||||||
public Field[]? Fields { get; init; }
|
public Field[]? Fields { get; init; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,5 @@ public record Emoji
|
||||||
{
|
{
|
||||||
public ulong? Id { get; init; }
|
public ulong? Id { get; init; }
|
||||||
public string? Name { get; init; }
|
public string? Name { get; init; }
|
||||||
public bool? Animated { get; init; }
|
// public bool? Animated { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -12,19 +12,19 @@ public record Guild
|
||||||
{
|
{
|
||||||
public ulong Id { get; init; }
|
public ulong Id { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
public string? Icon { get; init; }
|
// public string? Icon { get; init; }
|
||||||
public string? Splash { get; init; }
|
// public string? Splash { get; init; }
|
||||||
public string? DiscoverySplash { get; init; }
|
// public string? DiscoverySplash { get; init; }
|
||||||
public bool? Owner { get; init; }
|
// public bool? Owner { get; init; }
|
||||||
public ulong OwnerId { get; init; }
|
public ulong OwnerId { get; init; }
|
||||||
public string Region { get; init; }
|
// public string Region { get; init; }
|
||||||
public ulong? AfkChannelId { get; init; }
|
// public ulong? AfkChannelId { get; init; }
|
||||||
public int AfkTimeout { get; init; }
|
// public int AfkTimeout { get; init; }
|
||||||
public bool? WidgetEnabled { get; init; }
|
// public bool? WidgetEnabled { get; init; }
|
||||||
public ulong? WidgetChannelId { get; init; }
|
// public ulong? WidgetChannelId { get; init; }
|
||||||
public int VerificationLevel { get; init; }
|
// public int VerificationLevel { get; init; }
|
||||||
public PremiumTier PremiumTier { get; init; }
|
public PremiumTier PremiumTier { get; init; }
|
||||||
|
|
||||||
public Role[] Roles { get; init; }
|
public Role[] Roles { get; init; }
|
||||||
public string[] Features { get; init; }
|
// public string[] Features { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -10,5 +10,5 @@ public record GuildMemberPartial
|
||||||
public string? Avatar { get; init; }
|
public string? Avatar { get; init; }
|
||||||
public string? Nick { get; init; }
|
public string? Nick { get; init; }
|
||||||
public ulong[] Roles { get; init; }
|
public ulong[] Roles { get; init; }
|
||||||
public string JoinedAt { get; init; }
|
// public string JoinedAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -46,29 +46,30 @@ public record Message
|
||||||
public MessageActivity? Activity { get; init; }
|
public MessageActivity? Activity { get; init; }
|
||||||
public User Author { get; init; }
|
public User Author { get; init; }
|
||||||
public string? Content { get; init; }
|
public string? Content { get; init; }
|
||||||
public string? Timestamp { get; init; }
|
// public string? Timestamp { get; init; }
|
||||||
public string? EditedTimestamp { get; init; }
|
// public string? EditedTimestamp { get; init; }
|
||||||
public bool Tts { get; init; }
|
// public bool Tts { get; init; }
|
||||||
public bool MentionEveryone { get; init; }
|
// public bool MentionEveryone { get; init; }
|
||||||
public User.Extra[] Mentions { 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 Attachment[] Attachments { get; init; }
|
||||||
public Embed[]? Embeds { get; init; }
|
public Embed[]? Embeds { get; init; }
|
||||||
public Sticker[]? StickerItems { get; init; }
|
public Sticker[]? StickerItems { get; init; }
|
||||||
public Sticker[]? Stickers { get; init; }
|
// public Sticker[]? Stickers { get; init; }
|
||||||
public Reaction[] Reactions { get; init; }
|
// public Reaction[] Reactions { get; init; }
|
||||||
public bool Pinned { get; init; }
|
// public bool Pinned { get; init; }
|
||||||
public ulong? WebhookId { get; init; }
|
public ulong? WebhookId { get; init; }
|
||||||
public ulong? ApplicationId { get; init; }
|
public ulong? ApplicationId { get; init; }
|
||||||
public MessageType Type { get; init; }
|
public MessageType Type { get; init; }
|
||||||
public Reference? MessageReference { get; set; }
|
public Reference? MessageReference { get; set; }
|
||||||
public MessageFlags Flags { get; init; }
|
// public MessageFlags Flags { get; init; }
|
||||||
|
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
public Optional<Message?> ReferencedMessage { get; init; }
|
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);
|
public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId);
|
||||||
|
|
||||||
|
|
@ -82,8 +83,8 @@ public record Message
|
||||||
public int Size { get; init; }
|
public int Size { get; init; }
|
||||||
public string Url { get; init; }
|
public string Url { get; init; }
|
||||||
public string ProxyUrl { get; init; }
|
public string ProxyUrl { get; init; }
|
||||||
public int? Width { get; init; }
|
// public int? Width { get; init; }
|
||||||
public int? Height { get; init; }
|
// public int? Height { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Reaction
|
public record Reaction
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,10 @@ public record Role
|
||||||
{
|
{
|
||||||
public ulong Id { get; init; }
|
public ulong Id { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
public uint Color { get; init; }
|
// public uint Color { get; init; }
|
||||||
public bool Hoist { get; init; }
|
// public bool Hoist { get; init; }
|
||||||
public int Position { get; init; }
|
public int Position { get; init; }
|
||||||
public PermissionSet Permissions { get; init; }
|
public PermissionSet Permissions { get; init; }
|
||||||
public bool Managed { get; init; }
|
// public bool Managed { get; init; }
|
||||||
public bool Mentionable { get; init; }
|
public bool Mentionable { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -26,10 +26,10 @@ public record User
|
||||||
public string? Avatar { get; init; }
|
public string? Avatar { get; init; }
|
||||||
public bool Bot { get; init; }
|
public bool Bot { get; init; }
|
||||||
public bool? System { get; init; }
|
public bool? System { get; init; }
|
||||||
public Flags PublicFlags { get; init; }
|
// public Flags PublicFlags { get; init; }
|
||||||
|
|
||||||
public record Extra: User
|
public record Extra: User
|
||||||
{
|
{
|
||||||
public GuildMemberPartial? Member { get; init; }
|
// public GuildMemberPartial? Member { get; init; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -24,9 +24,9 @@
|
||||||
},
|
},
|
||||||
"Grpc.Tools": {
|
"Grpc.Tools": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.37.0, )",
|
"requested": "[2.47.0, )",
|
||||||
"resolved": "2.37.0",
|
"resolved": "2.47.0",
|
||||||
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
|
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
|
||||||
},
|
},
|
||||||
"Polly": {
|
"Polly": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
|
@ -25,6 +27,25 @@ public static class APIJsonExt
|
||||||
|
|
||||||
return o;
|
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
|
public struct FrontersReturnNew
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,9 @@ public class PrivateController: PKControllerBase
|
||||||
// guilds.Select(g => new HashEntry(g.Value<string>("id"), true)).ToArray()
|
// 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();
|
var o = new JObject();
|
||||||
|
|
||||||
o.Add("system", system.ToJson(LookupContext.ByOwner));
|
o.Add("system", system.ToJson(LookupContext.ByOwner));
|
||||||
|
|
|
||||||
77
PluralKit.API/Controllers/v2/AutoproxyControllerV2.cs
Normal file
77
PluralKit.API/Controllers/v2/AutoproxyControllerV2.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,21 @@ public class GroupControllerV2: PKControllerBase
|
||||||
return Ok(group.ToJson(ContextFor(group), system.Hid));
|
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}")]
|
[HttpPatch("groups/{groupRef}")]
|
||||||
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
|
public async Task<IActionResult> DoGroupPatch(string groupRef, [FromBody] JObject data)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,21 @@ public class MemberControllerV2: PKControllerBase
|
||||||
return Ok(member.ToJson(ContextFor(member), systemStr: system.Hid));
|
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}")]
|
[HttpPatch("members/{memberRef}")]
|
||||||
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
|
public async Task<IActionResult> DoMemberPatch(string memberRef, [FromBody] JObject data)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@ public class SwitchControllerV2: PKControllerBase
|
||||||
if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value))
|
if (await _repo.GetSwitches(system.Id).Select(x => x.Timestamp).ContainsAsync(value))
|
||||||
throw Errors.SameSwitchTimestampError;
|
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();
|
var members = await _db.Execute(conn => _repo.GetSwitchMembers(conn, sw.Id)).ToListAsync();
|
||||||
return Ok(new FrontersReturnNew
|
return Ok(new FrontersReturnNew
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ public class SystemControllerV2: PKControllerBase
|
||||||
return Ok(system.ToJson(ContextFor(system)));
|
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}")]
|
[HttpPatch("{systemRef}")]
|
||||||
public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data)
|
public async Task<IActionResult> DoSystemPatch(string systemRef, [FromBody] JObject data)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
|
<PackageReference Include="App.Metrics.Prometheus" Version="4.3.0" />
|
||||||
<PackageReference Include="App.Metrics.Reporting.Console" Version="4.3.0" />
|
<PackageReference Include="App.Metrics.Reporting.Console" Version="4.3.0" />
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.13.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.NewtonsoftJson" Version="3.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="4.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="4.2.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="4.2.0" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -107,31 +107,19 @@ public class Startup
|
||||||
|
|
||||||
// handle common ISEs that are generated by invalid user input
|
// handle common ISEs that are generated by invalid user input
|
||||||
if (exc.Error.IsUserError())
|
if (exc.Error.IsUserError())
|
||||||
{
|
await ctx.Response.WriteJSON(400, "{\"message\":\"400: Bad Request\",\"code\":0}");
|
||||||
ctx.Response.StatusCode = 400;
|
|
||||||
await ctx.Response.WriteAsync("{\"message\":\"400: Bad Request\",\"code\":0}");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (exc.Error is not PKError)
|
else if (exc.Error is not PKError)
|
||||||
{
|
await ctx.Response.WriteJSON(500, "{\"message\":\"500: Internal Server Error\",\"code\":0}");
|
||||||
ctx.Response.StatusCode = 500;
|
|
||||||
await ctx.Response.WriteAsync("{\"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
|
// 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)
|
else if (exc.Error is ModelParseError fe)
|
||||||
{
|
await ctx.Response.WriteJSON(fe.ResponseCode, JsonConvert.SerializeObject(fe.ToJson()));
|
||||||
ctx.Response.StatusCode = fe.ResponseCode;
|
|
||||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(fe.ToJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var err = (PKError)exc.Error;
|
var err = (PKError)exc.Error;
|
||||||
ctx.Response.StatusCode = err.ResponseCode;
|
await ctx.Response.WriteJSON(err.ResponseCode, JsonConvert.SerializeObject(err.ToJson()));
|
||||||
|
|
||||||
var json = JsonConvert.SerializeObject(err.ToJson());
|
|
||||||
await ctx.Response.WriteAsync(json);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.Response.CompleteAsync();
|
await ctx.Response.CompleteAsync();
|
||||||
|
|
@ -145,7 +133,15 @@ public class Startup
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
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
|
// metrics
|
||||||
app.UseMetricsAllMiddleware();
|
app.UseMetricsAllMiddleware();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<configuration>
|
|
||||||
<runtime>
|
|
||||||
<gcServer enabled="true"/>
|
|
||||||
</runtime>
|
|
||||||
</configuration>
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -53,9 +53,9 @@
|
||||||
},
|
},
|
||||||
"Grpc.Tools": {
|
"Grpc.Tools": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.37.0, )",
|
"requested": "[2.47.0, )",
|
||||||
"resolved": "2.37.0",
|
"resolved": "2.47.0",
|
||||||
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
|
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
|
||||||
},
|
},
|
||||||
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
|
"Microsoft.AspNetCore.Mvc.NewtonsoftJson": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Init the shard stuff
|
||||||
_services.Resolve<ShardInfoService>().Init();
|
_services.Resolve<ShardInfoService>().Init();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,20 @@ public static class BotMetrics
|
||||||
Context = "Bot"
|
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()
|
public static MeterOptions CommandsRun => new()
|
||||||
{
|
{
|
||||||
Name = "Commands run",
|
Name = "Commands run",
|
||||||
|
|
|
||||||
|
|
@ -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 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 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 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 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 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");
|
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
|
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 };
|
public static Command[] BlacklistCommands = { BlacklistAdd, BlacklistRemove, BlacklistShow };
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ public partial class CommandTree
|
||||||
return ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx));
|
return ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx));
|
||||||
if (ctx.Match("edit", "e"))
|
if (ctx.Match("edit", "e"))
|
||||||
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx));
|
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));
|
return ctx.Execute<ProxiedMessage>(MessageReproxy, m => m.ReproxyMessage(ctx));
|
||||||
if (ctx.Match("log"))
|
if (ctx.Match("log"))
|
||||||
if (ctx.Match("channel"))
|
if (ctx.Match("channel"))
|
||||||
|
|
@ -57,6 +57,8 @@ public partial class CommandTree
|
||||||
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true));
|
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true));
|
||||||
else if (ctx.Match("disable", "off"))
|
else if (ctx.Match("disable", "off"))
|
||||||
return ctx.Execute<ServerConfig>(LogDisable, m => m.SetLogEnabled(ctx, false));
|
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"))
|
else if (ctx.Match("commands"))
|
||||||
return PrintCommandList(ctx, "message logging", LogCommands);
|
return PrintCommandList(ctx, "message logging", LogCommands);
|
||||||
else return PrintCommandExpectedError(ctx, LogCommands);
|
else return PrintCommandExpectedError(ctx, LogCommands);
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ public class Context
|
||||||
|
|
||||||
private Command? _currentCommand;
|
private Command? _currentCommand;
|
||||||
|
|
||||||
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message, int commandParseOffset,
|
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
|
||||||
PKSystem senderSystem, SystemConfig config, MessageContext messageContext)
|
int commandParseOffset, PKSystem senderSystem, SystemConfig config)
|
||||||
{
|
{
|
||||||
Message = (Message)message;
|
Message = (Message)message;
|
||||||
ShardId = shardId;
|
ShardId = shardId;
|
||||||
|
|
@ -37,7 +37,6 @@ public class Context
|
||||||
Channel = channel;
|
Channel = channel;
|
||||||
System = senderSystem;
|
System = senderSystem;
|
||||||
Config = config;
|
Config = config;
|
||||||
MessageContext = messageContext;
|
|
||||||
Cache = provider.Resolve<IDiscordCache>();
|
Cache = provider.Resolve<IDiscordCache>();
|
||||||
Database = provider.Resolve<IDatabase>();
|
Database = provider.Resolve<IDatabase>();
|
||||||
Repository = provider.Resolve<ModelRepository>();
|
Repository = provider.Resolve<ModelRepository>();
|
||||||
|
|
@ -61,7 +60,6 @@ public class Context
|
||||||
public readonly Guild Guild;
|
public readonly Guild Guild;
|
||||||
public readonly int ShardId;
|
public readonly int ShardId;
|
||||||
public readonly Cluster Cluster;
|
public readonly Cluster Cluster;
|
||||||
public readonly MessageContext MessageContext;
|
|
||||||
|
|
||||||
public Task<PermissionSet> BotPermissions => Cache.PermissionsIn(Channel.Id);
|
public Task<PermissionSet> BotPermissions => Cache.PermissionsIn(Channel.Id);
|
||||||
public Task<PermissionSet> UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message);
|
public Task<PermissionSet> UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message);
|
||||||
|
|
@ -96,12 +94,12 @@ public class Context
|
||||||
AllowedMentions = mentions ?? new AllowedMentions()
|
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)
|
// 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);
|
await _commandMessageService.RegisterMessage(msg.Id, msg.ChannelId, Author.Id);
|
||||||
}
|
// }
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
@ -110,6 +108,12 @@ public class Context
|
||||||
{
|
{
|
||||||
_currentCommand = commandDef;
|
_currentCommand = commandDef;
|
||||||
|
|
||||||
|
if (deprecated && commandDef != null)
|
||||||
|
{
|
||||||
|
await Reply($"{Emojis.Warn} This command has been removed. please use `pk;{commandDef.Key}` instead.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (_metrics.Measure.Timer.Time(BotMetrics.CommandTime, new MetricTags("Command", commandDef?.Key ?? "null")))
|
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?
|
// Got a complaint the old error was a bit too patronizing. Hopefully this is better?
|
||||||
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
|
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deprecated && commandDef != null)
|
|
||||||
await Reply($"{Emojis.Warn} This command is deprecated and will be removed soon. In the future, please use `pk;{commandDef.Key}`.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,13 @@ public static class ContextArgumentsExt
|
||||||
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
|
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
|
||||||
|
|
||||||
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
|
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())
|
if (defaultValue != null && ctx.MatchClearInner())
|
||||||
return defaultValue.Value;
|
return defaultValue.Value;
|
||||||
|
|
@ -114,8 +121,7 @@ public static class ContextArgumentsExt
|
||||||
return true;
|
return true;
|
||||||
else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles))
|
else if (ctx.Match(noToggles) || ctx.MatchFlag(noToggles))
|
||||||
return false;
|
return false;
|
||||||
else
|
else return null;
|
||||||
throw new PKError("You must pass either \"on\" or \"off\" to this command.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)
|
public static (ulong? messageId, ulong? channelId) MatchMessage(this Context ctx, bool parseRawMessageId)
|
||||||
|
|
|
||||||
|
|
@ -89,10 +89,12 @@ public class Autoproxy
|
||||||
var eb = new EmbedBuilder()
|
var eb = new EmbedBuilder()
|
||||||
.Title($"Current autoproxy status (for {ctx.Guild.Name.EscapeMarkdown()})");
|
.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
|
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),
|
AutoproxyMode.Member when settings.AutoproxyMember.HasValue => await ctx.Repository.GetMember(settings.AutoproxyMember.Value),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
@ -104,7 +106,7 @@ public class Autoproxy
|
||||||
break;
|
break;
|
||||||
case AutoproxyMode.Front:
|
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.");
|
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();
|
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`."));
|
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();
|
return eb.Build();
|
||||||
|
|
|
||||||
|
|
@ -268,8 +268,7 @@ public class Checks
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_proxy.ShouldProxy(channel, msg, context);
|
_proxy.ShouldProxy(channel, msg, context);
|
||||||
_matcher.TryMatch(context, autoproxySettings, members, out var match, msg.Content, msg.Attachments.Length > 0,
|
_matcher.TryMatch(context, autoproxySettings, members, out var match, msg.Content, msg.Attachments.Length > 0, true);
|
||||||
context.AllowAutoproxy);
|
|
||||||
|
|
||||||
await ctx.Reply("I'm not sure why this message was not proxied, sorry.");
|
await ctx.Reply("I'm not sure why this message was not proxied, sorry.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ public class Config
|
||||||
{
|
{
|
||||||
var items = new List<PaginatedConfigItem>();
|
var items = new List<PaginatedConfigItem>();
|
||||||
|
|
||||||
|
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
|
||||||
|
|
||||||
items.Add(new(
|
items.Add(new(
|
||||||
"autoproxy account",
|
"autoproxy account",
|
||||||
"Whether autoproxy is enabled for the current account",
|
"Whether autoproxy is enabled for the current account",
|
||||||
EnabledDisabled(ctx.MessageContext.AllowAutoproxy),
|
EnabledDisabled(allowAutoproxy),
|
||||||
"enabled"
|
"enabled"
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -122,16 +124,18 @@ public class Config
|
||||||
|
|
||||||
public async Task AutoproxyAccount(Context ctx)
|
public async Task AutoproxyAccount(Context ctx)
|
||||||
{
|
{
|
||||||
|
var allowAutoproxy = await ctx.Repository.GetAutoproxyEnabled(ctx.Author.Id);
|
||||||
|
|
||||||
if (!ctx.HasNext())
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var allow = ctx.MatchToggle(true);
|
var allow = ctx.MatchToggle(true);
|
||||||
|
|
||||||
var statusString = EnabledDisabled(allow);
|
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}>.");
|
await ctx.Reply($"{Emojis.Note} Autoproxy is already {statusString} for account <@{ctx.Author.Id}>.");
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -374,38 +374,39 @@ public class Groups
|
||||||
|
|
||||||
public async Task GroupColor(Context ctx, PKGroup target)
|
public async Task GroupColor(Context ctx, PKGroup target)
|
||||||
{
|
{
|
||||||
var color = ctx.RemainderOrNull();
|
var isOwnSystem = ctx.System?.Id == target.System;
|
||||||
if (await ctx.MatchClear())
|
var matchedRaw = ctx.MatchRaw();
|
||||||
{
|
var matchedClear = await ctx.MatchClear();
|
||||||
ctx.CheckOwnGroup(target);
|
|
||||||
|
|
||||||
var patch = new GroupPatch { Color = Partial<string>.Null() };
|
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
||||||
await ctx.Repository.UpdateGroup(target.Id, patch);
|
|
||||||
|
|
||||||
await ctx.Reply($"{Emojis.Success} Group color cleared.");
|
|
||||||
}
|
|
||||||
else if (!ctx.HasNext())
|
|
||||||
{
|
{
|
||||||
if (target.Color == null)
|
if (target.Color == null)
|
||||||
if (ctx.System?.Id == target.System)
|
await ctx.Reply(
|
||||||
await ctx.Reply(
|
"This group does not have a color set." + (isOwnSystem ? $" To set one, type `pk;group {target.Reference(ctx)} color <color>`." : ""));
|
||||||
$"This group does not have a color set. To set one, type `pk;group {target.Reference(ctx)} color <color>`.");
|
else if (matchedRaw)
|
||||||
else
|
await ctx.Reply("```\n#" + target.Color + "\n```");
|
||||||
await ctx.Reply("This group does not have a color set.");
|
|
||||||
else
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("Group color")
|
.Title("Group color")
|
||||||
.Color(target.Color.ToDiscordColor())
|
.Color(target.Color.ToDiscordColor())
|
||||||
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
|
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
|
||||||
.Description($"This group's color is **#{target.Color}**."
|
.Description($"This group's color is **#{target.Color}**."
|
||||||
+ (ctx.System?.Id == target.System
|
+ (isOwnSystem ? $" To clear it, type `pk;group {target.Reference(ctx)} color -clear`." : ""))
|
||||||
? $" To clear it, type `pk;group {target.Reference(ctx)} color -clear`."
|
|
||||||
: ""))
|
|
||||||
.Build());
|
.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
|
else
|
||||||
{
|
{
|
||||||
ctx.CheckOwnGroup(target);
|
var color = ctx.RemainderOrNull();
|
||||||
|
|
||||||
if (color.StartsWith("#")) color = color.Substring(1);
|
if (color.StartsWith("#")) color = color.Substring(1);
|
||||||
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
|
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
using Myriad.Builders;
|
|
||||||
using Myriad.Types;
|
using Myriad.Types;
|
||||||
|
using Myriad.Rest.Types.Requests;
|
||||||
|
|
||||||
using PluralKit.Core;
|
using PluralKit.Core;
|
||||||
|
|
||||||
|
|
@ -10,64 +10,159 @@ public class Help
|
||||||
private static Embed helpEmbed = new()
|
private static Embed helpEmbed = new()
|
||||||
{
|
{
|
||||||
Title = "PluralKit",
|
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.",
|
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.",
|
||||||
Fields = new[]
|
Footer = new("By @Ske#6201 | Myriad by @Layl#8888 | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"),
|
||||||
{
|
|
||||||
new Embed.Field
|
|
||||||
(
|
|
||||||
"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."
|
|
||||||
+ " This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account."
|
|
||||||
),
|
|
||||||
new
|
|
||||||
(
|
|
||||||
"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."
|
|
||||||
),
|
|
||||||
new
|
|
||||||
(
|
|
||||||
"How do I get started?",
|
|
||||||
String.Join("\n", new[]
|
|
||||||
{
|
|
||||||
"To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):",
|
|
||||||
"**1**. `pk;system new` - Create a system (if you haven't already)",
|
|
||||||
"**2**. `pk;member add John` - Add a new member to your system",
|
|
||||||
"**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags",
|
|
||||||
"**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.",
|
|
||||||
"**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.",
|
|
||||||
"\nSee [the Getting Started guide](https://pluralkit.me/start) for more information."
|
|
||||||
})
|
|
||||||
),
|
|
||||||
new
|
|
||||||
(
|
|
||||||
"Useful tips",
|
|
||||||
String.Join("\n", new[] {
|
|
||||||
$"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)",
|
|
||||||
$"React with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)",
|
|
||||||
$"React with {Emojis.Bell} on a proxied message to \"ping\" the sender",
|
|
||||||
"Type **`pk;invite`** to get a link to invite this bot to your own server!"
|
|
||||||
})
|
|
||||||
),
|
|
||||||
new
|
|
||||||
(
|
|
||||||
"More information",
|
|
||||||
String.Join("\n", new[] {
|
|
||||||
"For a full list of commands, see [the command list](https://pluralkit.me/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."
|
|
||||||
})
|
|
||||||
),
|
|
||||||
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,
|
Color = DiscordUtils.Blue,
|
||||||
};
|
};
|
||||||
|
|
||||||
public Task HelpRoot(Context ctx) => ctx.Reply(embed: helpEmbed);
|
private static Dictionary<string, Embed.Field[]> helpEmbedPages = new Dictionary<string, 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."
|
||||||
|
+ " This is useful for multiple people sharing one body (aka \"systems\"), people who wish to roleplay as different characters without having several accounts, or anyone else who may want to post messages as a different person from the same account."
|
||||||
|
),
|
||||||
|
new
|
||||||
|
(
|
||||||
|
"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?",
|
||||||
|
String.Join("\n", new[]
|
||||||
|
{
|
||||||
|
"To get started using PluralKit, try running the following commands (of course replacing the relevant names with your own):",
|
||||||
|
"**1**. `pk;system new` - Create a system (if you haven't already)",
|
||||||
|
"**2**. `pk;member add John` - Add a new member to your system",
|
||||||
|
"**3**. `pk;member John proxy [text]` - Set up [square brackets] as proxy tags",
|
||||||
|
"**4**. You're done! You can now type [a message in brackets] and it'll be proxied appropriately.",
|
||||||
|
"**5**. Optionally, you may set an avatar from the URL of an image with `pk;member John avatar [link to image]`, or from a file by typing `pk;member John avatar` and sending the message with an attached image.",
|
||||||
|
"\nSee [the Getting Started guide](https://pluralkit.me/start) for more information."
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"usefultips",
|
||||||
|
new Embed.Field[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
(
|
||||||
|
"Useful tips",
|
||||||
|
String.Join("\n", new[] {
|
||||||
|
$"React with {Emojis.Error} on a proxied message to delete it (only if you sent it!)",
|
||||||
|
$"React with {Emojis.RedQuestion} on a proxied message to look up information about it (like who sent it)",
|
||||||
|
$"React with {Emojis.Bell} on a proxied message to \"ping\" the sender",
|
||||||
|
"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), 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.",
|
||||||
|
"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"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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[]
|
private static string explanation = String.Join("\n\n", new[]
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -225,41 +225,39 @@ public class MemberEdit
|
||||||
|
|
||||||
public async Task Color(Context ctx, PKMember target)
|
public async Task Color(Context ctx, PKMember target)
|
||||||
{
|
{
|
||||||
var color = ctx.RemainderOrNull();
|
var isOwnSystem = ctx.System?.Id == target.System;
|
||||||
if (await ctx.MatchClear())
|
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 (target.Color == null)
|
||||||
if (ctx.System?.Id == target.System)
|
await ctx.Reply(
|
||||||
await ctx.Reply(
|
"This member does not have a color set." + (isOwnSystem ? $" To set one, type `pk;member {target.Reference(ctx)} color <color>`." : ""));
|
||||||
$"This member does not have a color set. To set one, type `pk;member {target.Reference(ctx)} color <color>`.");
|
else if (matchedRaw)
|
||||||
else
|
await ctx.Reply("```\n#" + target.Color + "\n```");
|
||||||
await ctx.Reply("This member does not have a color set.");
|
|
||||||
else
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("Member color")
|
.Title("Member color")
|
||||||
.Color(target.Color.ToDiscordColor())
|
.Color(target.Color.ToDiscordColor())
|
||||||
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
|
.Thumbnail(new Embed.EmbedThumbnail($"https://fakeimg.pl/256x256/{target.Color}/?text=%20"))
|
||||||
.Description($"This member's color is **#{target.Color}**."
|
.Description($"This member's color is **#{target.Color}**."
|
||||||
+ (ctx.System?.Id == target.System
|
+ (isOwnSystem ? $" To clear it, type `pk;member {target.Reference(ctx)} color -clear`." : ""))
|
||||||
? $" To clear it, type `pk;member {target.Reference(ctx)} color -clear`."
|
|
||||||
: ""))
|
|
||||||
.Build());
|
.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
|
else
|
||||||
{
|
{
|
||||||
ctx.CheckOwnMember(target);
|
var color = ctx.RemainderOrNull();
|
||||||
|
|
||||||
if (color.StartsWith("#")) color = color.Substring(1);
|
if (color.StartsWith("#")) color = color.Substring(1);
|
||||||
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
|
if (!Regex.IsMatch(color, "^[0-9a-fA-F]{6}$")) throw Errors.InvalidColorError(color);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Autofac;
|
||||||
|
|
||||||
using Myriad.Builders;
|
using Myriad.Builders;
|
||||||
using Myriad.Cache;
|
using Myriad.Cache;
|
||||||
using Myriad.Extensions;
|
using Myriad.Extensions;
|
||||||
|
|
@ -45,7 +47,7 @@ public class ProxiedMessage
|
||||||
_logChannel = logChannel;
|
_logChannel = logChannel;
|
||||||
// _cache = cache;
|
// _cache = cache;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
_proxy = proxy;
|
_proxy = proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReproxyMessage(Context ctx)
|
public async Task ReproxyMessage(Context ctx)
|
||||||
|
|
@ -61,7 +63,7 @@ public class ProxiedMessage
|
||||||
throw new PKError("Could not find a member to reproxy the message with.");
|
throw new PKError("Could not find a member to reproxy the message with.");
|
||||||
|
|
||||||
// Fetch members and get the ProxyMember for `target`
|
// Fetch members and get the ProxyMember for `target`
|
||||||
List <ProxyMember> members;
|
List<ProxyMember> members;
|
||||||
using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime))
|
using (_metrics.Measure.Timer.Time(BotMetrics.ProxyMembersQueryTime))
|
||||||
members = (await _repo.GetProxyMembers(ctx.Author.Id, msg.Message.Guild!.Value)).ToList();
|
members = (await _repo.GetProxyMembers(ctx.Author.Id, msg.Message.Guild!.Value)).ToList();
|
||||||
var match = members.Find(x => x.Id == target.Id);
|
var match = members.Find(x => x.Id == target.Id);
|
||||||
|
|
@ -70,7 +72,7 @@ public class ProxiedMessage
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _proxy.ExecuteReproxy(ctx.Message, msg.Message, match);
|
await _proxy.ExecuteReproxy(ctx.Message, msg.Message, members, match);
|
||||||
|
|
||||||
if (ctx.Guild == null)
|
if (ctx.Guild == null)
|
||||||
await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success });
|
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))
|
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
|
||||||
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
|
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
|
||||||
|
|
||||||
await _logChannel.LogMessage(ctx.MessageContext, msg.Message, ctx.Message, editedMsg,
|
await _logChannel.LogMessage(msg.Message, ctx.Message, editedMsg, originalMsg!.Content!);
|
||||||
originalMsg!.Content!);
|
|
||||||
}
|
}
|
||||||
catch (NotFoundException)
|
catch (NotFoundException)
|
||||||
{
|
{
|
||||||
|
|
@ -140,13 +141,12 @@ public class ProxiedMessage
|
||||||
var editType = isReproxy ? "reproxy" : "edit";
|
var editType = isReproxy ? "reproxy" : "edit";
|
||||||
var editTypeAction = isReproxy ? "reproxied" : "edited";
|
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;
|
FullMessage? msg = null;
|
||||||
|
|
||||||
var (referencedMessage, _) = ctx.MatchMessage(false);
|
var (referencedMessage, _) = ctx.MatchMessage(false);
|
||||||
if (referencedMessage != null)
|
if (referencedMessage != null)
|
||||||
{
|
{
|
||||||
|
await using var conn = await ctx.Database.Obtain();
|
||||||
msg = await ctx.Repository.GetMessage(conn, referencedMessage.Value);
|
msg = await ctx.Repository.GetMessage(conn, referencedMessage.Value);
|
||||||
if (msg == null)
|
if (msg == null)
|
||||||
throw new PKError("This is not a message proxied by PluralKit.");
|
throw new PKError("This is not a message proxied by PluralKit.");
|
||||||
|
|
@ -161,6 +161,7 @@ public class ProxiedMessage
|
||||||
if (recent == null)
|
if (recent == null)
|
||||||
throw new PKSyntaxError($"Could not find a recent message to {editType}.");
|
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);
|
msg = await ctx.Repository.GetMessage(conn, recent.Mid);
|
||||||
if (msg == null)
|
if (msg == null)
|
||||||
throw new PKSyntaxError($"Could not find a recent message to {editType}.");
|
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)
|
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
|
||||||
{
|
{
|
||||||
var message = await ctx.Repository.GetCommandMessage(messageId);
|
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
||||||
if (message == null)
|
if (authorId == null)
|
||||||
throw Errors.MessageNotFound(messageId);
|
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.");
|
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)
|
if (ctx.Guild != null)
|
||||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@ public class Misc
|
||||||
.Footer(new(String.Join(" \u2022 ", new[] {
|
.Footer(new(String.Join(" \u2022 ", new[] {
|
||||||
$"PluralKit {BuildInfoService.Version}",
|
$"PluralKit {BuildInfoService.Version}",
|
||||||
(isCluster ? $"Cluster {_botConfig.Cluster.NodeIndex}" : ""),
|
(isCluster ? $"Cluster {_botConfig.Cluster.NodeIndex}" : ""),
|
||||||
"https://github.com/xSke/PluralKit",
|
"https://github.com/PluralKit/PluralKit",
|
||||||
"Last restarted:",
|
"Last restarted:",
|
||||||
})))
|
})))
|
||||||
.Timestamp(process.StartTime.ToString("O"));
|
.Timestamp(process.StartTime.ToString("O"));
|
||||||
|
|
|
||||||
|
|
@ -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)
|
public async Task SetBlacklisted(Context ctx, bool shouldAdd)
|
||||||
{
|
{
|
||||||
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
|
||||||
|
|
@ -180,27 +231,26 @@ public class ServerConfig
|
||||||
|
|
||||||
public async Task SetLogCleanup(Context ctx)
|
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 botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
|
||||||
|
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);
|
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
|
||||||
|
|
||||||
bool newValue;
|
bool? newValue = ctx.MatchToggleOrNull();
|
||||||
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 (newValue == null)
|
||||||
|
{
|
||||||
var guildCfg = await ctx.Repository.GetGuild(ctx.Guild.Id);
|
var guildCfg = await ctx.Repository.GetGuild(ctx.Guild.Id);
|
||||||
if (guildCfg.LogCleanupEnabled)
|
if (guildCfg.LogCleanupEnabled)
|
||||||
eb.Description(
|
eb.Description(
|
||||||
|
|
@ -212,9 +262,9 @@ public class ServerConfig
|
||||||
return;
|
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(
|
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.");
|
$"{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
|
else
|
||||||
|
|
|
||||||
|
|
@ -132,12 +132,16 @@ public class SystemEdit
|
||||||
public async Task Color(Context ctx, PKSystem target)
|
public async Task Color(Context ctx, PKSystem target)
|
||||||
{
|
{
|
||||||
var isOwnSystem = ctx.System?.Id == target.Id;
|
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)
|
if (target.Color == null)
|
||||||
await ctx.Reply(
|
await ctx.Reply(
|
||||||
"This system does not have a color set." + (isOwnSystem ? " To set one, type `pk;system color <color>`." : ""));
|
"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
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("System color")
|
.Title("System color")
|
||||||
|
|
@ -151,7 +155,7 @@ public class SystemEdit
|
||||||
|
|
||||||
ctx.CheckSystem().CheckOwnSystem(target);
|
ctx.CheckSystem().CheckOwnSystem(target);
|
||||||
|
|
||||||
if (await ctx.MatchClear())
|
if (matchedClear)
|
||||||
{
|
{
|
||||||
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Color = Partial<string>.Null() });
|
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { Color = Partial<string>.Null() });
|
||||||
|
|
||||||
|
|
@ -273,7 +277,7 @@ public class SystemEdit
|
||||||
await ctx.Reply(
|
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}'.");
|
$"{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);
|
await ctx.Reply(setDisabledWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -284,7 +288,7 @@ public class SystemEdit
|
||||||
await ctx.Reply(
|
await ctx.Reply(
|
||||||
$"{Emojis.Success} System server tag cleared. Member names will now end with the global system tag, if there is one set.");
|
$"{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);
|
await ctx.Reply(setDisabledWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,7 +297,7 @@ public class SystemEdit
|
||||||
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id,
|
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id,
|
||||||
new SystemGuildPatch { TagEnabled = newValue });
|
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)
|
string PrintEnableDisableResult(bool newValue, bool changedValue)
|
||||||
|
|
@ -308,20 +312,20 @@ public class SystemEdit
|
||||||
|
|
||||||
if (newValue)
|
if (newValue)
|
||||||
{
|
{
|
||||||
if (ctx.MessageContext.TagEnabled)
|
if (settings.TagEnabled)
|
||||||
{
|
{
|
||||||
if (ctx.MessageContext.SystemGuildTag == null)
|
if (settings.Tag == null)
|
||||||
str +=
|
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.";
|
" 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
|
else
|
||||||
str +=
|
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
|
else
|
||||||
{
|
{
|
||||||
if (ctx.MessageContext.SystemGuildTag != null)
|
if (settings.Tag != null)
|
||||||
str +=
|
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
|
else
|
||||||
str +=
|
str +=
|
||||||
" Member names will now end with the global system tag when proxied in the current server, if there is one set.";
|
" 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(
|
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"
|
$"{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."
|
+ $"**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 you don't want this to happen, use `pk;s delete -no-export` instead.");
|
||||||
if (!await ctx.ConfirmWithReply(target.Hid))
|
if (!await ctx.ConfirmWithReply(target.Hid))
|
||||||
throw new PKError(
|
throw new PKError(
|
||||||
$"System deletion cancelled. Note that you must reply with your system ID (`{target.Hid}`) *verbatim*.");
|
$"System deletion cancelled. Note that you must reply with your system ID (`{target.Hid}`) *verbatim*.");
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ public class SystemLink
|
||||||
|
|
||||||
ulong id;
|
ulong id;
|
||||||
if (!ctx.MatchUserRaw(out 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();
|
var accountIds = (await ctx.Repository.GetSystemAccounts(ctx.System.Id)).ToList();
|
||||||
if (!accountIds.Contains(id)) throw Errors.AccountNotLinked;
|
if (!accountIds.Contains(id)) throw Errors.AccountNotLinked;
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,14 @@ public class InteractionCreated: IEventHandler<InteractionCreateEvent>
|
||||||
if (evt.Type == Interaction.InteractionType.MessageComponent)
|
if (evt.Type == Interaction.InteractionType.MessageComponent)
|
||||||
{
|
{
|
||||||
var customId = evt.Data?.CustomId;
|
var customId = evt.Data?.CustomId;
|
||||||
if (customId != null)
|
if (customId == null) return;
|
||||||
{
|
|
||||||
var ctx = new InteractionContext(evt, _services);
|
var ctx = new InteractionContext(evt, _services);
|
||||||
|
|
||||||
|
if (customId.Contains("help-menu"))
|
||||||
|
await Help.ButtonClick(ctx);
|
||||||
|
else
|
||||||
await _interactionDispatch.Dispatch(customId, ctx);
|
await _interactionDispatch.Dispatch(customId, ctx);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return;
|
if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return;
|
||||||
if (IsDuplicateMessage(evt)) 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
|
// 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
|
// 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);
|
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
|
||||||
_lastMessageCache.AddMessage(evt);
|
_lastMessageCache.AddMessage(evt);
|
||||||
|
|
||||||
// Get message context from DB (tracking w/ metrics)
|
// if the message was not sent by an user account, only try running log cleanup
|
||||||
MessageContext ctx;
|
if (evt.Author.Bot || evt.WebhookId != null || evt.Author.System == true)
|
||||||
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
|
{
|
||||||
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel.Id);
|
await TryHandleLogClean(channel, evt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Try each handler until we find one that succeeds
|
// Try each handler until we find one that succeeds
|
||||||
if (await TryHandleLogClean(evt, ctx))
|
|
||||||
|
if (await TryHandleCommand(shardId, evt, guild, channel))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only do command/proxy handling if it's a user account
|
await TryHandleProxy(evt, guild, channel, rootChannel.Id, botPermissions);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.GuildId == null) return;
|
||||||
if (!evt.Author.Bot || channel.Type != Channel.ChannelType.GuildText ||
|
if (channel.Type != Channel.ChannelType.GuildText) return;
|
||||||
!ctx.LogCleanupEnabled) return false;
|
|
||||||
|
|
||||||
await _loggerClean.HandleLoggerBotCleanup(evt);
|
var guildSettings = await _repo.GetGuild(evt.GuildId!.Value);
|
||||||
return true;
|
|
||||||
|
if (guildSettings.LogCleanupEnabled)
|
||||||
|
await _loggerClean.HandleLoggerBotCleanup(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<bool> TryHandleCommand(int shardId, MessageCreateEvent evt, Guild? guild,
|
private async ValueTask<bool> TryHandleCommand(int shardId, MessageCreateEvent evt, Guild? guild, Channel channel)
|
||||||
Channel channel, MessageContext ctx)
|
|
||||||
{
|
{
|
||||||
var content = evt.Content;
|
var content = evt.Content;
|
||||||
if (content == null) return false;
|
if (content == null) return false;
|
||||||
|
|
@ -117,17 +115,6 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
if (!HasCommandPrefix(content, ourUserId, out var cmdStart) || cmdStart == content.Length)
|
if (!HasCommandPrefix(content, ourUserId, out var cmdStart) || cmdStart == content.Length)
|
||||||
return false;
|
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
|
// 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
|
// This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string
|
||||||
var trimStartLengthDiff =
|
var trimStartLengthDiff =
|
||||||
|
|
@ -136,9 +123,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var system = ctx.SystemId != null ? await _repo.GetSystem(ctx.SystemId.Value) : null;
|
var system = await _repo.GetSystemByAccount(evt.Author.Id);
|
||||||
var config = ctx.SystemId != null ? await _repo.GetSystemConfig(ctx.SystemId.Value) : null;
|
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
|
||||||
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, ctx));
|
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config));
|
||||||
}
|
}
|
||||||
catch (PKError)
|
catch (PKError)
|
||||||
{
|
{
|
||||||
|
|
@ -169,17 +156,16 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<bool> TryHandleProxy(MessageCreateEvent evt, Guild guild, Channel channel,
|
private async ValueTask<bool> TryHandleProxy(MessageCreateEvent evt, Guild guild, Channel channel, ulong rootChannel, PermissionSet botPermissions)
|
||||||
MessageContext ctx)
|
|
||||||
{
|
{
|
||||||
if (ctx.IsDeleting) return false;
|
// Get message context from DB (tracking w/ metrics)
|
||||||
|
MessageContext ctx;
|
||||||
var botPermissions = await _cache.PermissionsIn(channel.Id);
|
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
|
||||||
|
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, ctx.AllowAutoproxy,
|
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, true, botPermissions);
|
||||||
botPermissions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch any failed proxy checks so they get ignored in the global error handler
|
// Catch any failed proxy checks so they get ignored in the global error handler
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,10 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var commandMsg = await _commandMessageService.GetCommandMessage(evt.MessageId);
|
var (authorId, _) = await _commandMessageService.GetCommandMessage(evt.MessageId);
|
||||||
if (commandMsg != null)
|
if (authorId != null)
|
||||||
{
|
{
|
||||||
await HandleCommandDeleteReaction(evt, commandMsg);
|
await HandleCommandDeleteReaction(evt, authorId.Value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,11 +141,11 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
await _repo.DeleteMessage(evt.MessageId);
|
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
|
// Can only delete your own message
|
||||||
// (except in DMs, where msg will be null)
|
// (except in DMs, where msg will be null)
|
||||||
if (msg != null && msg.AuthorId != evt.UserId)
|
if (authorId != null && authorId != evt.UserId)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// todo: don't try to delete the user's own messages in DMs
|
// todo: don't try to delete the user's own messages in DMs
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ public class Init
|
||||||
{
|
{
|
||||||
private static async Task Main(string[] args)
|
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
|
// Load configuration and run global init stuff
|
||||||
var config = InitUtils.BuildConfiguration(args).Build();
|
var config = InitUtils.BuildConfiguration(args).Build();
|
||||||
InitUtils.InitStatic();
|
InitUtils.InitStatic();
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
|
<PackageReference Include="Google.Protobuf" Version="3.13.0"/>
|
||||||
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.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="Humanizer.Core" Version="2.8.26"/>
|
||||||
<PackageReference Include="Sentry" Version="3.11.1"/>
|
<PackageReference Include="Sentry" Version="3.11.1"/>
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2"/>
|
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2"/>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -43,6 +43,10 @@ public class ProxyMatcher
|
||||||
{
|
{
|
||||||
match = default;
|
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
|
// Skip autoproxy match if we hit the escape character
|
||||||
if (messageContent.StartsWith(AutoproxyEscapeCharacter))
|
if (messageContent.StartsWith(AutoproxyEscapeCharacter))
|
||||||
throw new ProxyService.ProxyChecksFailedException(
|
throw new ProxyService.ProxyChecksFailedException(
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ public class ProxyService
|
||||||
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
|
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);
|
var originalMsg = await _rest.GetMessageOrNull(msg.Channel, msg.Mid);
|
||||||
if (originalMsg == null)
|
if (originalMsg == null)
|
||||||
|
|
@ -213,24 +213,34 @@ public class ProxyService
|
||||||
throw new ProxyChecksFailedException(
|
throw new ProxyChecksFailedException(
|
||||||
"Proxying was disabled in this channel by a server administrator (via the proxy blacklist).");
|
"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
|
var match = new ProxyMatch
|
||||||
{
|
{
|
||||||
Member = member,
|
Member = member,
|
||||||
|
Content = prevMatched ? prevMatch.Content : originalMsg.Content,
|
||||||
|
ProxyTags = member.ProxyTags.FirstOrDefault(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var messageChannel = await _rest.GetChannelOrNull(msg.Channel!);
|
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 threadId = messageChannel.IsThread() ? messageChannel.Id : (ulong?)null;
|
||||||
var guild = await _rest.GetGuildOrNull(msg.Guild!.Value);
|
var guild = await _rest.GetGuildOrNull(msg.Guild!.Value);
|
||||||
|
var guildMember = await _rest.GetGuildMember(msg.Guild!.Value, trigger.Author.Id);
|
||||||
|
|
||||||
// Grab user permissions
|
// 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);
|
var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone);
|
||||||
|
|
||||||
// Make sure user has permissions to send messages
|
// Make sure user has permissions to send messages
|
||||||
if (!senderPermissions.HasFlag(PermissionSet.SendMessages))
|
if (!senderPermissions.HasFlag(PermissionSet.SendMessages))
|
||||||
throw new PKError("You don't have permission to send messages in the channel that message is in.");
|
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
|
// Send the reproxied webhook
|
||||||
var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest
|
var proxyMessage = await _webhookExecutor.ExecuteWebhook(new ProxyRequest
|
||||||
{
|
{
|
||||||
|
|
@ -239,15 +249,15 @@ public class ProxyService
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
Name = match.Member.ProxyName(ctx),
|
Name = match.Member.ProxyName(ctx),
|
||||||
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
|
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
|
||||||
Content = originalMsg.Content!,
|
Content = match.ProxyContent!,
|
||||||
Attachments = originalMsg.Attachments!,
|
Attachments = originalMsg.Attachments!,
|
||||||
FileSizeLimit = guild.FileSizeLimit(),
|
FileSizeLimit = guild.FileSizeLimit(),
|
||||||
Embeds = originalMsg.Embeds!.ToArray(),
|
Embeds = mangledEmbeds,
|
||||||
Stickers = originalMsg.StickerItems!,
|
Stickers = originalMsg.StickerItems!,
|
||||||
AllowEveryone = allowEveryone
|
AllowEveryone = allowEveryone
|
||||||
});
|
});
|
||||||
|
|
||||||
var autoproxySettings = await _repo.GetAutoproxySettings(ctx.SystemId.Value, msg.Guild!.Value, null);
|
|
||||||
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match, deletePrevious: false);
|
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match, deletePrevious: false);
|
||||||
await _rest.DeleteMessage(originalMsg.ChannelId!, originalMsg.Id!);
|
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,
|
private Embed CreateReplyEmbed(ProxyMatch match, Message trigger, Message repliedTo, string? nickname,
|
||||||
string? avatar)
|
string? avatar)
|
||||||
{
|
{
|
||||||
|
|
@ -377,8 +415,8 @@ public class ProxyService
|
||||||
{
|
{
|
||||||
var sentMessage = new PKMessage
|
var sentMessage = new PKMessage
|
||||||
{
|
{
|
||||||
Channel = triggerMessage.ChannelId,
|
Channel = proxyMessage.ChannelId,
|
||||||
Guild = triggerMessage.GuildId,
|
Guild = proxyMessage.GuildId,
|
||||||
Member = match.Member.Id,
|
Member = match.Member.Id,
|
||||||
Mid = proxyMessage.Id,
|
Mid = proxyMessage.Id,
|
||||||
OriginalMid = triggerMessage.Id,
|
OriginalMid = triggerMessage.Id,
|
||||||
|
|
@ -389,7 +427,7 @@ public class ProxyService
|
||||||
=> _repo.AddMessage(sentMessage);
|
=> _repo.AddMessage(sentMessage);
|
||||||
|
|
||||||
Task LogMessageToChannel() =>
|
Task LogMessageToChannel() =>
|
||||||
_logChannel.LogMessage(ctx, sentMessage, triggerMessage, proxyMessage).AsTask();
|
_logChannel.LogMessage(sentMessage, triggerMessage, proxyMessage).AsTask();
|
||||||
|
|
||||||
Task SaveLatchAutoproxy() => autoproxySettings.AutoproxyMode == AutoproxyMode.Latch
|
Task SaveLatchAutoproxy() => autoproxySettings.AutoproxyMode == AutoproxyMode.Latch
|
||||||
? _repo.UpdateAutoproxy(ctx.SystemId.Value, triggerMessage.GuildId, null, new()
|
? _repo.UpdateAutoproxy(ctx.SystemId.Value, triggerMessage.GuildId, null, new()
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ public class ProxyTagParser
|
||||||
// We got a match, extract inner text
|
// We got a match, extract inner text
|
||||||
inner = input.Substring(prefix.Length, input.Length - prefix.Length - suffix.Length);
|
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";
|
return inner.Trim() != "\U0000fe0f";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,28 +8,36 @@ namespace PluralKit.Bot;
|
||||||
|
|
||||||
public class CommandMessageService
|
public class CommandMessageService
|
||||||
{
|
{
|
||||||
private readonly IClock _clock;
|
private readonly RedisService _redis;
|
||||||
private readonly IDatabase _db;
|
|
||||||
private readonly ILogger _logger;
|
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;
|
_redis = redis;
|
||||||
_repo = repo;
|
|
||||||
_clock = clock;
|
|
||||||
_logger = logger.ForContext<CommandMessageService>();
|
_logger = logger.ForContext<CommandMessageService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RegisterMessage(ulong messageId, ulong channelId, ulong authorId)
|
public async Task RegisterMessage(ulong messageId, ulong channelId, ulong authorId)
|
||||||
{
|
{
|
||||||
|
if (_redis.Connection == null) return;
|
||||||
|
|
||||||
_logger.Debug(
|
_logger.Debug(
|
||||||
"Registering command response {MessageId} from author {AuthorId} in {ChannelId}",
|
"Registering command response {MessageId} from author {AuthorId} in {ChannelId}",
|
||||||
messageId, authorId, 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) =>
|
public async Task<(ulong?, ulong?)> GetCommandMessage(ulong messageId)
|
||||||
await _repo.GetCommandMessage(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +72,8 @@ public class EmbedService
|
||||||
.Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl()))
|
.Thumbnail(new Embed.EmbedThumbnail(system.AvatarUrl.TryGetCleanCdnUrl()))
|
||||||
.Footer(new Embed.EmbedFooter(
|
.Footer(new Embed.EmbedFooter(
|
||||||
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
|
$"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))
|
if (system.DescriptionPrivacy.CanAccess(ctx))
|
||||||
eb.Image(new Embed.EmbedImage(system.BannerImage));
|
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}"))
|
$"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"));
|
.Timestamp(timestamp.ToDateTimeOffset().ToString("O"));
|
||||||
|
|
||||||
|
if (oldContent == "")
|
||||||
|
oldContent = "*no message content*";
|
||||||
|
|
||||||
if (oldContent != null)
|
if (oldContent != null)
|
||||||
embed.Field(new Embed.Field("Old message", oldContent?.NormalizeLineEndSpacing().Truncate(1000)));
|
embed.Field(new Embed.Field("Old message", oldContent?.NormalizeLineEndSpacing().Truncate(1000)));
|
||||||
|
|
||||||
|
|
@ -179,8 +183,7 @@ public class EmbedService
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var eb = new EmbedBuilder()
|
var eb = new EmbedBuilder()
|
||||||
// TODO: add URL of website when that's up
|
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
|
||||||
.Author(new Embed.EmbedAuthor(name, IconUrl: avatar.TryGetCleanCdnUrl()))
|
|
||||||
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
|
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
|
||||||
.Color(color)
|
.Color(color)
|
||||||
.Footer(new Embed.EmbedFooter(
|
.Footer(new Embed.EmbedFooter(
|
||||||
|
|
@ -264,7 +267,7 @@ public class EmbedService
|
||||||
}
|
}
|
||||||
|
|
||||||
var eb = new EmbedBuilder()
|
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);
|
.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)}" : "")}"));
|
eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,16 @@ public class LogChannelService
|
||||||
_logger = logger.ForContext<LogChannelService>();
|
_logger = logger.ForContext<LogChannelService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask LogMessage(MessageContext ctx, PKMessage proxiedMessage, Message trigger,
|
public async ValueTask LogMessage(PKMessage proxiedMessage, Message trigger, Message hookMessage, string oldContent = null)
|
||||||
Message hookMessage, string oldContent = null)
|
|
||||||
{
|
{
|
||||||
var logChannelId = await GetAndCheckLogChannel(ctx, trigger, proxiedMessage);
|
var logChannelId = await GetAndCheckLogChannel(trigger, proxiedMessage);
|
||||||
if (logChannelId == null)
|
if (logChannelId == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var triggerChannel = await _cache.GetChannel(proxiedMessage.Channel);
|
var triggerChannel = await _cache.GetChannel(proxiedMessage.Channel);
|
||||||
|
|
||||||
var system = await _repo.GetSystem(ctx.SystemId.Value);
|
|
||||||
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
|
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
|
||||||
|
var system = await _repo.GetSystem(member.System);
|
||||||
|
|
||||||
// Send embed!
|
// Send embed!
|
||||||
var embed = _embed.CreateLoggedMessageEmbed(trigger, hookMessage, system.Hid, member, triggerChannel.Name,
|
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 } });
|
await _rest.CreateMessage(logChannelId.Value, new MessageRequest { Content = url, Embeds = new[] { embed } });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ulong?> GetAndCheckLogChannel(MessageContext ctx, Message trigger,
|
private async Task<ulong?> GetAndCheckLogChannel(Message trigger, PKMessage proxiedMessage)
|
||||||
PKMessage proxiedMessage)
|
|
||||||
{
|
{
|
||||||
if (proxiedMessage.Guild == null && proxiedMessage.Channel != trigger.ChannelId)
|
if (proxiedMessage.Guild == null && proxiedMessage.Channel != trigger.ChannelId)
|
||||||
// a very old message is being edited outside of its original channel
|
// a very old message is being edited outside of its original channel
|
||||||
|
|
@ -63,18 +61,15 @@ public class LogChannelService
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
|
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
|
||||||
var logChannelId = ctx.LogChannel;
|
|
||||||
var isBlacklisted = ctx.InLogBlacklist;
|
|
||||||
|
|
||||||
if (proxiedMessage.Guild != trigger.GuildId)
|
// get log channel info from the database
|
||||||
{
|
var guild = await _repo.GetGuild(guildId);
|
||||||
// we're editing a message from a different server, get log channel info from the database
|
var logChannelId = guild.LogChannel;
|
||||||
var guild = await _repo.GetGuild(proxiedMessage.Guild.Value);
|
var isBlacklisted = guild.LogBlacklist.Any(x => x == trigger.ChannelId);
|
||||||
logChannelId = guild.LogChannel;
|
|
||||||
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
|
// Find log channel and check if valid
|
||||||
var logChannel = await FindLogChannel(guildId, logChannelId.Value);
|
var logChannel = await FindLogChannel(guildId, logChannelId.Value);
|
||||||
|
|
@ -86,7 +81,7 @@ public class LogChannelService
|
||||||
{
|
{
|
||||||
_logger.Information(
|
_logger.Information(
|
||||||
"Does not have permission to log proxy, ignoring (channel: {ChannelId}, guild: {GuildId}, bot permissions: {BotPermissions})",
|
"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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using App.Metrics;
|
||||||
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
using Myriad.Gateway;
|
using Myriad.Gateway;
|
||||||
|
|
@ -9,13 +11,13 @@ namespace PluralKit.Bot;
|
||||||
|
|
||||||
public class PrivateChannelService
|
public class PrivateChannelService
|
||||||
{
|
{
|
||||||
private static readonly Dictionary<ulong, ulong> _channelsCache = new();
|
private readonly IMetrics _metrics;
|
||||||
|
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ModelRepository _repo;
|
private readonly ModelRepository _repo;
|
||||||
private readonly DiscordApiClient _rest;
|
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;
|
_logger = logger;
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_rest = rest;
|
_rest = rest;
|
||||||
|
|
@ -23,35 +25,32 @@ public class PrivateChannelService
|
||||||
|
|
||||||
public async Task TrySavePrivateChannel(MessageCreateEvent evt)
|
public async Task TrySavePrivateChannel(MessageCreateEvent evt)
|
||||||
{
|
{
|
||||||
if (evt.GuildId != null) return;
|
if (evt.GuildId == null) await SaveDmChannel(evt.Author.Id, evt.ChannelId);
|
||||||
if (_channelsCache.TryGetValue(evt.Author.Id, out _)) return;
|
|
||||||
|
|
||||||
await SaveDmChannel(evt.Author.Id, evt.ChannelId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ulong> GetOrCreateDmChannel(ulong userId)
|
public async Task<ulong> GetOrCreateDmChannel(ulong userId)
|
||||||
{
|
{
|
||||||
if (_channelsCache.TryGetValue(userId, out var cachedChannelId))
|
|
||||||
return cachedChannelId;
|
|
||||||
|
|
||||||
var channelId = await _repo.GetDmChannel(userId);
|
var channelId = await _repo.GetDmChannel(userId);
|
||||||
if (channelId == null)
|
if (channelId != null)
|
||||||
{
|
{
|
||||||
var channel = await _rest.CreateDm(userId);
|
_metrics.Measure.Meter.Mark(BotMetrics.DatabaseDMCacheHits);
|
||||||
channelId = channel.Id;
|
return channelId.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawn off saving the channel as to not block the current thread
|
_metrics.Measure.Meter.Mark(BotMetrics.DMCacheMisses);
|
||||||
_ = SaveDmChannel(userId, channelId.Value);
|
|
||||||
|
|
||||||
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)
|
private async Task SaveDmChannel(ulong userId, ulong channelId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_channelsCache.Add(userId, channelId);
|
|
||||||
await _repo.UpdateAccount(userId, new() { DmChannel = channelId });
|
await _repo.UpdateAccount(userId, new() { DmChannel = channelId });
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public class RedisGatewayService
|
||||||
_redis = await ConnectionMultiplexer.ConnectAsync(_config.RedisGatewayUrl);
|
_redis = await ConnectionMultiplexer.ConnectAsync(_config.RedisGatewayUrl);
|
||||||
|
|
||||||
_logger.Debug("Subscribing to shard {ShardId} on redis", shardId);
|
_logger.Debug("Subscribing to shard {ShardId} on redis", shardId);
|
||||||
|
|
||||||
var channel = await _redis.GetSubscriber().SubscribeAsync($"evt-{shardId}");
|
var channel = await _redis.GetSubscriber().SubscribeAsync($"evt-{shardId}");
|
||||||
channel.OnMessage((evt) => Handle(shardId, evt));
|
channel.OnMessage((evt) => Handle(shardId, evt));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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);
|
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,
|
private async Task TrySendRemainingAttachments(Webhook webhook, string name, string avatarUrl,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public class InteractionContext
|
||||||
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
|
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
|
||||||
new InteractionApplicationCommandCallbackData
|
new InteractionApplicationCommandCallbackData
|
||||||
{
|
{
|
||||||
// Components = _evt.Message.Components
|
Components = Event.Message.Components
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,12 @@ public class SentryEnricher:
|
||||||
ISentryEnricher<MessageReactionAddEvent>
|
ISentryEnricher<MessageReactionAddEvent>
|
||||||
{
|
{
|
||||||
private readonly Bot _bot;
|
private readonly Bot _bot;
|
||||||
|
private readonly BotConfig _config;
|
||||||
|
|
||||||
public SentryEnricher(Bot bot)
|
public SentryEnricher(Bot bot, BotConfig config)
|
||||||
{
|
{
|
||||||
_bot = bot;
|
_bot = bot;
|
||||||
|
_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: should this class take the Scope by dependency injection instead?
|
// TODO: should this class take the Scope by dependency injection instead?
|
||||||
|
|
@ -37,6 +39,8 @@ public class SentryEnricher:
|
||||||
{"message", evt.Id.ToString()}
|
{"message", evt.Id.ToString()}
|
||||||
});
|
});
|
||||||
scope.SetTag("shard", shardId.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
|
// 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
|
// 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)}
|
{"messages", string.Join(",", evt.Ids)}
|
||||||
});
|
});
|
||||||
scope.SetTag("shard", shardId.ToString());
|
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)
|
public void Enrich(Scope scope, int shardId, MessageUpdateEvent evt)
|
||||||
|
|
@ -68,6 +74,8 @@ public class SentryEnricher:
|
||||||
{"message", evt.Id.ToString()}
|
{"message", evt.Id.ToString()}
|
||||||
});
|
});
|
||||||
scope.SetTag("shard", shardId.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)
|
public void Enrich(Scope scope, int shardId, MessageDeleteEvent evt)
|
||||||
|
|
@ -80,6 +88,8 @@ public class SentryEnricher:
|
||||||
{"message", evt.Id.ToString()}
|
{"message", evt.Id.ToString()}
|
||||||
});
|
});
|
||||||
scope.SetTag("shard", shardId.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)
|
public void Enrich(Scope scope, int shardId, MessageReactionAddEvent evt)
|
||||||
|
|
@ -94,5 +104,7 @@ public class SentryEnricher:
|
||||||
{"reaction", evt.Emoji.Name}
|
{"reaction", evt.Emoji.Name}
|
||||||
});
|
});
|
||||||
scope.SetTag("shard", shardId.ToString());
|
scope.SetTag("shard", shardId.ToString());
|
||||||
|
if (_config.Cluster != null)
|
||||||
|
scope.SetTag("cluster", _config.Cluster!.NodeIndex.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -24,9 +24,9 @@
|
||||||
},
|
},
|
||||||
"Grpc.Tools": {
|
"Grpc.Tools": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[2.37.0, )",
|
"requested": "[2.47.0, )",
|
||||||
"resolved": "2.37.0",
|
"resolved": "2.47.0",
|
||||||
"contentHash": "cud/urkbw3QoQ8+kNeCy2YI0sHrh7td/1cZkVbH6hDLIXX7zzmJbV/KjYSiqiYtflQf+S5mJPLzDQWScN/QdDg=="
|
"contentHash": "nInNoLfT/zR7+0VNIC4Lu5nF8azjTz3KwHB1ckwsYUxvof4uSxIt/LlCKb/NH7GPfXfdvqDDinguPpP5t55nuA=="
|
||||||
},
|
},
|
||||||
"Humanizer.Core": {
|
"Humanizer.Core": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace PluralKit.Core;
|
||||||
public class CoreConfig
|
public class CoreConfig
|
||||||
{
|
{
|
||||||
public string Database { get; set; }
|
public string Database { get; set; }
|
||||||
|
public string? DatabasePassword { get; set; }
|
||||||
public string RedisAddr { get; set; }
|
public string RedisAddr { get; set; }
|
||||||
public bool UseRedisMetrics { get; set; } = false;
|
public bool UseRedisMetrics { get; set; } = false;
|
||||||
public string SentryUrl { get; set; }
|
public string SentryUrl { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ internal partial class Database: IDatabase
|
||||||
_migrator = migrator;
|
_migrator = migrator;
|
||||||
_logger = logger.ForContext<Database>();
|
_logger = logger.ForContext<Database>();
|
||||||
|
|
||||||
_connectionString = new NpgsqlConnectionStringBuilder(_config.Database)
|
var connectionString = new NpgsqlConnectionStringBuilder(_config.Database)
|
||||||
{
|
{
|
||||||
Pooling = true,
|
Pooling = true,
|
||||||
Enlist = false,
|
Enlist = false,
|
||||||
|
|
@ -43,7 +43,12 @@ internal partial class Database: IDatabase
|
||||||
|
|
||||||
// Lower timeout than default (15s -> 2s), should ideally fail-fast instead of hanging
|
// Lower timeout than default (15s -> 2s), should ideally fail-fast instead of hanging
|
||||||
Timeout = 2
|
Timeout = 2
|
||||||
}.ConnectionString;
|
};
|
||||||
|
|
||||||
|
if (_config.DatabasePassword != null)
|
||||||
|
connectionString.Password = _config.DatabasePassword;
|
||||||
|
|
||||||
|
_connectionString = connectionString.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly PostgresCompiler _compiler = new();
|
private static readonly PostgresCompiler _compiler = new();
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ public class MessageContext
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a system is being deleted (no actions should be taken, or commands ran)
|
/// Whether a system is being deleted (no actions should be taken, or commands ran)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsDeleting { get; }
|
|
||||||
public ulong? LogChannel { get; }
|
public ulong? LogChannel { get; }
|
||||||
public bool InBlacklist { get; }
|
public bool InBlacklist { get; }
|
||||||
public bool InLogBlacklist { get; }
|
public bool InLogBlacklist { get; }
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
create function message_context(account_id bigint, guild_id bigint, channel_id bigint)
|
create function message_context(account_id bigint, guild_id bigint, channel_id bigint)
|
||||||
returns table (
|
returns table (
|
||||||
system_id int,
|
system_id int,
|
||||||
is_deleting bool,
|
|
||||||
log_channel bigint,
|
log_channel bigint,
|
||||||
in_blacklist bool,
|
in_blacklist bool,
|
||||||
in_log_blacklist bool,
|
in_log_blacklist bool,
|
||||||
|
|
@ -28,7 +27,6 @@ as $$
|
||||||
guild as (select * from servers where id = guild_id)
|
guild as (select * from servers where id = guild_id)
|
||||||
select
|
select
|
||||||
system.id as system_id,
|
system.id as system_id,
|
||||||
system.is_deleting,
|
|
||||||
guild.log_channel,
|
guild.log_channel,
|
||||||
(channel_id = any (guild.blacklist)) as in_blacklist,
|
(channel_id = any (guild.blacklist)) as in_blacklist,
|
||||||
(channel_id = any (guild.log_blacklist)) as in_log_blacklist,
|
(channel_id = any (guild.log_blacklist)) as in_log_blacklist,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ public partial class ModelRepository
|
||||||
public async Task<ulong?> GetDmChannel(ulong id)
|
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 }));
|
=> 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)
|
public async Task UpdateAccount(ulong id, AccountPatch patch)
|
||||||
{
|
{
|
||||||
_logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch);
|
_logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch);
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ namespace PluralKit.Core;
|
||||||
|
|
||||||
public partial class ModelRepository
|
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");
|
var locationStr = guildId != null ? "guild" : (channelId != null ? "channel" : "global");
|
||||||
_logger.Information("Updated autoproxy for {SystemId} in location {location}: {@AutoproxyPatch}", system, locationStr, patch);
|
_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)
|
.Where("channel_id", channelId ?? 0)
|
||||||
);
|
);
|
||||||
_ = _dispatch.Dispatch(system, guildId, channelId, patch);
|
_ = _dispatch.Dispatch(system, guildId, channelId, patch);
|
||||||
await _db.ExecuteQuery(query);
|
return _db.QueryFirst<AutoproxySettings>(query, "returning *");
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: this might break with differently scoped autoproxy
|
// todo: this might break with differently scoped autoproxy
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
}
|
|
||||||
|
|
@ -4,20 +4,6 @@ namespace PluralKit.Core;
|
||||||
|
|
||||||
public partial class ModelRepository
|
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()
|
public Task<Counts> GetStats()
|
||||||
=> _db.Execute(conn => conn.QuerySingleAsync<Counts>("select * from info"));
|
=> _db.Execute(conn => conn.QuerySingleAsync<Counts>("select * from info"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,11 @@ public partial class ModelRepository
|
||||||
_logger.Information("Updated {SwitchId} members: {Members}", switchId, members);
|
_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);
|
_logger.Information("Updated {SwitchId} timestamp: {SwitchTimestamp}", id, time);
|
||||||
var query = new Query("switches").AsUpdate(new { timestamp = time }).Where("id", id);
|
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
|
_ = _dispatch.Dispatch(id, new UpdateDispatchData
|
||||||
{
|
{
|
||||||
Event = DispatchEvent.UPDATE_SWITCH,
|
Event = DispatchEvent.UPDATE_SWITCH,
|
||||||
|
|
@ -105,6 +105,7 @@ public partial class ModelRepository
|
||||||
timestamp = time.FormatExport(),
|
timestamp = time.FormatExport(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteSwitch(SwitchId id)
|
public async Task DeleteSwitch(SwitchId id)
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,6 @@ public partial class ModelRepository
|
||||||
|
|
||||||
public async Task DeleteSystem(SystemId id)
|
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);
|
var query = new Query("systems").AsDelete().Where("id", id);
|
||||||
await _db.ExecuteQuery(query);
|
await _db.ExecuteQuery(query);
|
||||||
_logger.Information("Deleted {SystemId}", id);
|
_logger.Information("Deleted {SystemId}", id);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ public class AutoproxySettings
|
||||||
{
|
{
|
||||||
public AutoproxyMode AutoproxyMode { get; }
|
public AutoproxyMode AutoproxyMode { get; }
|
||||||
public MemberId? AutoproxyMember { get; }
|
public MemberId? AutoproxyMember { get; }
|
||||||
public Instant LastLatchTimestamp { get; }
|
public Instant? LastLatchTimestamp { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AutoproxyExt
|
public static class AutoproxyExt
|
||||||
|
|
@ -27,7 +27,8 @@ public static class AutoproxyExt
|
||||||
|
|
||||||
// tbd
|
// tbd
|
||||||
o.Add("autoproxy_mode", settings.AutoproxyMode.ToString().ToLower());
|
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;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
using SqlKata;
|
using SqlKata;
|
||||||
|
|
@ -16,4 +18,30 @@ public class AutoproxyPatch: PatchObject
|
||||||
.With("autoproxy_member", AutoproxyMember)
|
.With("autoproxy_member", AutoproxyMember)
|
||||||
.With("last_latch_timestamp", LastLatchTimestamp)
|
.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -30,23 +30,23 @@ public partial class BulkImporter
|
||||||
|
|
||||||
await _repo.UpdateSystem(_system.Id, patch, _conn);
|
await _repo.UpdateSystem(_system.Id, patch, _conn);
|
||||||
|
|
||||||
var configPatch = new SystemConfigPatch();
|
|
||||||
|
|
||||||
if (importFile.ContainsKey("config"))
|
if (importFile.ContainsKey("config"))
|
||||||
configPatch = SystemConfigPatch.FromJson(importFile.Value<JObject>("config"));
|
{
|
||||||
|
var configPatch = SystemConfigPatch.FromJson(importFile.Value<JObject>("config"));
|
||||||
|
|
||||||
if (importFile.ContainsKey("timezone"))
|
if (importFile.ContainsKey("timezone"))
|
||||||
configPatch.UiTz = importFile.Value<string>("timezone");
|
configPatch.UiTz = importFile.Value<string>("timezone");
|
||||||
|
|
||||||
configPatch.AssertIsValid();
|
configPatch.AssertIsValid();
|
||||||
if (configPatch.Errors.Count > 0)
|
if (configPatch.Errors.Count > 0)
|
||||||
throw new ImportException($"Field config.{patch.Errors[0].Key} in export file is invalid.");
|
throw new ImportException($"Field config.{patch.Errors[0].Key} in export file is invalid.");
|
||||||
|
|
||||||
await _repo.UpdateSystemConfig(_system.Id, configPatch, _conn);
|
await _repo.UpdateSystemConfig(_system.Id, configPatch, _conn);
|
||||||
|
}
|
||||||
|
|
||||||
var members = importFile.Value<JArray>("members");
|
var members = importFile.Value<JArray>("members");
|
||||||
var groups = importFile.Value<JArray>("groups");
|
|
||||||
var switches = importFile.Value<JArray>("switches");
|
var switches = importFile.Value<JArray>("switches");
|
||||||
|
var groups = importFile.Value<JArray>("groups");
|
||||||
|
|
||||||
var newMembers = members.Count(m =>
|
var newMembers = members.Count(m =>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -11,8 +11,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.Tests", "PluralKi
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Myriad", "Myriad\Myriad.csproj", "{ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Myriad", "Myriad\Myriad.csproj", "{ACB9BF37-F29C-4068-A7D1-2EFF2C308C4B}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluralKit.ScheduledTasks", "PluralKit.ScheduledTasks\PluralKit.ScheduledTasks.csproj", "{374A8EB3-655D-4230-982B-459AE3553991}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
# 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 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:
|
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
|
## Docker
|
||||||
The easiest way to get the bot running is with Docker. The repository contains a `docker-compose.yml` file ready to use.
|
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
|
* 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)
|
* (`PluralKit.Database` is overridden in `docker-compose.yml` to point to the Postgres container)
|
||||||
* Build the bot: `docker-compose build`
|
* 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:
|
In other words:
|
||||||
```
|
```
|
||||||
$ git clone https://github.com/xSke/PluralKit
|
$ git clone https://github.com/PluralKit/PluralKit
|
||||||
$ cd PluralKit
|
$ cd PluralKit
|
||||||
$ cp pluralkit.conf.example pluralkit.conf
|
$ cp pluralkit.conf.example pluralkit.conf
|
||||||
$ nano pluralkit.conf # (or vim, or whatever)
|
$ nano pluralkit.conf # (or vim, or whatever)
|
||||||
|
|
@ -48,7 +48,7 @@ $ docker-compose up -d
|
||||||
|
|
||||||
## Manually
|
## Manually
|
||||||
* Install the .NET 6 SDK (see https://dotnet.microsoft.com/download)
|
* 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`
|
* Create and fill in a `pluralkit.conf` file in the same directory as `docker-compose.yml`
|
||||||
* Run the bot: `dotnet run --project PluralKit.Bot`
|
* Run the bot: `dotnet run --project PluralKit.Bot`
|
||||||
* Alternatively, `dotnet build -c Release -o build/`, then `dotnet build/PluralKit.Bot.dll`
|
* Alternatively, `dotnet build -c Release -o build/`, then `dotnet build/PluralKit.Bot.dll`
|
||||||
|
|
|
||||||
3
dashboard/.gitignore
vendored
Normal file
3
dashboard/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
dashboard
|
||||||
19
dashboard/Dockerfile
Normal file
19
dashboard/Dockerfile
Normal 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
12
dashboard/README.md
Normal 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
5
dashboard/go.mod
Normal 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
2
dashboard/go.sum
Normal 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
16
dashboard/index.html
Normal 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
145
dashboard/main.go
Normal 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
42
dashboard/package.json
Normal 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
BIN
dashboard/public/myriad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 664 KiB |
85
dashboard/src/App.svelte
Normal file
85
dashboard/src/App.svelte
Normal 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>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue