mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-15 10:10:12 +00:00
Merge branch 'main' into rust-command-parser
This commit is contained in:
commit
77276c15e6
119 changed files with 4837 additions and 1017 deletions
3
.cargo/config.toml
Normal file
3
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[build]
|
||||||
|
rustflags = ["-C", "target-cpu=native"]
|
||||||
|
|
||||||
11
.github/workflows/rust.yml
vendored
11
.github/workflows/rust.yml
vendored
|
|
@ -3,16 +3,19 @@
|
||||||
# todo: don't use docker/build-push-action
|
# todo: don't use docker/build-push-action
|
||||||
# todo: run builds on pull request
|
# todo: run builds on pull request
|
||||||
|
|
||||||
name: Build and push API Docker image
|
name: Build and push Rust service Docker images
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- 'lib/libpk/**'
|
- 'lib/libpk/**'
|
||||||
- 'services/api/**'
|
- 'services/api/**'
|
||||||
|
- 'services/gateway/**'
|
||||||
|
- 'services/avatars/**'
|
||||||
- '.github/workflows/rust.yml'
|
- '.github/workflows/rust.yml'
|
||||||
- 'Dockerfile.rust'
|
- 'Dockerfile.rust'
|
||||||
|
- 'Dockerfile.bin'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
|
@ -45,7 +48,7 @@ jobs:
|
||||||
|
|
||||||
# add more binaries here
|
# add more binaries here
|
||||||
- run: |
|
- run: |
|
||||||
for binary in "api"; do
|
for binary in "api" "gateway" "avatars"; do
|
||||||
for tag in latest ${{ env.BRANCH_NAME }} ${{ github.sha }}; do
|
for tag in latest ${{ env.BRANCH_NAME }} ${{ github.sha }}; do
|
||||||
cat Dockerfile.bin | sed "s/__BINARY__/$binary/g" | docker build -t ghcr.io/pluralkit/$binary:$tag -f - .
|
cat Dockerfile.bin | sed "s/__BINARY__/$binary/g" | docker build -t ghcr.io/pluralkit/$binary:$tag -f - .
|
||||||
done
|
done
|
||||||
|
|
|
||||||
16
.github/workflows/rustfmt.yml
vendored
Normal file
16
.github/workflows/rustfmt.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
name: "Check Rust formatting"
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rustfmt:
|
||||||
|
name: cargo fmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- name: Rustfmt Check
|
||||||
|
uses: actions-rust-lang/rustfmt@v1
|
||||||
3
.github/workflows/scheduled_tasks.yml
vendored
3
.github/workflows/scheduled_tasks.yml
vendored
|
|
@ -2,8 +2,9 @@ name: Build scheduled tasks runner Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, gateway-service]
|
||||||
paths:
|
paths:
|
||||||
|
- .github/workflows/scheduled_tasks.yml
|
||||||
- 'services/scheduled_tasks/**'
|
- 'services/scheduled_tasks/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
1328
Cargo.lock
generated
1328
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
34
Cargo.toml
34
Cargo.toml
|
|
@ -4,21 +4,43 @@ members = [
|
||||||
"./lib/libpk",
|
"./lib/libpk",
|
||||||
"./lib/commands",
|
"./lib/commands",
|
||||||
"./services/api",
|
"./services/api",
|
||||||
"./services/dispatch"
|
"./services/dispatch",
|
||||||
|
"./services/gateway",
|
||||||
|
"./services/avatars"
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum = "0.7.5"
|
axum = "0.7.5"
|
||||||
|
axum-macros = "0.4.1"
|
||||||
|
bytes = "1.6.0"
|
||||||
|
chrono = "0.4"
|
||||||
fred = { version = "5.2.0", default-features = false, features = ["tracing", "pool-prefer-active"] }
|
fred = { version = "5.2.0", default-features = false, features = ["tracing", "pool-prefer-active"] }
|
||||||
|
futures = "0.3.30"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
metrics = "0.20.1"
|
metrics = "0.23.0"
|
||||||
serde = "1.0.152"
|
reqwest = { version = "0.12.7" , default-features = false, features = ["rustls-tls", "trust-dns"]}
|
||||||
|
serde = { version = "1.0.196", features = ["derive"] }
|
||||||
serde_json = "1.0.117"
|
serde_json = "1.0.117"
|
||||||
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "chrono", "macros"] }
|
signal-hook = "0.3.17"
|
||||||
tokio = { version = "1.25.0", features = ["full"] }
|
sqlx = { version = "0.7.4", features = ["runtime-tokio", "postgres", "time", "macros", "uuid"] }
|
||||||
tracing = "0.1.37"
|
time = "0.3.34"
|
||||||
|
tokio = { version = "1.36.0", features = ["full"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"] }
|
||||||
|
uuid = { version = "1.7.0", features = ["serde"] }
|
||||||
|
|
||||||
|
twilight-gateway = { git = "https://github.com/pluralkit/twilight" }
|
||||||
|
twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", features = ["permission-calculator"] }
|
||||||
|
twilight-util = { git = "https://github.com/pluralkit/twilight", features = ["permission-calculator"] }
|
||||||
|
twilight-model = { git = "https://github.com/pluralkit/twilight" }
|
||||||
|
twilight-http = { git = "https://github.com/pluralkit/twilight", default-features = false, features = ["rustls-native-roots"] }
|
||||||
|
|
||||||
|
#twilight-gateway = { path = "../twilight/twilight-gateway" }
|
||||||
|
#twilight-cache-inmemory = { path = "../twilight/twilight-cache-inmemory", features = ["permission-calculator"] }
|
||||||
|
#twilight-util = { path = "../twilight/twilight-util", features = ["permission-calculator"] }
|
||||||
|
#twilight-model = { path = "../twilight/twilight-model" }
|
||||||
|
#twilight-http = { path = "../twilight/twilight-http", default-features = false, features = ["rustls-native-roots"] }
|
||||||
|
|
||||||
prost = "0.12"
|
prost = "0.12"
|
||||||
prost-types = "0.12"
|
prost-types = "0.12"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ WORKDIR /build
|
||||||
|
|
||||||
RUN apk add rustup build-base protoc
|
RUN apk add rustup build-base protoc
|
||||||
# todo: arm64 target
|
# todo: arm64 target
|
||||||
RUN rustup-init --default-host x86_64-unknown-linux-musl --default-toolchain stable --profile default -y
|
RUN rustup-init --default-host x86_64-unknown-linux-musl --default-toolchain nightly-2024-08-20 --profile default -y
|
||||||
|
|
||||||
ENV PATH=/root/.cargo/bin:$PATH
|
ENV PATH=/root/.cargo/bin:$PATH
|
||||||
ENV RUSTFLAGS='-C link-arg=-s'
|
ENV RUSTFLAGS='-C link-arg=-s'
|
||||||
|
|
@ -27,9 +27,15 @@ COPY proto/ /build/proto
|
||||||
# this needs to match workspaces in Cargo.toml
|
# this needs to match workspaces in Cargo.toml
|
||||||
COPY lib/libpk /build/lib/libpk
|
COPY lib/libpk /build/lib/libpk
|
||||||
COPY services/api/ /build/services/api
|
COPY services/api/ /build/services/api
|
||||||
|
COPY services/gateway/ /build/services/gateway
|
||||||
|
COPY services/avatars/ /build/services/avatars
|
||||||
|
|
||||||
RUN cargo build --bin api --release --target x86_64-unknown-linux-musl
|
RUN cargo build --bin api --release --target x86_64-unknown-linux-musl
|
||||||
|
RUN cargo build --bin gateway --release --target x86_64-unknown-linux-musl
|
||||||
|
RUN cargo build --bin avatars --release --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
COPY --from=binary-builder /build/target/x86_64-unknown-linux-musl/release/api /api
|
COPY --from=binary-builder /build/target/x86_64-unknown-linux-musl/release/api /api
|
||||||
|
COPY --from=binary-builder /build/target/x86_64-unknown-linux-musl/release/gateway /gateway
|
||||||
|
COPY --from=binary-builder /build/target/x86_64-unknown-linux-musl/release/avatars /avatars
|
||||||
|
|
|
||||||
|
|
@ -100,15 +100,19 @@ public static class DiscordCacheExtensions
|
||||||
await cache.SaveChannel(thread);
|
await cache.SaveChannel(thread);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<PermissionSet> BotPermissionsIn(this IDiscordCache cache, ulong channelId)
|
public static async Task<PermissionSet> BotPermissionsIn(this IDiscordCache cache, ulong guildId, ulong channelId)
|
||||||
{
|
{
|
||||||
var channel = await cache.GetRootChannel(channelId);
|
// disable this for now
|
||||||
|
//if (cache is HttpDiscordCache)
|
||||||
|
// return await ((HttpDiscordCache)cache).BotChannelPermissions(guildId, channelId);
|
||||||
|
|
||||||
|
var channel = await cache.GetRootChannel(guildId, channelId);
|
||||||
|
|
||||||
if (channel.GuildId != null)
|
if (channel.GuildId != null)
|
||||||
{
|
{
|
||||||
var userId = cache.GetOwnUser();
|
var userId = cache.GetOwnUser();
|
||||||
var member = await cache.TryGetSelfMember(channel.GuildId.Value);
|
var member = await cache.TryGetSelfMember(channel.GuildId.Value);
|
||||||
return await cache.PermissionsFor2(channelId, userId, member);
|
return await cache.PermissionsFor2(guildId, channelId, userId, member);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PermissionSet.Dm;
|
return PermissionSet.Dm;
|
||||||
|
|
|
||||||
187
Myriad/Cache/HTTPDiscordCache.cs
Normal file
187
Myriad/Cache/HTTPDiscordCache.cs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
using Serilog;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
using Myriad.Serialization;
|
||||||
|
using Myriad.Types;
|
||||||
|
|
||||||
|
namespace Myriad.Cache;
|
||||||
|
|
||||||
|
public class HttpDiscordCache: IDiscordCache
|
||||||
|
{
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
private readonly Uri _cacheEndpoint;
|
||||||
|
private readonly int _shardCount;
|
||||||
|
private readonly ulong _ownUserId;
|
||||||
|
|
||||||
|
private readonly MemoryDiscordCache _innerCache;
|
||||||
|
|
||||||
|
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||||
|
|
||||||
|
public EventHandler<(bool?, string)> OnDebug;
|
||||||
|
|
||||||
|
public HttpDiscordCache(ILogger logger, HttpClient client, string cacheEndpoint, int shardCount, ulong ownUserId, bool useInnerCache)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_client = client;
|
||||||
|
_cacheEndpoint = new Uri(cacheEndpoint);
|
||||||
|
_shardCount = shardCount;
|
||||||
|
_ownUserId = ownUserId;
|
||||||
|
_jsonSerializerOptions = new JsonSerializerOptions().ConfigureForMyriad();
|
||||||
|
if (useInnerCache) _innerCache = new MemoryDiscordCache(ownUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask SaveGuild(Guild guild) => _innerCache?.SaveGuild(guild) ?? default;
|
||||||
|
public ValueTask SaveChannel(Channel channel) => _innerCache?.SaveChannel(channel) ?? default;
|
||||||
|
public ValueTask SaveUser(User user) => default;
|
||||||
|
public ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member) => _innerCache?.SaveSelfMember(guildId, member) ?? default;
|
||||||
|
public ValueTask SaveRole(ulong guildId, Myriad.Types.Role role) => _innerCache?.SaveRole(guildId, role) ?? default;
|
||||||
|
public ValueTask SaveDmChannelStub(ulong channelId) => _innerCache?.SaveDmChannelStub(channelId) ?? default;
|
||||||
|
public ValueTask RemoveGuild(ulong guildId) => _innerCache?.RemoveGuild(guildId) ?? default;
|
||||||
|
public ValueTask RemoveChannel(ulong channelId) => _innerCache?.RemoveChannel(channelId) ?? default;
|
||||||
|
public ValueTask RemoveUser(ulong userId) => _innerCache?.RemoveUser(userId) ?? default;
|
||||||
|
public ValueTask RemoveRole(ulong guildId, ulong roleId) => _innerCache?.RemoveRole(guildId, roleId) ?? default;
|
||||||
|
|
||||||
|
public ulong GetOwnUser() => _ownUserId;
|
||||||
|
|
||||||
|
private async Task<T?> QueryCache<T>(string endpoint, ulong guildId)
|
||||||
|
{
|
||||||
|
var cluster = _cacheEndpoint.Authority;
|
||||||
|
if (cluster.Contains(".service.consul"))
|
||||||
|
// int(((guild_id >> 22) % shard_count) / 16)
|
||||||
|
cluster = $"cluster{(int)(((guildId >> 22) % (ulong)_shardCount) / 16)}.{cluster}";
|
||||||
|
|
||||||
|
var response = await _client.GetAsync($"{_cacheEndpoint.Scheme}://{cluster}{endpoint}");
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return default;
|
||||||
|
|
||||||
|
if (response.StatusCode != HttpStatusCode.Found)
|
||||||
|
throw new Exception($"failed to query http cache: {response.StatusCode}");
|
||||||
|
|
||||||
|
var plaintext = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<T>(plaintext, _jsonSerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Guild?> TryGetGuild(ulong guildId)
|
||||||
|
{
|
||||||
|
var hres = await QueryCache<Guild?>($"/guilds/{guildId}", guildId);
|
||||||
|
if (_innerCache == null) return hres;
|
||||||
|
var lres = await _innerCache.TryGetGuild(guildId);
|
||||||
|
|
||||||
|
if (lres == null && hres == null) return null;
|
||||||
|
if (lres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetGuild({guildId}) was only successful on remote cache");
|
||||||
|
OnDebug(null, (true, "guild"));
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
if (hres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetGuild({guildId}) was only successful on local cache");
|
||||||
|
OnDebug(null, (false, "guild"));
|
||||||
|
return lres;
|
||||||
|
}
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Channel?> TryGetChannel(ulong guildId, ulong channelId)
|
||||||
|
{
|
||||||
|
var hres = await QueryCache<Channel?>($"/guilds/{guildId}/channels/{channelId}", guildId);
|
||||||
|
if (_innerCache == null) return hres;
|
||||||
|
var lres = await _innerCache.TryGetChannel(guildId, channelId);
|
||||||
|
if (lres == null && hres == null) return null;
|
||||||
|
if (lres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetChannel({guildId}, {channelId}) was only successful on remote cache");
|
||||||
|
OnDebug(null, (true, "channel"));
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
if (hres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetChannel({guildId}, {channelId}) was only successful on local cache");
|
||||||
|
OnDebug(null, (false, "channel"));
|
||||||
|
return lres;
|
||||||
|
}
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
|
||||||
|
// this should be a GetUserCached method on nirn-proxy (it's always called as GetOrFetchUser)
|
||||||
|
// so just return nothing
|
||||||
|
public Task<User?> TryGetUser(ulong userId)
|
||||||
|
=> Task.FromResult<User?>(null);
|
||||||
|
|
||||||
|
public async Task<GuildMemberPartial?> TryGetSelfMember(ulong guildId)
|
||||||
|
{
|
||||||
|
var hres = await QueryCache<GuildMemberPartial?>($"/guilds/{guildId}/members/@me", guildId);
|
||||||
|
if (_innerCache == null) return hres;
|
||||||
|
var lres = await _innerCache.TryGetSelfMember(guildId);
|
||||||
|
if (lres == null && hres == null) return null;
|
||||||
|
if (lres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetSelfMember({guildId}) was only successful on remote cache");
|
||||||
|
OnDebug(null, (true, "self_member"));
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
if (hres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"TryGetSelfMember({guildId}) was only successful on local cache");
|
||||||
|
OnDebug(null, (false, "self_member"));
|
||||||
|
return lres;
|
||||||
|
}
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
|
||||||
|
// public async Task<PermissionSet> BotChannelPermissions(ulong guildId, ulong channelId)
|
||||||
|
// {
|
||||||
|
// // todo: local cache throws rather than returning null
|
||||||
|
// // we need to throw too, and try/catch local cache here
|
||||||
|
// var lres = await _innerCache.BotPermissionsIn(guildId, channelId);
|
||||||
|
// var hres = await QueryCache<PermissionSet?>($"/guilds/{guildId}/channels/{channelId}/permissions/@me", guildId);
|
||||||
|
// if (lres == null && hres == null) return null;
|
||||||
|
// if (lres == null)
|
||||||
|
// {
|
||||||
|
// _logger.Warning($"TryGetChannel({guildId}, {channelId}) was only successful on remote cache");
|
||||||
|
// OnDebug(null, (true, "botchannelperms"));
|
||||||
|
// return hres;
|
||||||
|
// }
|
||||||
|
// if (hres == null)
|
||||||
|
// {
|
||||||
|
// _logger.Warning($"TryGetChannel({guildId}, {channelId}) was only successful on local cache");
|
||||||
|
// OnDebug(null, (false, "botchannelperms"));
|
||||||
|
// return lres;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // this one is easy to check, so let's check it
|
||||||
|
// if ((int)lres != (int)hres)
|
||||||
|
// {
|
||||||
|
// // trust local
|
||||||
|
// _logger.Warning($"got different permissions for {channelId} (local {(int)lres}, remote {(int)hres})");
|
||||||
|
// OnDebug(null, (null, "botchannelperms"));
|
||||||
|
// return lres;
|
||||||
|
// }
|
||||||
|
// return hres;
|
||||||
|
// }
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId)
|
||||||
|
{
|
||||||
|
var hres = await QueryCache<IEnumerable<Channel>>($"/guilds/{guildId}/channels", guildId);
|
||||||
|
if (_innerCache == null) return hres;
|
||||||
|
var lres = await _innerCache.GetGuildChannels(guildId);
|
||||||
|
if (lres == null && hres == null) return null;
|
||||||
|
if (lres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"GetGuildChannels({guildId}) was only successful on remote cache");
|
||||||
|
OnDebug(null, (true, "guild_channels"));
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
if (hres == null)
|
||||||
|
{
|
||||||
|
_logger.Warning($"GetGuildChannels({guildId}) was only successful on local cache");
|
||||||
|
OnDebug(null, (false, "guild_channels"));
|
||||||
|
return lres;
|
||||||
|
}
|
||||||
|
return hres;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,11 +18,9 @@ public interface IDiscordCache
|
||||||
|
|
||||||
internal ulong GetOwnUser();
|
internal ulong GetOwnUser();
|
||||||
public Task<Guild?> TryGetGuild(ulong guildId);
|
public Task<Guild?> TryGetGuild(ulong guildId);
|
||||||
public Task<Channel?> TryGetChannel(ulong channelId);
|
public Task<Channel?> TryGetChannel(ulong guildId, ulong channelId);
|
||||||
public Task<User?> TryGetUser(ulong userId);
|
public Task<User?> TryGetUser(ulong userId);
|
||||||
public Task<GuildMemberPartial?> TryGetSelfMember(ulong guildId);
|
public Task<GuildMemberPartial?> TryGetSelfMember(ulong guildId);
|
||||||
public Task<Role?> TryGetRole(ulong roleId);
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Guild> GetAllGuilds();
|
|
||||||
public Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId);
|
public Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@ public class MemoryDiscordCache: IDiscordCache
|
||||||
return Task.FromResult(cg?.Guild);
|
return Task.FromResult(cg?.Guild);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Channel?> TryGetChannel(ulong channelId)
|
public Task<Channel?> TryGetChannel(ulong _, ulong channelId)
|
||||||
{
|
{
|
||||||
_channels.TryGetValue(channelId, out var channel);
|
_channels.TryGetValue(channelId, out var channel);
|
||||||
return Task.FromResult(channel);
|
return Task.FromResult(channel);
|
||||||
|
|
@ -155,19 +155,6 @@ public class MemoryDiscordCache: IDiscordCache
|
||||||
return Task.FromResult(guildMember);
|
return Task.FromResult(guildMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Role?> TryGetRole(ulong roleId)
|
|
||||||
{
|
|
||||||
_roles.TryGetValue(roleId, out var role);
|
|
||||||
return Task.FromResult(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Guild> GetAllGuilds()
|
|
||||||
{
|
|
||||||
return _guilds.Values
|
|
||||||
.Select(g => g.Guild)
|
|
||||||
.ToAsyncEnumerable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId)
|
public Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId)
|
||||||
{
|
{
|
||||||
if (!_guilds.TryGetValue(guildId, out var guild))
|
if (!_guilds.TryGetValue(guildId, out var guild))
|
||||||
|
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
using Google.Protobuf;
|
|
||||||
|
|
||||||
using StackExchange.Redis;
|
|
||||||
using StackExchange.Redis.KeyspaceIsolation;
|
|
||||||
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
using Myriad.Types;
|
|
||||||
|
|
||||||
namespace Myriad.Cache;
|
|
||||||
|
|
||||||
#pragma warning disable 4014
|
|
||||||
public class RedisDiscordCache: IDiscordCache
|
|
||||||
{
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
private readonly ulong _ownUserId;
|
|
||||||
public RedisDiscordCache(ILogger logger, ulong ownUserId)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_ownUserId = ownUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ConnectionMultiplexer _redis { get; set; }
|
|
||||||
|
|
||||||
public async Task InitAsync(string addr)
|
|
||||||
{
|
|
||||||
_redis = await ConnectionMultiplexer.ConnectAsync(addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
private IDatabase db => _redis.GetDatabase().WithKeyPrefix("discord:");
|
|
||||||
|
|
||||||
public async ValueTask SaveGuild(Guild guild)
|
|
||||||
{
|
|
||||||
_logger.Verbose("Saving guild {GuildId} to redis", guild.Id);
|
|
||||||
|
|
||||||
var g = new CachedGuild();
|
|
||||||
g.Id = guild.Id;
|
|
||||||
g.Name = guild.Name;
|
|
||||||
g.OwnerId = guild.OwnerId;
|
|
||||||
g.PremiumTier = (int)guild.PremiumTier;
|
|
||||||
|
|
||||||
var tr = db.CreateTransaction();
|
|
||||||
|
|
||||||
tr.HashSetAsync("guilds", guild.Id.HashWrapper(g));
|
|
||||||
|
|
||||||
foreach (var role in guild.Roles)
|
|
||||||
{
|
|
||||||
// Don't call SaveRole because that updates guild state
|
|
||||||
// and we just got a brand new one :)
|
|
||||||
// actually with redis it doesn't update guild state, but we're still doing it here because transaction
|
|
||||||
tr.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole()
|
|
||||||
{
|
|
||||||
Id = role.Id,
|
|
||||||
Name = role.Name,
|
|
||||||
Position = role.Position,
|
|
||||||
Permissions = (ulong)role.Permissions,
|
|
||||||
Mentionable = role.Mentionable,
|
|
||||||
}));
|
|
||||||
|
|
||||||
tr.HashSetAsync($"guild_roles:{guild.Id}", role.Id, true, When.NotExists);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tr.ExecuteAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask SaveChannel(Channel channel)
|
|
||||||
{
|
|
||||||
_logger.Verbose("Saving channel {ChannelId} to redis", channel.Id);
|
|
||||||
|
|
||||||
await db.HashSetAsync("channels", channel.Id.HashWrapper(channel.ToProtobuf()));
|
|
||||||
|
|
||||||
if (channel.GuildId != null)
|
|
||||||
await db.HashSetAsync($"guild_channels:{channel.GuildId.Value}", channel.Id, true, When.NotExists);
|
|
||||||
|
|
||||||
// todo: use a transaction for this?
|
|
||||||
if (channel.Recipients != null)
|
|
||||||
foreach (var recipient in channel.Recipients)
|
|
||||||
await SaveUser(recipient);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask SaveUser(User user)
|
|
||||||
{
|
|
||||||
_logger.Verbose("Saving user {UserId} to redis", user.Id);
|
|
||||||
|
|
||||||
var u = new CachedUser()
|
|
||||||
{
|
|
||||||
Id = user.Id,
|
|
||||||
Username = user.Username,
|
|
||||||
Discriminator = user.Discriminator,
|
|
||||||
Bot = user.Bot,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user.Avatar != null)
|
|
||||||
u.Avatar = user.Avatar;
|
|
||||||
|
|
||||||
await db.HashSetAsync("users", user.Id.HashWrapper(u));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask SaveSelfMember(ulong guildId, GuildMemberPartial member)
|
|
||||||
{
|
|
||||||
_logger.Verbose("Saving self member for guild {GuildId} to redis", guildId);
|
|
||||||
|
|
||||||
var gm = new CachedGuildMember();
|
|
||||||
foreach (var role in member.Roles)
|
|
||||||
gm.Roles.Add(role);
|
|
||||||
|
|
||||||
await db.HashSetAsync("members", guildId.HashWrapper(gm));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask SaveRole(ulong guildId, Myriad.Types.Role role)
|
|
||||||
{
|
|
||||||
_logger.Verbose("Saving role {RoleId} in {GuildId} to redis", role.Id, guildId);
|
|
||||||
|
|
||||||
await db.HashSetAsync("roles", role.Id.HashWrapper(new CachedRole()
|
|
||||||
{
|
|
||||||
Id = role.Id,
|
|
||||||
Mentionable = role.Mentionable,
|
|
||||||
Name = role.Name,
|
|
||||||
Permissions = (ulong)role.Permissions,
|
|
||||||
Position = role.Position,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.HashSetAsync($"guild_roles:{guildId}", role.Id, true, When.NotExists);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask SaveDmChannelStub(ulong channelId)
|
|
||||||
{
|
|
||||||
// Use existing channel object if present, otherwise add a stub
|
|
||||||
// We may get a message create before channel create and we want to have it saved
|
|
||||||
|
|
||||||
if (await TryGetChannel(channelId) == null)
|
|
||||||
await db.HashSetAsync("channels", channelId.HashWrapper(new CachedChannel()
|
|
||||||
{
|
|
||||||
Id = channelId,
|
|
||||||
Type = (int)Channel.ChannelType.Dm,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask RemoveGuild(ulong guildId)
|
|
||||||
=> await db.HashDeleteAsync("guilds", guildId);
|
|
||||||
|
|
||||||
public async ValueTask RemoveChannel(ulong channelId)
|
|
||||||
{
|
|
||||||
var oldChannel = await TryGetChannel(channelId);
|
|
||||||
|
|
||||||
if (oldChannel == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
await db.HashDeleteAsync("channels", channelId);
|
|
||||||
|
|
||||||
if (oldChannel.GuildId != null)
|
|
||||||
await db.HashDeleteAsync($"guild_channels:{oldChannel.GuildId.Value}", oldChannel.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask RemoveUser(ulong userId)
|
|
||||||
=> await db.HashDeleteAsync("users", userId);
|
|
||||||
|
|
||||||
public ulong GetOwnUser() => _ownUserId;
|
|
||||||
|
|
||||||
public async ValueTask RemoveRole(ulong guildId, ulong roleId)
|
|
||||||
{
|
|
||||||
await db.HashDeleteAsync("roles", roleId);
|
|
||||||
await db.HashDeleteAsync($"guild_roles:{guildId}", roleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Guild?> TryGetGuild(ulong guildId)
|
|
||||||
{
|
|
||||||
var redisGuild = await db.HashGetAsync("guilds", guildId);
|
|
||||||
if (redisGuild.IsNullOrEmpty)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var guild = ((byte[])redisGuild).Unmarshal<CachedGuild>();
|
|
||||||
|
|
||||||
var redisRoles = await db.HashGetAllAsync($"guild_roles:{guildId}");
|
|
||||||
|
|
||||||
// todo: put this in a transaction or something
|
|
||||||
var roles = await Task.WhenAll(redisRoles.Select(r => TryGetRole((ulong)r.Name)));
|
|
||||||
|
|
||||||
#pragma warning disable 8619
|
|
||||||
return guild.FromProtobuf() with { Roles = roles };
|
|
||||||
#pragma warning restore 8619
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Channel?> TryGetChannel(ulong channelId)
|
|
||||||
{
|
|
||||||
var redisChannel = await db.HashGetAsync("channels", channelId);
|
|
||||||
if (redisChannel.IsNullOrEmpty)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return ((byte[])redisChannel).Unmarshal<CachedChannel>().FromProtobuf();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<User?> TryGetUser(ulong userId)
|
|
||||||
{
|
|
||||||
var redisUser = await db.HashGetAsync("users", userId);
|
|
||||||
if (redisUser.IsNullOrEmpty)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return ((byte[])redisUser).Unmarshal<CachedUser>().FromProtobuf();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<GuildMemberPartial?> TryGetSelfMember(ulong guildId)
|
|
||||||
{
|
|
||||||
var redisMember = await db.HashGetAsync("members", guildId);
|
|
||||||
if (redisMember.IsNullOrEmpty)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new GuildMemberPartial()
|
|
||||||
{
|
|
||||||
Roles = ((byte[])redisMember).Unmarshal<CachedGuildMember>().Roles.ToArray()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Myriad.Types.Role?> TryGetRole(ulong roleId)
|
|
||||||
{
|
|
||||||
var redisRole = await db.HashGetAsync("roles", roleId);
|
|
||||||
if (redisRole.IsNullOrEmpty)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var role = ((byte[])redisRole).Unmarshal<CachedRole>();
|
|
||||||
|
|
||||||
return new Myriad.Types.Role()
|
|
||||||
{
|
|
||||||
Id = role.Id,
|
|
||||||
Name = role.Name,
|
|
||||||
Position = role.Position,
|
|
||||||
Permissions = (PermissionSet)role.Permissions,
|
|
||||||
Mentionable = role.Mentionable,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<Guild> GetAllGuilds()
|
|
||||||
{
|
|
||||||
// return _guilds.Values
|
|
||||||
// .Select(g => g.Guild)
|
|
||||||
// .ToAsyncEnumerable();
|
|
||||||
return new Guild[] { }.ToAsyncEnumerable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId)
|
|
||||||
{
|
|
||||||
var redisChannels = await db.HashGetAllAsync($"guild_channels:{guildId}");
|
|
||||||
if (redisChannels.Length == 0)
|
|
||||||
throw new ArgumentException("Guild not found", nameof(guildId));
|
|
||||||
|
|
||||||
#pragma warning disable 8619
|
|
||||||
return await Task.WhenAll(redisChannels.Select(c => TryGetChannel((ulong)c.Name)));
|
|
||||||
#pragma warning restore 8619
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class CacheProtoExt
|
|
||||||
{
|
|
||||||
public static Guild FromProtobuf(this CachedGuild guild)
|
|
||||||
=> new Guild()
|
|
||||||
{
|
|
||||||
Id = guild.Id,
|
|
||||||
Name = guild.Name,
|
|
||||||
OwnerId = guild.OwnerId,
|
|
||||||
PremiumTier = (PremiumTier)guild.PremiumTier,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static CachedChannel ToProtobuf(this Channel channel)
|
|
||||||
{
|
|
||||||
var c = new CachedChannel();
|
|
||||||
c.Id = channel.Id;
|
|
||||||
c.Type = (int)channel.Type;
|
|
||||||
if (channel.Position != null)
|
|
||||||
c.Position = channel.Position.Value;
|
|
||||||
c.Name = channel.Name;
|
|
||||||
if (channel.PermissionOverwrites != null)
|
|
||||||
foreach (var overwrite in channel.PermissionOverwrites)
|
|
||||||
c.PermissionOverwrites.Add(new Overwrite()
|
|
||||||
{
|
|
||||||
Id = overwrite.Id,
|
|
||||||
Type = (int)overwrite.Type,
|
|
||||||
Allow = (ulong)overwrite.Allow,
|
|
||||||
Deny = (ulong)overwrite.Deny,
|
|
||||||
});
|
|
||||||
if (channel.GuildId != null)
|
|
||||||
c.GuildId = channel.GuildId.Value;
|
|
||||||
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Channel FromProtobuf(this CachedChannel channel)
|
|
||||||
=> new Channel()
|
|
||||||
{
|
|
||||||
Id = channel.Id,
|
|
||||||
Type = (Channel.ChannelType)channel.Type,
|
|
||||||
Position = channel.Position,
|
|
||||||
Name = channel.Name,
|
|
||||||
PermissionOverwrites = channel.PermissionOverwrites
|
|
||||||
.Select(x => new Channel.Overwrite()
|
|
||||||
{
|
|
||||||
Id = x.Id,
|
|
||||||
Type = (Channel.OverwriteType)x.Type,
|
|
||||||
Allow = (PermissionSet)x.Allow,
|
|
||||||
Deny = (PermissionSet)x.Deny,
|
|
||||||
}).ToArray(),
|
|
||||||
GuildId = channel.HasGuildId ? channel.GuildId : null,
|
|
||||||
ParentId = channel.HasParentId ? channel.ParentId : null,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static User FromProtobuf(this CachedUser user)
|
|
||||||
=> new User()
|
|
||||||
{
|
|
||||||
Id = user.Id,
|
|
||||||
Username = user.Username,
|
|
||||||
Discriminator = user.Discriminator,
|
|
||||||
Avatar = user.HasAvatar ? user.Avatar : null,
|
|
||||||
Bot = user.Bot,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class RedisExt
|
|
||||||
{
|
|
||||||
// convenience method
|
|
||||||
public static HashEntry[] HashWrapper<T>(this ulong key, T value) where T : IMessage
|
|
||||||
=> new[] { new HashEntry(key, value.ToByteArray()) };
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class ProtobufExt
|
|
||||||
{
|
|
||||||
private static Dictionary<string, MessageParser> _parser = new();
|
|
||||||
|
|
||||||
public static byte[] Marshal(this IMessage message) => message.ToByteArray();
|
|
||||||
|
|
||||||
public static T Unmarshal<T>(this byte[] message) where T : IMessage<T>, new()
|
|
||||||
{
|
|
||||||
var type = typeof(T).ToString();
|
|
||||||
if (_parser.ContainsKey(type))
|
|
||||||
return (T)_parser[type].ParseFrom(message);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_parser.Add(type, new MessageParser<T>(() => new T()));
|
|
||||||
return Unmarshal<T>(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,27 +13,13 @@ public static class CacheExtensions
|
||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Channel> GetChannel(this IDiscordCache cache, ulong channelId)
|
public static async Task<Channel> GetChannel(this IDiscordCache cache, ulong guildId, ulong channelId)
|
||||||
{
|
{
|
||||||
if (!(await cache.TryGetChannel(channelId) is Channel channel))
|
if (!(await cache.TryGetChannel(guildId, channelId) is Channel channel))
|
||||||
throw new KeyNotFoundException($"Channel {channelId} not found in cache");
|
throw new KeyNotFoundException($"Channel {channelId} not found in cache");
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<User> GetUser(this IDiscordCache cache, ulong userId)
|
|
||||||
{
|
|
||||||
if (!(await cache.TryGetUser(userId) is User user))
|
|
||||||
throw new KeyNotFoundException($"User {userId} not found in cache");
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<Role> GetRole(this IDiscordCache cache, ulong roleId)
|
|
||||||
{
|
|
||||||
if (!(await cache.TryGetRole(roleId) is Role role))
|
|
||||||
throw new KeyNotFoundException($"Role {roleId} not found in cache");
|
|
||||||
return role;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async ValueTask<User?> GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest,
|
public static async ValueTask<User?> GetOrFetchUser(this IDiscordCache cache, DiscordApiClient rest,
|
||||||
ulong userId)
|
ulong userId)
|
||||||
{
|
{
|
||||||
|
|
@ -47,9 +33,9 @@ public static class CacheExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async ValueTask<Channel?> GetOrFetchChannel(this IDiscordCache cache, DiscordApiClient rest,
|
public static async ValueTask<Channel?> GetOrFetchChannel(this IDiscordCache cache, DiscordApiClient rest,
|
||||||
ulong channelId)
|
ulong guildId, ulong channelId)
|
||||||
{
|
{
|
||||||
if (await cache.TryGetChannel(channelId) is { } cacheChannel)
|
if (await cache.TryGetChannel(guildId, channelId) is { } cacheChannel)
|
||||||
return cacheChannel;
|
return cacheChannel;
|
||||||
|
|
||||||
var restChannel = await rest.GetChannel(channelId);
|
var restChannel = await rest.GetChannel(channelId);
|
||||||
|
|
@ -58,13 +44,13 @@ public static class CacheExtensions
|
||||||
return restChannel;
|
return restChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Channel> GetRootChannel(this IDiscordCache cache, ulong channelOrThread)
|
public static async Task<Channel> GetRootChannel(this IDiscordCache cache, ulong guildId, ulong channelOrThread)
|
||||||
{
|
{
|
||||||
var channel = await cache.GetChannel(channelOrThread);
|
var channel = await cache.GetChannel(guildId, channelOrThread);
|
||||||
if (!channel.IsThread())
|
if (!channel.IsThread())
|
||||||
return channel;
|
return channel;
|
||||||
|
|
||||||
var parent = await cache.GetChannel(channel.ParentId!.Value);
|
var parent = await cache.GetChannel(guildId, channel.ParentId!.Value);
|
||||||
return parent;
|
return parent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32,23 +32,23 @@ public static class PermissionExtensions
|
||||||
PermissionSet.EmbedLinks;
|
PermissionSet.EmbedLinks;
|
||||||
|
|
||||||
public static Task<PermissionSet> PermissionsForMCE(this IDiscordCache cache, MessageCreateEvent message) =>
|
public static Task<PermissionSet> PermissionsForMCE(this IDiscordCache cache, MessageCreateEvent message) =>
|
||||||
PermissionsFor2(cache, message.ChannelId, message.Author.Id, message.Member, message.WebhookId != null);
|
PermissionsFor2(cache, message.GuildId ?? 0, message.ChannelId, message.Author.Id, message.Member, message.WebhookId != null);
|
||||||
|
|
||||||
public static Task<PermissionSet>
|
public static Task<PermissionSet>
|
||||||
PermissionsForMemberInChannel(this IDiscordCache cache, ulong channelId, GuildMember member) =>
|
PermissionsForMemberInChannel(this IDiscordCache cache, ulong guildId, ulong channelId, GuildMember member) =>
|
||||||
PermissionsFor2(cache, channelId, member.User.Id, member);
|
PermissionsFor2(cache, guildId, channelId, member.User.Id, member);
|
||||||
|
|
||||||
public static async Task<PermissionSet> PermissionsFor2(this IDiscordCache cache, ulong channelId, ulong userId,
|
public static async Task<PermissionSet> PermissionsFor2(this IDiscordCache cache, ulong guildId, ulong channelId, ulong userId,
|
||||||
GuildMemberPartial? member, bool isThread = false)
|
GuildMemberPartial? member, bool isThread = false)
|
||||||
{
|
{
|
||||||
if (!(await cache.TryGetChannel(channelId) is Channel channel))
|
if (!(await cache.TryGetChannel(guildId, channelId) is Channel channel))
|
||||||
// todo: handle channel not found better
|
// todo: handle channel not found better
|
||||||
return PermissionSet.Dm;
|
return PermissionSet.Dm;
|
||||||
|
|
||||||
if (channel.GuildId == null)
|
if (channel.GuildId == null)
|
||||||
return PermissionSet.Dm;
|
return PermissionSet.Dm;
|
||||||
|
|
||||||
var rootChannel = await cache.GetRootChannel(channelId);
|
var rootChannel = await cache.GetRootChannel(guildId, channelId);
|
||||||
|
|
||||||
var guild = await cache.GetGuild(channel.GuildId.Value);
|
var guild = await cache.GetGuild(channel.GuildId.Value);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,8 @@ public class ShardConnection: IAsyncDisposable
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.Error(e, "Shard {ShardId}: Error reading from WebSocket");
|
// these are never useful
|
||||||
|
// _logger.Error(e, "Shard {ShardId}: Error reading from WebSocket");
|
||||||
// force close so we can "reset"
|
// force close so we can "reset"
|
||||||
await CloseInner(WebSocketCloseStatus.NormalClosure, null);
|
await CloseInner(WebSocketCloseStatus.NormalClosure, null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,14 @@ public record ExecuteWebhookRequest
|
||||||
public AllowedMentions? AllowedMentions { get; init; }
|
public AllowedMentions? AllowedMentions { get; init; }
|
||||||
public bool? Tts { get; init; }
|
public bool? Tts { get; init; }
|
||||||
public Message.MessageFlags? Flags { get; set; }
|
public Message.MessageFlags? Flags { get; set; }
|
||||||
|
public WebhookPoll? Poll { get; set; }
|
||||||
|
|
||||||
|
public record WebhookPoll
|
||||||
|
{
|
||||||
|
public Message.PollMedia Question { get; init; }
|
||||||
|
public Message.PollAnswer[] Answers { get; init; }
|
||||||
|
public int? Duration { get; init; }
|
||||||
|
public bool AllowMultiselect { get; init; }
|
||||||
|
public int LayoutType { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +70,8 @@ public record Message
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||||
public Optional<Message?> ReferencedMessage { get; init; }
|
public Optional<Message?> ReferencedMessage { get; init; }
|
||||||
|
|
||||||
|
public MessagePoll? Poll { 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);
|
||||||
|
|
@ -96,4 +98,17 @@ public record Message
|
||||||
public bool Me { get; init; }
|
public bool Me { get; init; }
|
||||||
public Emoji Emoji { get; init; }
|
public Emoji Emoji { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record PollMedia(string? Text, Emoji? Emoji);
|
||||||
|
|
||||||
|
public record PollAnswer(PollMedia PollMedia);
|
||||||
|
|
||||||
|
public record MessagePoll
|
||||||
|
{
|
||||||
|
public PollMedia Question { get; init; }
|
||||||
|
public PollAnswer[] Answers { get; init; }
|
||||||
|
public string? Expiry { get; init; }
|
||||||
|
public bool AllowMultiselect { get; init; }
|
||||||
|
public int LayoutType { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -147,6 +147,8 @@ public class GroupMemberControllerV2: PKControllerBase
|
||||||
public async Task<IActionResult> GetMemberGroups(string memberRef)
|
public async Task<IActionResult> GetMemberGroups(string memberRef)
|
||||||
{
|
{
|
||||||
var member = await ResolveMember(memberRef);
|
var member = await ResolveMember(memberRef);
|
||||||
|
if (member == null)
|
||||||
|
throw Errors.MemberNotFoundWithRef(memberRef);
|
||||||
var ctx = ContextFor(member);
|
var ctx = ContextFor(member);
|
||||||
|
|
||||||
var system = await _repo.GetSystem(member.System);
|
var system = await _repo.GetSystem(member.System);
|
||||||
|
|
|
||||||
|
|
@ -63,14 +63,14 @@ public class ApplicationCommandProxiedMessage
|
||||||
var messageId = ctx.Event.Data!.TargetId!.Value;
|
var messageId = ctx.Event.Data!.TargetId!.Value;
|
||||||
|
|
||||||
// check for command messages
|
// check for command messages
|
||||||
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
var cmessage = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
||||||
if (authorId != null)
|
if (cmessage != null)
|
||||||
{
|
{
|
||||||
if (authorId != ctx.User.Id)
|
if (cmessage.AuthorId != ctx.User.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.");
|
||||||
|
|
||||||
var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == channelId;
|
var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == cmessage.ChannelId;
|
||||||
await DeleteMessageInner(ctx, channelId!.Value, messageId, isDM);
|
await DeleteMessageInner(ctx, cmessage.GuildId, cmessage.ChannelId, messageId, isDM);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -78,10 +78,10 @@ public class ApplicationCommandProxiedMessage
|
||||||
var message = await ctx.Repository.GetFullMessage(messageId);
|
var message = await ctx.Repository.GetFullMessage(messageId);
|
||||||
if (message != null)
|
if (message != null)
|
||||||
{
|
{
|
||||||
if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.User.Id)
|
if (message.Message.Sender != ctx.User.Id && (ctx.System != null && message.System?.Id != ctx.System.Id))
|
||||||
throw new PKError("You can only delete your own messages.");
|
throw new PKError("You can only delete your own messages.");
|
||||||
|
|
||||||
await DeleteMessageInner(ctx, message.Message.Channel, message.Message.Mid, false);
|
await DeleteMessageInner(ctx, message.Message.Guild ?? 0, message.Message.Channel, message.Message.Mid, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,9 +89,9 @@ public class ApplicationCommandProxiedMessage
|
||||||
throw Errors.MessageNotFound(messageId);
|
throw Errors.MessageNotFound(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task DeleteMessageInner(InteractionContext ctx, ulong channelId, ulong messageId, bool isDM = false)
|
internal async Task DeleteMessageInner(InteractionContext ctx, ulong guildId, ulong channelId, ulong messageId, bool isDM = false)
|
||||||
{
|
{
|
||||||
if (!((await _cache.BotPermissionsIn(channelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
|
if (!((await _cache.BotPermissionsIn(guildId, channelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
|
||||||
throw new PKError("PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the message."
|
throw new PKError("PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the message."
|
||||||
+ " Please contact a server administrator to remedy this.");
|
+ " Please contact a server administrator to remedy this.");
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ public class ApplicationCommandProxiedMessage
|
||||||
// (if not, PK shouldn't send messages on their behalf)
|
// (if not, PK shouldn't send messages on their behalf)
|
||||||
var member = await _rest.GetGuildMember(ctx.GuildId, ctx.User.Id);
|
var member = await _rest.GetGuildMember(ctx.GuildId, ctx.User.Id);
|
||||||
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
|
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
|
||||||
if (member == null || !(await _cache.PermissionsForMemberInChannel(ctx.ChannelId, member)).HasFlag(requiredPerms))
|
if (member == null || !(await _cache.PermissionsForMemberInChannel(ctx.GuildId, ctx.ChannelId, member)).HasFlag(requiredPerms))
|
||||||
{
|
{
|
||||||
throw new PKError("You do not have permission to send messages in this channel.");
|
throw new PKError("You do not have permission to send messages in this channel.");
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,7 @@ public class Bot
|
||||||
{
|
{
|
||||||
// we HandleGatewayEvent **before** getting the own user, because the own user is set in HandleGatewayEvent for ReadyEvent
|
// we HandleGatewayEvent **before** getting the own user, because the own user is set in HandleGatewayEvent for ReadyEvent
|
||||||
await _cache.HandleGatewayEvent(evt);
|
await _cache.HandleGatewayEvent(evt);
|
||||||
|
|
||||||
await _cache.TryUpdateSelfMember(_config.ClientId, evt);
|
await _cache.TryUpdateSelfMember(_config.ClientId, evt);
|
||||||
|
|
||||||
await OnEventReceivedInner(shardId, evt);
|
await OnEventReceivedInner(shardId, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +173,16 @@ public class Bot
|
||||||
}
|
}
|
||||||
|
|
||||||
using var _ = LogContext.PushProperty("EventId", Guid.NewGuid());
|
using var _ = LogContext.PushProperty("EventId", Guid.NewGuid());
|
||||||
using var __ = LogContext.Push(await serviceScope.Resolve<SerilogGatewayEnricherFactory>().GetEnricher(shardId, evt));
|
// this fails when cache lookup fails, so put it in a try-catch
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var __ = LogContext.Push(await serviceScope.Resolve<SerilogGatewayEnricherFactory>().GetEnricher(shardId, evt));
|
||||||
|
}
|
||||||
|
catch (Exception exc)
|
||||||
|
{
|
||||||
|
|
||||||
|
await HandleError(handler, evt, serviceScope, exc);
|
||||||
|
}
|
||||||
_logger.Verbose("Received gateway event: {@Event}", evt);
|
_logger.Verbose("Received gateway event: {@Event}", evt);
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|
@ -243,7 +250,7 @@ public class Bot
|
||||||
if (!exc.ShowToUser()) return;
|
if (!exc.ShowToUser()) return;
|
||||||
|
|
||||||
// Once we've sent it to Sentry, report it to the user (if we have permission to)
|
// Once we've sent it to Sentry, report it to the user (if we have permission to)
|
||||||
var reportChannel = handler.ErrorChannelFor(evt, _config.ClientId);
|
var (guildId, reportChannel) = handler.ErrorChannelFor(evt, _config.ClientId);
|
||||||
if (reportChannel == null)
|
if (reportChannel == null)
|
||||||
{
|
{
|
||||||
if (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand)
|
if (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand)
|
||||||
|
|
@ -251,7 +258,7 @@ public class Bot
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var botPerms = await _cache.BotPermissionsIn(reportChannel.Value);
|
var botPerms = await _cache.BotPermissionsIn(guildId ?? 0, reportChannel.Value);
|
||||||
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
|
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
|
||||||
await _errorMessageService.SendErrorMessage(reportChannel.Value, sentryEvent.EventId.ToString());
|
await _errorMessageService.SendErrorMessage(reportChannel.Value, sentryEvent.EventId.ToString());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ public class BotConfig
|
||||||
|
|
||||||
public string? GatewayQueueUrl { get; set; }
|
public string? GatewayQueueUrl { get; set; }
|
||||||
public bool UseRedisRatelimiter { get; set; } = false;
|
public bool UseRedisRatelimiter { get; set; } = false;
|
||||||
public bool UseRedisCache { get; set; } = false;
|
|
||||||
|
public string? HttpCacheUrl { get; set; }
|
||||||
|
public bool HttpUseInnerCache { get; set; } = false;
|
||||||
|
|
||||||
public string? RedisGatewayUrl { get; set; }
|
public string? RedisGatewayUrl { get; set; }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,4 +136,11 @@ public static class BotMetrics
|
||||||
DurationUnit = TimeUnit.Seconds,
|
DurationUnit = TimeUnit.Seconds,
|
||||||
Context = "Bot"
|
Context = "Bot"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static MeterOptions CacheDebug => new()
|
||||||
|
{
|
||||||
|
Name = "Bad responses to cache lookups",
|
||||||
|
Context = "Bot",
|
||||||
|
MeasurementUnit = Unit.Calls
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +82,7 @@ public partial class CommandTree
|
||||||
public static Command SwitchMove = new Command("switch move", "switch move <date/time>", "Moves the latest switch in time");
|
public static Command SwitchMove = new Command("switch move", "switch move <date/time>", "Moves the latest switch in time");
|
||||||
public static Command SwitchEdit = new Command("switch edit", "switch edit <member> [member 2] [member 3...]", "Edits the members in the latest switch");
|
public static Command SwitchEdit = new Command("switch edit", "switch edit <member> [member 2] [member 3...]", "Edits the members in the latest switch");
|
||||||
public static Command SwitchEditOut = new Command("switch edit out", "switch edit out", "Turns the latest switch into a switch-out");
|
public static Command SwitchEditOut = new Command("switch edit out", "switch edit out", "Turns the latest switch into a switch-out");
|
||||||
|
public static Command SwitchCopy = new Command("switch copy", "switch copy <member> [member 2] [member 3...]", "Makes a new switch with the listed members added");
|
||||||
public static Command SwitchDelete = new Command("switch delete", "switch delete", "Deletes the latest switch");
|
public static Command SwitchDelete = new Command("switch delete", "switch delete", "Deletes the latest switch");
|
||||||
public static Command SwitchDeleteAll = new Command("switch delete", "switch delete all", "Deletes all logged switches");
|
public static Command SwitchDeleteAll = new Command("switch delete", "switch delete all", "Deletes all logged switches");
|
||||||
public static Command Link = new Command("link", "link <account>", "Links your system to another account");
|
public static Command Link = new Command("link", "link <account>", "Links your system to another account");
|
||||||
|
|
@ -92,6 +93,7 @@ public partial class CommandTree
|
||||||
public static Command Export = new Command("export", "export", "Exports system information to a data file");
|
public static Command Export = new Command("export", "export", "Exports system information to a data file");
|
||||||
public static Command Help = new Command("help", "help", "Shows help information about PluralKit");
|
public static Command Help = new Command("help", "help", "Shows help information about PluralKit");
|
||||||
public static Command Explain = new Command("explain", "explain", "Explains the basics of systems and proxying");
|
public static Command Explain = new Command("explain", "explain", "Explains the basics of systems and proxying");
|
||||||
|
public static Command Dashboard = new Command("dashboard", "dashboard", "Get a link to the PluralKit dashboard");
|
||||||
public static Command Message = new Command("message", "message <id|link> [delete|author]", "Looks up a proxied message");
|
public static Command Message = new Command("message", "message <id|link> [delete|author]", "Looks up a proxied message");
|
||||||
public static Command MessageEdit = new Command("edit", "edit [link] <text>", "Edit a previously proxied message");
|
public static Command MessageEdit = new Command("edit", "edit [link] <text>", "Edit a previously proxied message");
|
||||||
public static Command MessageReproxy = new Command("reproxy", "reproxy [link] <member>", "Reproxy a previously proxied message using a different member");
|
public static Command MessageReproxy = new Command("reproxy", "reproxy [link] <member>", "Reproxy a previously proxied message using a different member");
|
||||||
|
|
@ -137,7 +139,7 @@ public partial class CommandTree
|
||||||
|
|
||||||
public static Command[] SwitchCommands =
|
public static Command[] SwitchCommands =
|
||||||
{
|
{
|
||||||
Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll
|
Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll, SwitchCopy
|
||||||
};
|
};
|
||||||
|
|
||||||
public static Command[] ConfigCommands =
|
public static Command[] ConfigCommands =
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public partial class CommandTree
|
||||||
return CommandHelpRoot(ctx);
|
return CommandHelpRoot(ctx);
|
||||||
if (ctx.Match("ap", "autoproxy", "auto"))
|
if (ctx.Match("ap", "autoproxy", "auto"))
|
||||||
return HandleAutoproxyCommand(ctx);
|
return HandleAutoproxyCommand(ctx);
|
||||||
if (ctx.Match("config", "cfg"))
|
if (ctx.Match("config", "cfg", "configure"))
|
||||||
return HandleConfigCommand(ctx);
|
return HandleConfigCommand(ctx);
|
||||||
if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls"))
|
if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls"))
|
||||||
return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
|
return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
|
||||||
|
|
@ -105,6 +105,8 @@ public partial class CommandTree
|
||||||
return ctx.Execute<Random>(GroupRandom, r => r.Group(ctx, ctx.System));
|
return ctx.Execute<Random>(GroupRandom, r => r.Group(ctx, ctx.System));
|
||||||
else
|
else
|
||||||
return ctx.Execute<Random>(MemberRandom, m => m.Member(ctx, ctx.System));
|
return ctx.Execute<Random>(MemberRandom, m => m.Member(ctx, ctx.System));
|
||||||
|
if (ctx.Match("dashboard", "dash"))
|
||||||
|
return ctx.Execute<Help>(Dashboard, m => m.Dashboard(ctx));
|
||||||
|
|
||||||
// remove compiler warning
|
// remove compiler warning
|
||||||
return ctx.Reply(
|
return ctx.Reply(
|
||||||
|
|
@ -430,13 +432,15 @@ public partial class CommandTree
|
||||||
await ctx.Execute<Switch>(SwitchEdit, m => m.SwitchEdit(ctx));
|
await ctx.Execute<Switch>(SwitchEdit, m => m.SwitchEdit(ctx));
|
||||||
else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet"))
|
else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet"))
|
||||||
await ctx.Execute<Switch>(SwitchDelete, m => m.SwitchDelete(ctx));
|
await ctx.Execute<Switch>(SwitchDelete, m => m.SwitchDelete(ctx));
|
||||||
|
else if (ctx.Match("copy", "add", "duplicate", "dupe"))
|
||||||
|
await ctx.Execute<Switch>(SwitchCopy, m => m.SwitchEdit(ctx, true));
|
||||||
else if (ctx.Match("commands", "help"))
|
else if (ctx.Match("commands", "help"))
|
||||||
await PrintCommandList(ctx, "switching", SwitchCommands);
|
await PrintCommandList(ctx, "switching", SwitchCommands);
|
||||||
else if (ctx.HasNext()) // there are following arguments
|
else if (ctx.HasNext()) // there are following arguments
|
||||||
await ctx.Execute<Switch>(Switch, m => m.SwitchDo(ctx));
|
await ctx.Execute<Switch>(Switch, m => m.SwitchDo(ctx));
|
||||||
else
|
else
|
||||||
await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut,
|
await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut,
|
||||||
SwitchDelete, SystemFronter, SystemFrontHistory);
|
SwitchDelete, SwitchCopy, SystemFronter, SystemFrontHistory);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CommandHelpRoot(Context ctx)
|
private async Task CommandHelpRoot(Context ctx)
|
||||||
|
|
@ -544,6 +548,8 @@ public partial class CommandTree
|
||||||
return ctx.Execute<Config>(null, m => m.HidDisplayCaps(ctx));
|
return ctx.Execute<Config>(null, m => m.HidDisplayCaps(ctx));
|
||||||
if (ctx.MatchMultiple(new[] { "pad" }, new[] { "id", "ids" }) || ctx.MatchMultiple(new[] { "id" }, new[] { "pad", "padding" }) || ctx.Match("idpad", "padid", "padids"))
|
if (ctx.MatchMultiple(new[] { "pad" }, new[] { "id", "ids" }) || ctx.MatchMultiple(new[] { "id" }, new[] { "pad", "padding" }) || ctx.Match("idpad", "padid", "padids"))
|
||||||
return ctx.Execute<Config>(null, m => m.HidListPadding(ctx));
|
return ctx.Execute<Config>(null, m => m.HidListPadding(ctx));
|
||||||
|
if (ctx.MatchMultiple(new[] { "member", "group" }, new[] { "limit" }) || ctx.Match("limit"))
|
||||||
|
return ctx.Execute<Config>(null, m => m.LimitUpdate(ctx));
|
||||||
|
|
||||||
// todo: maybe add the list of configuration keys here?
|
// todo: maybe add the list of configuration keys here?
|
||||||
return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `pk;commands config` for the list of possible config settings.");
|
return ctx.Reply($"{Emojis.Error} Could not find a setting with that name. Please see `pk;commands config` for the list of possible config settings.");
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ public class Context
|
||||||
public readonly int ShardId;
|
public readonly int ShardId;
|
||||||
public readonly Cluster Cluster;
|
public readonly Cluster Cluster;
|
||||||
|
|
||||||
public Task<PermissionSet> BotPermissions => Cache.BotPermissionsIn(Channel.Id);
|
public Task<PermissionSet> BotPermissions => Cache.BotPermissionsIn(Guild?.Id ?? 0, Channel.Id);
|
||||||
public Task<PermissionSet> UserPermissions => Cache.PermissionsForMCE((MessageCreateEvent)Message);
|
public Task<PermissionSet> UserPermissions => Cache.PermissionsForMCE((MessageCreateEvent)Message);
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ public class Context
|
||||||
// {
|
// {
|
||||||
// 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)
|
||||||
// but since we can, we just store all sent messages for possible deletion
|
// 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, Guild?.Id ?? 0, msg.ChannelId, Author.Id);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,12 @@ public static class ContextArgumentsExt
|
||||||
public static bool MatchClear(this Context ctx)
|
public static bool MatchClear(this Context ctx)
|
||||||
=> ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear");
|
=> ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear");
|
||||||
|
|
||||||
public static bool MatchRaw(this Context ctx) =>
|
public static ReplyFormat MatchFormat(this Context ctx)
|
||||||
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
|
{
|
||||||
|
if (ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw")) return ReplyFormat.Raw;
|
||||||
|
if (ctx.Match("pt", "plaintext") || ctx.MatchFlag("pt", "plaintext")) return ReplyFormat.Plaintext;
|
||||||
|
return ReplyFormat.Standard;
|
||||||
|
}
|
||||||
|
|
||||||
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
|
public static bool MatchToggle(this Context ctx, bool? defaultValue = null)
|
||||||
{
|
{
|
||||||
|
|
@ -184,4 +188,11 @@ public static class ContextArgumentsExt
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ReplyFormat
|
||||||
|
{
|
||||||
|
Standard,
|
||||||
|
Raw,
|
||||||
|
Plaintext
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +188,8 @@ public static class ContextEntityArgumentsExt
|
||||||
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
|
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var channel = await ctx.Cache.TryGetChannel(id);
|
// todo: match channels in other guilds
|
||||||
|
var channel = await ctx.Cache.TryGetChannel(ctx.Guild!.Id, id);
|
||||||
if (channel == null)
|
if (channel == null)
|
||||||
channel = await ctx.Rest.GetChannelOrNull(id);
|
channel = await ctx.Rest.GetChannelOrNull(id);
|
||||||
if (channel == null)
|
if (channel == null)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
using Humanizer;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using SqlKata;
|
using SqlKata;
|
||||||
|
|
||||||
|
using Myriad.Builders;
|
||||||
|
using Myriad.Extensions;
|
||||||
|
using Myriad.Cache;
|
||||||
using Myriad.Rest;
|
using Myriad.Rest;
|
||||||
using Myriad.Types;
|
using Myriad.Types;
|
||||||
|
|
||||||
|
|
@ -14,11 +18,64 @@ public class Admin
|
||||||
{
|
{
|
||||||
private readonly BotConfig _botConfig;
|
private readonly BotConfig _botConfig;
|
||||||
private readonly DiscordApiClient _rest;
|
private readonly DiscordApiClient _rest;
|
||||||
|
private readonly IDiscordCache _cache;
|
||||||
|
|
||||||
public Admin(BotConfig botConfig, DiscordApiClient rest)
|
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache)
|
||||||
{
|
{
|
||||||
_botConfig = botConfig;
|
_botConfig = botConfig;
|
||||||
_rest = rest;
|
_rest = rest;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Embed> CreateEmbed(Context ctx, PKSystem system)
|
||||||
|
{
|
||||||
|
string UntilLimit(int count, int limit)
|
||||||
|
{
|
||||||
|
var brackets = new List<int> { 10, 25, 50, 100 };
|
||||||
|
if (count == limit)
|
||||||
|
return "(at limit)";
|
||||||
|
|
||||||
|
foreach (var x in brackets)
|
||||||
|
{
|
||||||
|
if (limit - x <= count)
|
||||||
|
return $"(approx. {x} to limit)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<(ulong Id, User? User)[]> GetUsers(IEnumerable<ulong> ids)
|
||||||
|
{
|
||||||
|
async Task<(ulong Id, User? User)> Inner(ulong id)
|
||||||
|
{
|
||||||
|
var user = await _cache.GetOrFetchUser(_rest, id);
|
||||||
|
return (id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.WhenAll(ids.Select(Inner));
|
||||||
|
}
|
||||||
|
|
||||||
|
var config = await ctx.Repository.GetSystemConfig(system.Id);
|
||||||
|
|
||||||
|
// Fetch/render info for all accounts simultaneously
|
||||||
|
var accounts = await ctx.Repository.GetSystemAccounts(system.Id);
|
||||||
|
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted: `{x.Id}`)");
|
||||||
|
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Title("System info")
|
||||||
|
.Color(DiscordUtils.Green)
|
||||||
|
.Field(new Embed.Field("System ID", $"`{system.Hid}`"))
|
||||||
|
.Field(new Embed.Field("Linked accounts", string.Join("\n", users).Truncate(1000)));
|
||||||
|
|
||||||
|
var memberLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||||
|
var memberCount = await ctx.Repository.GetSystemMemberCount(system.Id);
|
||||||
|
eb.Field(new Embed.Field("Member limit", $"{memberLimit} {UntilLimit(memberCount, memberLimit)}", true));
|
||||||
|
|
||||||
|
var groupLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
|
||||||
|
var groupCount = await ctx.Repository.GetSystemGroupCount(system.Id);
|
||||||
|
eb.Field(new Embed.Field("Group limit", $"{groupLimit} {UntilLimit(groupCount, groupLimit)}", true));
|
||||||
|
|
||||||
|
return eb.Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSystemId(Context ctx)
|
public async Task UpdateSystemId(Context ctx)
|
||||||
|
|
@ -37,6 +94,8 @@ public class Admin
|
||||||
if (existingSystem != null)
|
if (existingSystem != null)
|
||||||
throw new PKError($"Another system already exists with ID `{newHid}`.");
|
throw new PKError($"Another system already exists with ID `{newHid}`.");
|
||||||
|
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
|
if (!await ctx.PromptYesNo($"Change system ID of `{target.Hid}` to `{newHid}`?", "Change"))
|
||||||
throw new PKError("ID change cancelled.");
|
throw new PKError("ID change cancelled.");
|
||||||
|
|
||||||
|
|
@ -60,6 +119,9 @@ public class Admin
|
||||||
if (existingMember != null)
|
if (existingMember != null)
|
||||||
throw new PKError($"Another member already exists with ID `{newHid}`.");
|
throw new PKError($"Another member already exists with ID `{newHid}`.");
|
||||||
|
|
||||||
|
var system = await ctx.Repository.GetSystem(target.System);
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, system));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo(
|
if (!await ctx.PromptYesNo(
|
||||||
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
|
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
|
||||||
"Change"
|
"Change"
|
||||||
|
|
@ -86,6 +148,9 @@ public class Admin
|
||||||
if (existingGroup != null)
|
if (existingGroup != null)
|
||||||
throw new PKError($"Another group already exists with ID `{newHid}`.");
|
throw new PKError($"Another group already exists with ID `{newHid}`.");
|
||||||
|
|
||||||
|
var system = await ctx.Repository.GetSystem(target.System);
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, system));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?",
|
if (!await ctx.PromptYesNo($"Change group ID of **{target.Name}** (`{target.Hid}`) to `{newHid}`?",
|
||||||
"Change"
|
"Change"
|
||||||
))
|
))
|
||||||
|
|
@ -103,6 +168,8 @@ public class Admin
|
||||||
if (target == null)
|
if (target == null)
|
||||||
throw new PKError("Unknown system.");
|
throw new PKError("Unknown system.");
|
||||||
|
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll"))
|
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll"))
|
||||||
throw new PKError("ID change cancelled.");
|
throw new PKError("ID change cancelled.");
|
||||||
|
|
||||||
|
|
@ -124,6 +191,9 @@ public class Admin
|
||||||
if (target == null)
|
if (target == null)
|
||||||
throw new PKError("Unknown member.");
|
throw new PKError("Unknown member.");
|
||||||
|
|
||||||
|
var system = await ctx.Repository.GetSystem(target.System);
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, system));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo(
|
if (!await ctx.PromptYesNo(
|
||||||
$"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?",
|
$"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?",
|
||||||
"Reroll"
|
"Reroll"
|
||||||
|
|
@ -148,6 +218,9 @@ public class Admin
|
||||||
if (target == null)
|
if (target == null)
|
||||||
throw new PKError("Unknown group.");
|
throw new PKError("Unknown group.");
|
||||||
|
|
||||||
|
var system = await ctx.Repository.GetSystem(target.System);
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, system));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo($"Reroll group ID for **{target.Name}** (`{target.Hid}`)?",
|
if (!await ctx.PromptYesNo($"Reroll group ID for **{target.Name}** (`{target.Hid}`)?",
|
||||||
"Change"
|
"Change"
|
||||||
))
|
))
|
||||||
|
|
@ -176,7 +249,7 @@ public class Admin
|
||||||
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
|
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
|
||||||
if (!ctx.HasNext())
|
if (!ctx.HasNext())
|
||||||
{
|
{
|
||||||
await ctx.Reply($"Current member limit is **{currentLimit}** members.");
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +257,7 @@ public class Admin
|
||||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||||
|
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
if (!await ctx.PromptYesNo($"Update member limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||||
throw new PKError("Member limit change cancelled.");
|
throw new PKError("Member limit change cancelled.");
|
||||||
|
|
||||||
|
|
@ -204,7 +278,7 @@ public class Admin
|
||||||
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
|
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
|
||||||
if (!ctx.HasNext())
|
if (!ctx.HasNext())
|
||||||
{
|
{
|
||||||
await ctx.Reply($"Current group limit is **{currentLimit}** groups.");
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,6 +286,7 @@ public class Admin
|
||||||
if (!int.TryParse(newLimitStr, out var newLimit))
|
if (!int.TryParse(newLimitStr, out var newLimit))
|
||||||
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
throw new PKError($"Couldn't parse `{newLimitStr}` as number.");
|
||||||
|
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, target));
|
||||||
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
if (!await ctx.PromptYesNo($"Update group limit from **{currentLimit}** to **{newLimit}**?", "Update"))
|
||||||
throw new PKError("Group limit change cancelled.");
|
throw new PKError("Group limit change cancelled.");
|
||||||
|
|
||||||
|
|
@ -243,6 +318,7 @@ public class Admin
|
||||||
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config);
|
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config);
|
||||||
|
|
||||||
var system = await ctx.Repository.GetSystem(systemId.Value!);
|
var system = await ctx.Repository.GetSystem(systemId.Value!);
|
||||||
|
await ctx.Reply(null, await CreateEmbed(ctx, system));
|
||||||
|
|
||||||
if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account"))
|
if (!await ctx.PromptYesNo($"Associate account {account.NameAndMention()} with system `{system.Hid}`?", "Recover account"))
|
||||||
throw new PKError("System recovery cancelled.");
|
throw new PKError("System recovery cancelled.");
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ public class Checks
|
||||||
var error = "Channel not found or you do not have permissions to access it.";
|
var error = "Channel not found or you do not have permissions to access it.";
|
||||||
|
|
||||||
// todo: this breaks if channel is not in cache and bot does not have View Channel permissions
|
// todo: this breaks if channel is not in cache and bot does not have View Channel permissions
|
||||||
|
// with new cache it breaks if channel is not in current guild
|
||||||
var channel = await ctx.MatchChannel();
|
var channel = await ctx.MatchChannel();
|
||||||
if (channel == null || channel.GuildId == null)
|
if (channel == null || channel.GuildId == null)
|
||||||
throw new PKError(error);
|
throw new PKError(error);
|
||||||
|
|
@ -156,7 +157,8 @@ public class Checks
|
||||||
if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||||
throw new PKError(error);
|
throw new PKError(error);
|
||||||
|
|
||||||
var botPermissions = await _cache.BotPermissionsIn(channel.Id);
|
// todo: permcheck channel outside of guild?
|
||||||
|
var botPermissions = await _cache.BotPermissionsIn(ctx.Guild.Id, channel.Id);
|
||||||
|
|
||||||
// We use a bitfield so we can set individual permission bits
|
// We use a bitfield so we can set individual permission bits
|
||||||
ulong missingPermissions = 0;
|
ulong missingPermissions = 0;
|
||||||
|
|
@ -231,11 +233,11 @@ public class Checks
|
||||||
var channel = await _rest.GetChannelOrNull(channelId.Value);
|
var channel = await _rest.GetChannelOrNull(channelId.Value);
|
||||||
if (channel == null)
|
if (channel == null)
|
||||||
throw new PKError("Unable to get the channel associated with this message.");
|
throw new PKError("Unable to get the channel associated with this message.");
|
||||||
|
|
||||||
var rootChannel = await _cache.GetRootChannel(channel.Id);
|
|
||||||
if (channel.GuildId == null)
|
if (channel.GuildId == null)
|
||||||
throw new PKError("PluralKit is not able to proxy messages in DMs.");
|
throw new PKError("PluralKit is not able to proxy messages in DMs.");
|
||||||
|
|
||||||
|
var rootChannel = await _cache.GetRootChannel(channel.GuildId!.Value, channel.Id);
|
||||||
|
|
||||||
// using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId
|
// using channel.GuildId here since _rest.GetMessage() doesn't return the GuildId
|
||||||
var context = await ctx.Repository.GetMessageContext(msg.Author.Id, channel.GuildId.Value, rootChannel.Id, msg.ChannelId);
|
var context = await ctx.Repository.GetMessageContext(msg.Author.Id, channel.GuildId.Value, rootChannel.Id, msg.ChannelId);
|
||||||
var members = (await ctx.Repository.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList();
|
var members = (await ctx.Repository.GetProxyMembers(msg.Author.Id, channel.GuildId.Value)).ToList();
|
||||||
|
|
|
||||||
|
|
@ -536,4 +536,10 @@ public class Config
|
||||||
}
|
}
|
||||||
else throw new PKError(badInputError);
|
else throw new PKError(badInputError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task LimitUpdate(Context ctx)
|
||||||
|
{
|
||||||
|
throw new PKError("You cannot update your own member or group limits. If you need a limit update, please join the " +
|
||||||
|
"support server and ask in #bot-support: https://discord.gg/PczBt78");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -53,41 +53,55 @@ public class GroupMember
|
||||||
|
|
||||||
public async Task ListMemberGroups(Context ctx, PKMember target)
|
public async Task ListMemberGroups(Context ctx, PKMember target)
|
||||||
{
|
{
|
||||||
var pctx = ctx.DirectLookupContextFor(target.System);
|
var targetSystem = await ctx.Repository.GetSystem(target.System);
|
||||||
|
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System));
|
||||||
|
opts.MemberFilter = target.Id;
|
||||||
|
|
||||||
var groups = await ctx.Repository.GetMemberGroups(target.Id)
|
var title = new StringBuilder($"Groups containing {target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
|
||||||
.Where(g => g.Visibility.CanAccess(pctx))
|
if (ctx.Guild != null)
|
||||||
.OrderBy(g => (g.DisplayName ?? g.Name), StringComparer.InvariantCultureIgnoreCase)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var description = "";
|
|
||||||
var msg = "";
|
|
||||||
|
|
||||||
if (groups.Count == 0)
|
|
||||||
description = "This member has no groups.";
|
|
||||||
else
|
|
||||||
description = string.Join("\n", groups.Select(g => $"[`{g.DisplayHid(ctx.Config, isList: true)}`] **{g.DisplayName ?? g.Name}**"));
|
|
||||||
|
|
||||||
if (pctx == LookupContext.ByOwner)
|
|
||||||
{
|
{
|
||||||
msg +=
|
var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, targetSystem.Id);
|
||||||
$"\n\nTo add this member to one or more groups, use `pk;m {target.Reference(ctx)} group add <group> [group 2] [group 3...]`";
|
if (guildSettings.DisplayName != null)
|
||||||
if (groups.Count > 0)
|
title.Append($"{guildSettings.DisplayName} (`{targetSystem.DisplayHid(ctx.Config)}`)");
|
||||||
msg +=
|
else if (targetSystem.NameFor(ctx) != null)
|
||||||
$"\nTo remove this member from one or more groups, use `pk;m {target.Reference(ctx)} group remove <group> [group 2] [group 3...]`";
|
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.DisplayHid(ctx.Config)}`)");
|
||||||
|
else
|
||||||
|
title.Append($"`{targetSystem.DisplayHid(ctx.Config)}`");
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (targetSystem.NameFor(ctx) != null)
|
||||||
|
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.DisplayHid(ctx.Config)}`)");
|
||||||
|
else
|
||||||
|
title.Append($"`{targetSystem.DisplayHid(ctx.Config)}`");
|
||||||
|
}
|
||||||
|
if (opts.Search != null)
|
||||||
|
title.Append($" matching **{opts.Search.Truncate(100)}**");
|
||||||
|
|
||||||
await ctx.Reply(msg, new EmbedBuilder().Title($"{target.Name}'s groups").Description(description).Build());
|
await ctx.RenderGroupList(ctx.LookupContextFor(target.System), target.System, title.ToString(),
|
||||||
|
target.Color, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddRemoveMembers(Context ctx, PKGroup target, Groups.AddRemoveOperation op)
|
public async Task AddRemoveMembers(Context ctx, PKGroup target, Groups.AddRemoveOperation op)
|
||||||
{
|
{
|
||||||
ctx.CheckOwnGroup(target);
|
ctx.CheckOwnGroup(target);
|
||||||
|
|
||||||
var members = (await ctx.ParseMemberList(ctx.System.Id))
|
List<MemberId> members;
|
||||||
|
if (ctx.MatchFlag("all", "a"))
|
||||||
|
{
|
||||||
|
members = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
|
||||||
|
new DatabaseViewsExt.ListQueryOptions { })))
|
||||||
|
.Select(m => m.Id)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
members = (await ctx.ParseMemberList(ctx.System.Id))
|
||||||
.Select(m => m.Id)
|
.Select(m => m.Id)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
|
var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
|
||||||
new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id })))
|
new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id })))
|
||||||
|
|
@ -127,7 +141,7 @@ public class GroupMember
|
||||||
var targetSystem = await GetGroupSystem(ctx, target);
|
var targetSystem = await GetGroupSystem(ctx, target);
|
||||||
ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy);
|
ctx.CheckSystemPrivacy(targetSystem.Id, target.ListPrivacy);
|
||||||
|
|
||||||
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System));
|
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.System), ctx.LookupContextFor(target.System));
|
||||||
opts.GroupFilter = target.Id;
|
opts.GroupFilter = target.Id;
|
||||||
|
|
||||||
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
|
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
|
||||||
|
|
|
||||||
|
|
@ -132,40 +132,47 @@ public class Groups
|
||||||
|
|
||||||
// No perms check, display name isn't covered by member privacy
|
// No perms check, display name isn't covered by member privacy
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.DisplayName == null)
|
if (target.DisplayName == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noDisplayNameSetMessage);
|
await ctx.Reply(noDisplayNameSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.DisplayName}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.DisplayName}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing displayname for group {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(target.DisplayName, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.DisplayName == null)
|
var eb = new EmbedBuilder()
|
||||||
{
|
.Field(new Embed.Field("Name", target.Name))
|
||||||
await ctx.Reply(noDisplayNameSetMessage);
|
.Field(new Embed.Field("Display Name", target.DisplayName));
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var eb = new EmbedBuilder()
|
|
||||||
.Field(new Embed.Field("Name", target.Name))
|
|
||||||
.Field(new Embed.Field("Display Name", target.DisplayName));
|
|
||||||
|
|
||||||
var reference = target.Reference(ctx);
|
var reference = target.Reference(ctx);
|
||||||
|
|
||||||
if (ctx.System?.Id == target.System)
|
if (ctx.System?.Id == target.System)
|
||||||
eb.Description(
|
eb.Description(
|
||||||
$"To change display name, type `pk;group {reference} displayname <display name>`.\n"
|
$"To change display name, type `pk;group {reference} displayname <display name>`.\n"
|
||||||
+ $"To clear it, type `pk;group {reference} displayname -clear`.\n"
|
+ $"To clear it, type `pk;group {reference} displayname -clear`.\n"
|
||||||
+ $"To print the raw display name, type `pk;group {reference} displayname -raw`.");
|
+ $"To print the raw display name, type `pk;group {reference} displayname -raw`.");
|
||||||
|
|
||||||
if (ctx.System?.Id == target.System)
|
if (ctx.System?.Id == target.System)
|
||||||
eb.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters."));
|
eb.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters."));
|
||||||
|
|
||||||
await ctx.Reply(embed: eb.Build());
|
await ctx.Reply(embed: eb.Build());
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +191,8 @@ public class Groups
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
|
var newDisplayName = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
|
||||||
|
if (newDisplayName.Length > Limits.MaxGroupNameLength)
|
||||||
|
throw new PKError($"Group name too long ({newDisplayName.Length}/{Limits.MaxGroupNameLength} characters).");
|
||||||
|
|
||||||
var patch = new GroupPatch { DisplayName = Partial<string>.Present(newDisplayName) };
|
var patch = new GroupPatch { DisplayName = Partial<string>.Present(newDisplayName) };
|
||||||
await ctx.Repository.UpdateGroup(target.Id, patch);
|
await ctx.Repository.UpdateGroup(target.Id, patch);
|
||||||
|
|
@ -201,30 +210,41 @@ public class Groups
|
||||||
noDescriptionSetMessage +=
|
noDescriptionSetMessage +=
|
||||||
$" To set one, type `pk;group {target.Reference(ctx)} description <description>`.";
|
$" To set one, type `pk;group {target.Reference(ctx)} description <description>`.";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Description == null)
|
if (target.Description == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
await ctx.Reply(noDescriptionSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Description}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.Description}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing description for group {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(target.Description, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Description == null)
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
.Title("Group description")
|
||||||
else
|
.Description(target.Description)
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
.Field(new Embed.Field("\u200B",
|
||||||
.Title("Group description")
|
$"To print the description with formatting, type `pk;group {target.Reference(ctx)} description -raw`."
|
||||||
.Description(target.Description)
|
+ (ctx.System?.Id == target.System
|
||||||
.Field(new Embed.Field("\u200B",
|
? $" To clear it, type `pk;group {target.Reference(ctx)} description -clear`."
|
||||||
$"To print the description with formatting, type `pk;group {target.Reference(ctx)} description -raw`."
|
: "")
|
||||||
+ (ctx.System?.Id == target.System
|
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
||||||
? $" To clear it, type `pk;group {target.Reference(ctx)} description -clear`."
|
.Build());
|
||||||
: "")
|
|
||||||
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
|
||||||
.Build());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -385,7 +405,7 @@ public class Groups
|
||||||
public async Task GroupColor(Context ctx, PKGroup target)
|
public async Task GroupColor(Context ctx, PKGroup target)
|
||||||
{
|
{
|
||||||
var isOwnSystem = ctx.System?.Id == target.System;
|
var isOwnSystem = ctx.System?.Id == target.System;
|
||||||
var matchedRaw = ctx.MatchRaw();
|
var matchedFormat = ctx.MatchFormat();
|
||||||
var matchedClear = ctx.MatchClear();
|
var matchedClear = ctx.MatchClear();
|
||||||
|
|
||||||
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
||||||
|
|
@ -393,8 +413,10 @@ public class Groups
|
||||||
if (target.Color == null)
|
if (target.Color == null)
|
||||||
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." + (isOwnSystem ? $" To set one, type `pk;group {target.Reference(ctx)} color <color>`." : ""));
|
||||||
else if (matchedRaw)
|
else if (matchedFormat == ReplyFormat.Raw)
|
||||||
await ctx.Reply("```\n#" + target.Color + "\n```");
|
await ctx.Reply("```\n#" + target.Color + "\n```");
|
||||||
|
else if (matchedFormat == ReplyFormat.Plaintext)
|
||||||
|
await ctx.Reply(target.Color);
|
||||||
else
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("Group color")
|
.Title("Group color")
|
||||||
|
|
@ -446,7 +468,7 @@ public class Groups
|
||||||
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
|
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
|
||||||
// - RenderGroupList checks the indivual privacy for each member (NameFor, etc)
|
// - RenderGroupList checks the indivual privacy for each member (NameFor, etc)
|
||||||
// the own system is always allowed to look up their list
|
// the own system is always allowed to look up their list
|
||||||
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id));
|
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(system.Id), ctx.LookupContextFor(system.Id));
|
||||||
await ctx.RenderGroupList(
|
await ctx.RenderGroupList(
|
||||||
ctx.LookupContextFor(system.Id),
|
ctx.LookupContextFor(system.Id),
|
||||||
system.Id,
|
system.Id,
|
||||||
|
|
@ -460,8 +482,8 @@ public class Groups
|
||||||
{
|
{
|
||||||
var title = new StringBuilder("Groups of ");
|
var title = new StringBuilder("Groups of ");
|
||||||
|
|
||||||
if (target.Name != null)
|
if (target.NameFor(ctx) != null)
|
||||||
title.Append($"{target.Name} (`{target.DisplayHid(ctx.Config)}`)");
|
title.Append($"{target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
|
||||||
else
|
else
|
||||||
title.Append($"`{target.DisplayHid(ctx.Config)}`");
|
title.Append($"`{target.DisplayHid(ctx.Config)}`");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,26 @@ public class Help
|
||||||
|
|
||||||
private static Dictionary<string, Embed.Field[]> helpEmbedPages = new Dictionary<string, Embed.Field[]>
|
private static Dictionary<string, Embed.Field[]> helpEmbedPages = new Dictionary<string, Embed.Field[]>
|
||||||
{
|
{
|
||||||
|
{
|
||||||
|
"default",
|
||||||
|
new Embed.Field[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
(
|
||||||
|
"System Recovery",
|
||||||
|
"In the case of your Discord account getting lost or deleted, the PluralKit staff can help you recover your system. "
|
||||||
|
+ "In order to do so, we will need your **PluralKit token**. This is the *only* way you can prove ownership so we can help you recover your system. "
|
||||||
|
+ "To get it, run `pk;token` and then store it in a safe place.\n\n"
|
||||||
|
+ "Keep your token safe, if other people get access to it they can also use it to access your system. "
|
||||||
|
+ "If your token is ever compromised run `pk;token refresh` to invalidate the old token and get a new one."
|
||||||
|
),
|
||||||
|
new
|
||||||
|
(
|
||||||
|
"Use the buttons below to see more info!",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"basicinfo",
|
"basicinfo",
|
||||||
new Embed.Field[]
|
new Embed.Field[]
|
||||||
|
|
@ -31,7 +51,7 @@ public class Help
|
||||||
(
|
(
|
||||||
"Why are people's names saying [APP] or [BOT] next to them?",
|
"Why are people's names saying [APP] or [BOT] next to them?",
|
||||||
"These people are not actually apps or bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."
|
"These people are not actually apps or bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."
|
||||||
),
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -137,7 +157,9 @@ public class Help
|
||||||
public Task HelpRoot(Context ctx)
|
public Task HelpRoot(Context ctx)
|
||||||
=> ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
|
=> 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!**" } },
|
Content = $"{Emojis.Warn} If you cannot see the rest of this message see [the FAQ](<https://pluralkit.me/faq/#why-do-most-of-pluralkit-s-messages-look-blank-or-empty>)",
|
||||||
|
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description,
|
||||||
|
Fields = helpEmbedPages.GetValueOrDefault("default") } },
|
||||||
Components = new[] { helpPageButtons(ctx.Author.Id) },
|
Components = new[] { helpPageButtons(ctx.Author.Id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -151,7 +173,7 @@ public class Help
|
||||||
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
|
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
|
||||||
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
|
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
|
||||||
{
|
{
|
||||||
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description + "\n\n**Use the buttons below to see more info!**" } },
|
Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault("default") } },
|
||||||
Components = new[] { buttons }
|
Components = new[] { buttons }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -172,4 +194,6 @@ public class Help
|
||||||
});
|
});
|
||||||
|
|
||||||
public Task Explain(Context ctx) => ctx.Reply(explanation);
|
public Task Explain(Context ctx) => ctx.Reply(explanation);
|
||||||
|
|
||||||
|
public Task Dashboard(Context ctx) => ctx.Reply("The PluralKit dashboard is at <https://dash.pluralkit.me>");
|
||||||
}
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ namespace PluralKit.Bot;
|
||||||
|
|
||||||
public static class ContextListExt
|
public static class ContextListExt
|
||||||
{
|
{
|
||||||
public static ListOptions ParseListOptions(this Context ctx, LookupContext lookupCtx)
|
public static ListOptions ParseListOptions(this Context ctx, LookupContext directLookupCtx, LookupContext lookupContext)
|
||||||
{
|
{
|
||||||
var p = new ListOptions();
|
var p = new ListOptions();
|
||||||
|
|
||||||
|
|
@ -55,10 +55,13 @@ public static class ContextListExt
|
||||||
if (ctx.MatchFlag("private-only", "po")) p.PrivacyFilter = PrivacyLevel.Private;
|
if (ctx.MatchFlag("private-only", "po")) p.PrivacyFilter = PrivacyLevel.Private;
|
||||||
|
|
||||||
// PERM CHECK: If we're trying to access non-public members of another system, error
|
// PERM CHECK: If we're trying to access non-public members of another system, error
|
||||||
if (p.PrivacyFilter != PrivacyLevel.Public && lookupCtx != LookupContext.ByOwner)
|
if (p.PrivacyFilter != PrivacyLevel.Public && directLookupCtx != LookupContext.ByOwner)
|
||||||
// TODO: should this just return null instead of throwing or something? >.>
|
// TODO: should this just return null instead of throwing or something? >.>
|
||||||
throw Errors.NotOwnInfo;
|
throw Errors.NotOwnInfo;
|
||||||
|
|
||||||
|
//this is for searching
|
||||||
|
p.Context = lookupContext;
|
||||||
|
|
||||||
// Additional fields to include in the search results
|
// Additional fields to include in the search results
|
||||||
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
|
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
|
||||||
p.IncludeLastSwitch = true;
|
p.IncludeLastSwitch = true;
|
||||||
|
|
@ -124,11 +127,14 @@ public static class ContextListExt
|
||||||
|
|
||||||
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedMember> page)
|
||||||
{
|
{
|
||||||
|
// if there are both 5 and 6 character Hids they should be padded to align correctly.
|
||||||
|
var shouldPad = page.Any(x => x.Hid.Length > 5);
|
||||||
|
|
||||||
// We may end up over the description character limit
|
// We may end up over the description character limit
|
||||||
// so run it through a helper that "makes it work" :)
|
// so run it through a helper that "makes it work" :)
|
||||||
eb.WithSimpleLineContent(page.Select(m =>
|
eb.WithSimpleLineContent(page.Select(m =>
|
||||||
{
|
{
|
||||||
var ret = $"[`{m.DisplayHid(ctx.Config, isList: true)}`] **{m.NameFor(ctx)}** ";
|
var ret = $"[`{m.DisplayHid(ctx.Config, isList: true, shouldPad: shouldPad)}`] **{m.NameFor(ctx)}** ";
|
||||||
|
|
||||||
if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count)
|
if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count)
|
||||||
ret += $"({count} messages)";
|
ret += $"({count} messages)";
|
||||||
|
|
@ -234,11 +240,14 @@ public static class ContextListExt
|
||||||
|
|
||||||
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
|
void ShortRenderer(EmbedBuilder eb, IEnumerable<ListedGroup> page)
|
||||||
{
|
{
|
||||||
|
// if there are both 5 and 6 character Hids they should be padded to align correctly.
|
||||||
|
var shouldPad = page.Any(x => x.Hid.Length > 5);
|
||||||
|
|
||||||
// We may end up over the description character limit
|
// We may end up over the description character limit
|
||||||
// so run it through a helper that "makes it work" :)
|
// so run it through a helper that "makes it work" :)
|
||||||
eb.WithSimpleLineContent(page.Select(g =>
|
eb.WithSimpleLineContent(page.Select(g =>
|
||||||
{
|
{
|
||||||
var ret = $"[`{g.DisplayHid(ctx.Config, isList: true)}`] **{g.NameFor(ctx)}** ";
|
var ret = $"[`{g.DisplayHid(ctx.Config, isList: true, shouldPad: shouldPad)}`] **{g.NameFor(ctx)}** ";
|
||||||
|
|
||||||
switch (opts.SortProperty)
|
switch (opts.SortProperty)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ public class ListOptions
|
||||||
public bool Reverse { get; set; }
|
public bool Reverse { get; set; }
|
||||||
|
|
||||||
public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public;
|
public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public;
|
||||||
|
public LookupContext Context { get; set; } = LookupContext.ByNonOwner;
|
||||||
public GroupId? GroupFilter { get; set; }
|
public GroupId? GroupFilter { get; set; }
|
||||||
|
public MemberId? MemberFilter { get; set; }
|
||||||
public string? Search { get; set; }
|
public string? Search { get; set; }
|
||||||
public bool SearchDescription { get; set; }
|
public bool SearchDescription { get; set; }
|
||||||
|
|
||||||
|
|
@ -96,8 +98,10 @@ public class ListOptions
|
||||||
{
|
{
|
||||||
PrivacyFilter = PrivacyFilter,
|
PrivacyFilter = PrivacyFilter,
|
||||||
GroupFilter = GroupFilter,
|
GroupFilter = GroupFilter,
|
||||||
|
MemberFilter = MemberFilter,
|
||||||
Search = Search,
|
Search = Search,
|
||||||
SearchDescription = SearchDescription
|
SearchDescription = SearchDescription,
|
||||||
|
Context = Context
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,30 +70,41 @@ public class MemberEdit
|
||||||
noDescriptionSetMessage +=
|
noDescriptionSetMessage +=
|
||||||
$" To set one, type `pk;member {target.Reference(ctx)} description <description>`.";
|
$" To set one, type `pk;member {target.Reference(ctx)} description <description>`.";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Description == null)
|
if (target.Description == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
await ctx.Reply(noDescriptionSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Description}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.Description}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing description for member {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(target.Description, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Description == null)
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
.Title("Member description")
|
||||||
else
|
.Description(target.Description)
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
.Field(new Embed.Field("\u200B",
|
||||||
.Title("Member description")
|
$"To print the description with formatting, type `pk;member {target.Reference(ctx)} description -raw`."
|
||||||
.Description(target.Description)
|
+ (ctx.System?.Id == target.System
|
||||||
.Field(new Embed.Field("\u200B",
|
? $" To clear it, type `pk;member {target.Reference(ctx)} description -clear`."
|
||||||
$"To print the description with formatting, type `pk;member {target.Reference(ctx)} description -raw`."
|
: "")
|
||||||
+ (ctx.System?.Id == target.System
|
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
||||||
? $" To clear it, type `pk;member {target.Reference(ctx)} description -clear`."
|
.Build());
|
||||||
: "")
|
|
||||||
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
|
||||||
.Build());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,26 +137,37 @@ public class MemberEdit
|
||||||
|
|
||||||
ctx.CheckSystemPrivacy(target.System, target.PronounPrivacy);
|
ctx.CheckSystemPrivacy(target.System, target.PronounPrivacy);
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Pronouns == null)
|
if (target.Pronouns == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noPronounsSetMessage);
|
await ctx.Reply(noPronounsSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Pronouns}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.Pronouns}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing pronouns for member {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(target.Pronouns, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Pronouns == null)
|
await ctx.Reply(
|
||||||
await ctx.Reply(noPronounsSetMessage);
|
$"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;member {target.Reference(ctx)} pronouns -raw`."
|
||||||
else
|
+ (ctx.System?.Id == target.System
|
||||||
await ctx.Reply(
|
? $" To clear them, type `pk;member {target.Reference(ctx)} pronouns -clear`."
|
||||||
$"**{target.NameFor(ctx)}**'s pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;member {target.Reference(ctx)} pronouns -raw`."
|
: "")
|
||||||
+ (ctx.System?.Id == target.System
|
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
|
||||||
? $" To clear them, type `pk;member {target.Reference(ctx)} pronouns -clear`."
|
|
||||||
: "")
|
|
||||||
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +254,7 @@ public class MemberEdit
|
||||||
public async Task Color(Context ctx, PKMember target)
|
public async Task Color(Context ctx, PKMember target)
|
||||||
{
|
{
|
||||||
var isOwnSystem = ctx.System?.Id == target.System;
|
var isOwnSystem = ctx.System?.Id == target.System;
|
||||||
var matchedRaw = ctx.MatchRaw();
|
var matchedFormat = ctx.MatchFormat();
|
||||||
var matchedClear = ctx.MatchClear();
|
var matchedClear = ctx.MatchClear();
|
||||||
|
|
||||||
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
||||||
|
|
@ -240,8 +262,10 @@ public class MemberEdit
|
||||||
if (target.Color == null)
|
if (target.Color == null)
|
||||||
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." + (isOwnSystem ? $" To set one, type `pk;member {target.Reference(ctx)} color <color>`." : ""));
|
||||||
else if (matchedRaw)
|
else if (matchedFormat == ReplyFormat.Raw)
|
||||||
await ctx.Reply("```\n#" + target.Color + "\n```");
|
await ctx.Reply("```\n#" + target.Color + "\n```");
|
||||||
|
else if (matchedFormat == ReplyFormat.Plaintext)
|
||||||
|
await ctx.Reply(target.Color);
|
||||||
else
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("Member color")
|
.Title("Member color")
|
||||||
|
|
@ -388,12 +412,26 @@ public class MemberEdit
|
||||||
|
|
||||||
// No perms check, display name isn't covered by member privacy
|
// No perms check, display name isn't covered by member privacy
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if what's next is "raw"/"plaintext" we need to check for null
|
||||||
|
if (format != ReplyFormat.Standard)
|
||||||
if (target.DisplayName == null)
|
if (target.DisplayName == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noDisplayNameSetMessage);
|
await ctx.Reply(noDisplayNameSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.DisplayName}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.DisplayName}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing displayname for member {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(target.DisplayName, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -450,12 +488,26 @@ public class MemberEdit
|
||||||
// No perms check, display name isn't covered by member privacy
|
// No perms check, display name isn't covered by member privacy
|
||||||
var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id);
|
var memberGuildConfig = await ctx.Repository.GetMemberGuild(ctx.Guild.Id, target.Id);
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if what's next is "raw"/"plaintext" we need to check for null
|
||||||
|
if (format != ReplyFormat.Standard)
|
||||||
if (memberGuildConfig.DisplayName == null)
|
if (memberGuildConfig.DisplayName == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noServerNameSetMessage);
|
await ctx.Reply(noServerNameSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{memberGuildConfig.DisplayName}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{memberGuildConfig.DisplayName}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing servername for member {target.Reference(ctx)}");
|
||||||
|
await ctx.Reply(memberGuildConfig.DisplayName, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ public class MemberProxy
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`).");
|
throw new PKSyntaxError("You must pass an example proxy to add (eg. `[text]` or `J:text`).");
|
||||||
|
|
||||||
var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false));
|
var tagToAdd = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing());
|
||||||
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
if (tagToAdd.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
||||||
if (target.ProxyTags.Contains(tagToAdd))
|
if (target.ProxyTags.Contains(tagToAdd))
|
||||||
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
|
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
|
||||||
|
|
@ -87,10 +87,17 @@ public class MemberProxy
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`).");
|
throw new PKSyntaxError("You must pass a proxy tag to remove (eg. `[text]` or `J:text`).");
|
||||||
|
|
||||||
var tagToRemove = ParseProxyTags(ctx.RemainderOrNull(false));
|
var remainder = ctx.RemainderOrNull(false);
|
||||||
|
var tagToRemove = ParseProxyTags(remainder.NormalizeLineEndSpacing());
|
||||||
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
if (tagToRemove.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
||||||
if (!target.ProxyTags.Contains(tagToRemove))
|
if (!target.ProxyTags.Contains(tagToRemove))
|
||||||
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
|
{
|
||||||
|
// Legacy support for when line endings weren't normalized
|
||||||
|
tagToRemove = ParseProxyTags(remainder);
|
||||||
|
if (!target.ProxyTags.Contains(tagToRemove))
|
||||||
|
throw Errors.ProxyTagDoesNotExist(tagToRemove, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var newTags = target.ProxyTags.ToList();
|
var newTags = target.ProxyTags.ToList();
|
||||||
newTags.Remove(tagToRemove);
|
newTags.Remove(tagToRemove);
|
||||||
|
|
@ -102,7 +109,7 @@ public class MemberProxy
|
||||||
// Subcommand: bare proxy tag given
|
// Subcommand: bare proxy tag given
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false));
|
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing());
|
||||||
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
|
||||||
|
|
||||||
// This is mostly a legacy command, so it's gonna warn if there's
|
// This is mostly a legacy command, so it's gonna warn if there's
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ public class ProxiedMessage
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var editedMsg =
|
var editedMsg =
|
||||||
await _webhookExecutor.EditWebhookMessage(msg.Channel, msg.Mid, newContent, clearEmbeds);
|
await _webhookExecutor.EditWebhookMessage(msg.Guild ?? 0, msg.Channel, msg.Mid, newContent, clearEmbeds);
|
||||||
|
|
||||||
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 });
|
||||||
|
|
@ -352,7 +352,9 @@ public class ProxiedMessage
|
||||||
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
|
||||||
showContent = false;
|
showContent = false;
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
|
|
||||||
|
if (format != ReplyFormat.Standard)
|
||||||
{
|
{
|
||||||
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
|
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
|
||||||
if (discordMessage == null || !showContent)
|
if (discordMessage == null || !showContent)
|
||||||
|
|
@ -365,21 +367,32 @@ public class ProxiedMessage
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.Reply($"```{content}```");
|
if (format == ReplyFormat.Raw)
|
||||||
|
|
||||||
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
|
|
||||||
{
|
{
|
||||||
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
await ctx.Reply($"```{content}```");
|
||||||
await ctx.Rest.CreateMessage(
|
|
||||||
ctx.Channel.Id,
|
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
|
||||||
new MessageRequest
|
{
|
||||||
{
|
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||||
Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment."
|
await ctx.Rest.CreateMessage(
|
||||||
},
|
ctx.Channel.Id,
|
||||||
new[] { new MultipartFile("message.txt", stream, null, null, null) });
|
new MessageRequest
|
||||||
|
{
|
||||||
|
Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment."
|
||||||
|
},
|
||||||
|
new[] { new MultipartFile("message.txt", stream, null, null, null) });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing contents of message {message.Message.Mid}");
|
||||||
|
await ctx.Reply(content, embed: eb.Build());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDelete)
|
if (isDelete)
|
||||||
|
|
@ -387,7 +400,7 @@ public class ProxiedMessage
|
||||||
if (!showContent)
|
if (!showContent)
|
||||||
throw new PKError(noShowContentError);
|
throw new PKError(noShowContentError);
|
||||||
|
|
||||||
if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.Author.Id)
|
if (message.Message.Sender != ctx.Author.Id && (ctx.System != null && message.System?.Id != ctx.System.Id))
|
||||||
throw new PKError("You can only delete your own messages.");
|
throw new PKError("You can only delete your own messages.");
|
||||||
|
|
||||||
await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid);
|
await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid);
|
||||||
|
|
@ -423,14 +436,14 @@ public class ProxiedMessage
|
||||||
|
|
||||||
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
|
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
|
||||||
{
|
{
|
||||||
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
var cmessage = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
||||||
if (authorId == null)
|
if (cmessage == null)
|
||||||
throw Errors.MessageNotFound(messageId);
|
throw Errors.MessageNotFound(messageId);
|
||||||
|
|
||||||
if (authorId != ctx.Author.Id)
|
if (cmessage!.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(channelId!.Value, messageId);
|
await ctx.Rest.DeleteMessage(cmessage.ChannelId, messageId);
|
||||||
|
|
||||||
if (ctx.Guild != null)
|
if (ctx.Guild != null)
|
||||||
await ctx.Rest.DeleteMessage(ctx.Message);
|
await ctx.Rest.DeleteMessage(ctx.Message);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ public class Random
|
||||||
{
|
{
|
||||||
ctx.CheckSystemPrivacy(group.System, group.ListPrivacy);
|
ctx.CheckSystemPrivacy(group.System, group.ListPrivacy);
|
||||||
|
|
||||||
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System));
|
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(group.System), ctx.LookupContextFor(group.System));
|
||||||
opts.GroupFilter = group.Id;
|
opts.GroupFilter = group.Id;
|
||||||
|
|
||||||
var members = await ctx.Database.Execute(conn => conn.QueryMemberList(group.System, opts.ToQueryOptions()));
|
var members = await ctx.Database.Execute(conn => conn.QueryMemberList(group.System, opts.ToQueryOptions()));
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ public class ServerConfig
|
||||||
if (channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildPublicThread && channel.Type != Channel.ChannelType.GuildPrivateThread)
|
if (channel.Type != Channel.ChannelType.GuildText && channel.Type != Channel.ChannelType.GuildPublicThread && channel.Type != Channel.ChannelType.GuildPrivateThread)
|
||||||
throw new PKError("PluralKit cannot log messages to this type of channel.");
|
throw new PKError("PluralKit cannot log messages to this type of channel.");
|
||||||
|
|
||||||
var perms = await _cache.BotPermissionsIn(channel.Id);
|
var perms = await _cache.BotPermissionsIn(ctx.Guild.Id, channel.Id);
|
||||||
if (!perms.HasFlag(PermissionSet.SendMessages))
|
if (!perms.HasFlag(PermissionSet.SendMessages))
|
||||||
throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel.");
|
throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel.");
|
||||||
if (!perms.HasFlag(PermissionSet.EmbedLinks))
|
if (!perms.HasFlag(PermissionSet.EmbedLinks))
|
||||||
|
|
@ -104,7 +104,7 @@ public class ServerConfig
|
||||||
|
|
||||||
// Resolve all channels from the cache and order by position
|
// Resolve all channels from the cache and order by position
|
||||||
var channels = (await Task.WhenAll(blacklist.Blacklist
|
var channels = (await Task.WhenAll(blacklist.Blacklist
|
||||||
.Select(id => _cache.TryGetChannel(id))))
|
.Select(id => _cache.TryGetChannel(ctx.Guild.Id, id))))
|
||||||
.Where(c => c != null)
|
.Where(c => c != null)
|
||||||
.OrderBy(c => c.Position)
|
.OrderBy(c => c.Position)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
@ -121,7 +121,7 @@ public class ServerConfig
|
||||||
async (eb, l) =>
|
async (eb, l) =>
|
||||||
{
|
{
|
||||||
async Task<string> CategoryName(ulong? id) =>
|
async Task<string> CategoryName(ulong? id) =>
|
||||||
id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)";
|
id != null ? (await _cache.GetChannel(ctx.Guild.Id, id.Value)).Name : "(no category)";
|
||||||
|
|
||||||
ulong? lastCategory = null;
|
ulong? lastCategory = null;
|
||||||
|
|
||||||
|
|
@ -153,8 +153,9 @@ public class ServerConfig
|
||||||
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
|
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
|
||||||
|
|
||||||
// Resolve all channels from the cache and order by position
|
// Resolve all channels from the cache and order by position
|
||||||
|
// todo: GetAllChannels?
|
||||||
var channels = (await Task.WhenAll(config.LogBlacklist
|
var channels = (await Task.WhenAll(config.LogBlacklist
|
||||||
.Select(id => _cache.TryGetChannel(id))))
|
.Select(id => _cache.TryGetChannel(ctx.Guild.Id, id))))
|
||||||
.Where(c => c != null)
|
.Where(c => c != null)
|
||||||
.OrderBy(c => c.Position)
|
.OrderBy(c => c.Position)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
@ -171,7 +172,7 @@ public class ServerConfig
|
||||||
async (eb, l) =>
|
async (eb, l) =>
|
||||||
{
|
{
|
||||||
async Task<string> CategoryName(ulong? id) =>
|
async Task<string> CategoryName(ulong? id) =>
|
||||||
id != null ? (await _cache.GetChannel(id.Value)).Name : "(no category)";
|
id != null ? (await _cache.GetChannel(ctx.Guild.Id, id.Value)).Name : "(no category)";
|
||||||
|
|
||||||
ulong? lastCategory = null;
|
ulong? lastCategory = null;
|
||||||
|
|
||||||
|
|
@ -204,7 +205,8 @@ public class ServerConfig
|
||||||
var affectedChannels = new List<Channel>();
|
var affectedChannels = new List<Channel>();
|
||||||
if (ctx.Match("all"))
|
if (ctx.Match("all"))
|
||||||
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
|
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
|
||||||
.Where(x => x.Type == Channel.ChannelType.GuildText).ToList();
|
// All the channel types you can proxy in
|
||||||
|
.Where(x => DiscordUtils.IsValidGuildChannel(x)).ToList();
|
||||||
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
else if (!ctx.HasNext()) throw new PKSyntaxError("You must pass one or more #channels.");
|
||||||
else
|
else
|
||||||
while (ctx.HasNext())
|
while (ctx.HasNext())
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ namespace PluralKit.Bot;
|
||||||
|
|
||||||
public class Switch
|
public class Switch
|
||||||
{
|
{
|
||||||
|
|
||||||
public async Task SwitchDo(Context ctx)
|
public async Task SwitchDo(Context ctx)
|
||||||
{
|
{
|
||||||
ctx.CheckSystem();
|
ctx.CheckSystem();
|
||||||
|
|
@ -103,12 +104,69 @@ public class Switch
|
||||||
await ctx.Reply($"{Emojis.Success} Switch moved to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago).");
|
await ctx.Reply($"{Emojis.Success} Switch moved to <t:{newSwitchTime}> ({newSwitchDeltaStr} ago).");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SwitchEdit(Context ctx)
|
public async Task SwitchEdit(Context ctx, bool newSwitch = false)
|
||||||
{
|
{
|
||||||
ctx.CheckSystem();
|
ctx.CheckSystem();
|
||||||
|
|
||||||
var members = await ctx.ParseMemberList(ctx.System.Id);
|
var newMembers = await ctx.ParseMemberList(ctx.System.Id);
|
||||||
await DoEditCommand(ctx, members);
|
|
||||||
|
await using var conn = await ctx.Database.Obtain();
|
||||||
|
var currentSwitch = await ctx.Repository.GetLatestSwitch(ctx.System.Id);
|
||||||
|
if (currentSwitch == null)
|
||||||
|
throw Errors.NoRegisteredSwitches;
|
||||||
|
var currentSwitchMembers = await ctx.Repository.GetSwitchMembers(conn, currentSwitch.Id).ToListAsync().AsTask();
|
||||||
|
|
||||||
|
if (ctx.MatchFlag("first", "f"))
|
||||||
|
newMembers = FirstInSwitch(newMembers[0], currentSwitchMembers);
|
||||||
|
else if (ctx.MatchFlag("remove", "r"))
|
||||||
|
newMembers = RemoveFromSwitch(newMembers, currentSwitchMembers);
|
||||||
|
else if (ctx.MatchFlag("append", "a"))
|
||||||
|
newMembers = AppendToSwitch(newMembers, currentSwitchMembers);
|
||||||
|
else if (ctx.MatchFlag("prepend", "p"))
|
||||||
|
newMembers = PrependToSwitch(newMembers, currentSwitchMembers);
|
||||||
|
|
||||||
|
if (newSwitch)
|
||||||
|
{
|
||||||
|
// if there's no edit flag, assume we're appending
|
||||||
|
if (!ctx.MatchFlag("first", "f", "remove", "r", "append", "a", "prepend", "p"))
|
||||||
|
newMembers = AppendToSwitch(newMembers, currentSwitchMembers);
|
||||||
|
await DoSwitchCommand(ctx, newMembers);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await DoEditCommand(ctx, newMembers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PKMember> PrependToSwitch(List<PKMember> members, List<PKMember> currentSwitchMembers)
|
||||||
|
{
|
||||||
|
members.AddRange(currentSwitchMembers);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PKMember> AppendToSwitch(List<PKMember> members, List<PKMember> currentSwitchMembers)
|
||||||
|
{
|
||||||
|
currentSwitchMembers.AddRange(members);
|
||||||
|
members = currentSwitchMembers;
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PKMember> RemoveFromSwitch(List<PKMember> members, List<PKMember> currentSwitchMembers)
|
||||||
|
{
|
||||||
|
var memberIds = members.Select(m => m.Id.Value);
|
||||||
|
currentSwitchMembers = currentSwitchMembers.Where(m => !memberIds.Contains(m.Id.Value)).ToList();
|
||||||
|
members = currentSwitchMembers;
|
||||||
|
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<PKMember> FirstInSwitch(PKMember member, List<PKMember> currentSwitchMembers)
|
||||||
|
{
|
||||||
|
currentSwitchMembers = currentSwitchMembers.Where(m => m.Id != member.Id).ToList();
|
||||||
|
var members = new List<PKMember> { member };
|
||||||
|
members.AddRange(currentSwitchMembers);
|
||||||
|
|
||||||
|
return members;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SwitchEditOut(Context ctx)
|
public async Task SwitchEditOut(Context ctx)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ using PluralKit.Core;
|
||||||
|
|
||||||
namespace PluralKit.Bot;
|
namespace PluralKit.Bot;
|
||||||
|
|
||||||
|
using Myriad.Builders;
|
||||||
|
using Myriad.Types;
|
||||||
|
|
||||||
public class System
|
public class System
|
||||||
{
|
{
|
||||||
private readonly EmbedService _embeds;
|
private readonly EmbedService _embeds;
|
||||||
|
|
@ -29,9 +32,25 @@ public class System
|
||||||
var system = await ctx.Repository.CreateSystem(systemName);
|
var system = await ctx.Repository.CreateSystem(systemName);
|
||||||
await ctx.Repository.AddAccount(system.Id, ctx.Author.Id);
|
await ctx.Repository.AddAccount(system.Id, ctx.Author.Id);
|
||||||
|
|
||||||
// TODO: better message, perhaps embed like in groups?
|
var eb = new EmbedBuilder()
|
||||||
await ctx.Reply(
|
.Title(
|
||||||
$"{Emojis.Success} Your system has been created. Type `pk;system` to view it, and type `pk;system help` for more information about commands you can use now. Now that you have that set up, check out the getting started guide on setting up members and proxies: <https://pluralkit.me/start>");
|
$"{Emojis.Success} Your system has been created.")
|
||||||
|
.Field(new Embed.Field("Getting Started",
|
||||||
|
"New to PK? Check out our Getting Started guide on setting up members and proxies: https://pluralkit.me/start\n" +
|
||||||
|
"Otherwise, type `pk;system` to view your system and `pk;system help` for more information about commands you can use."))
|
||||||
|
.Field(new Embed.Field($"{Emojis.Warn} Notice {Emojis.Warn}", "PluralKit is a bot meant to help you share information about your system. " +
|
||||||
|
"Member descriptions are meant to be the equivalent to a Discord About Me. Because of this, any info you put in PK is **public by default**.\n" +
|
||||||
|
"Note that this does **not** include message content, only member fields. For more information, check out " +
|
||||||
|
"[the privacy section of the user guide](https://pluralkit.me/guide/#privacy). "))
|
||||||
|
.Field(new Embed.Field("System Recovery", "In the case of your Discord account getting lost or deleted, the PluralKit staff can help you recover your system. " +
|
||||||
|
"In order to do so, we will need your **PluralKit token**. This is the *only* way you can prove ownership so we can help you recover your system. " +
|
||||||
|
"To get it, run `pk;token` and then store it in a safe place.\n\n" +
|
||||||
|
"Keep your token safe, if other people get access to it they can also use it to access your system. " +
|
||||||
|
"If your token is ever compromised run `pk;token refresh` to invalidate the old token and get a new one."))
|
||||||
|
.Field(new Embed.Field("Questions?",
|
||||||
|
"Please join the PK server https://discord.gg/PczBt78 if you have any questions, we're happy to help"));
|
||||||
|
await ctx.Reply($"{Emojis.Warn} If you cannot see the rest of this message see [the FAQ](<https://pluralkit.me/faq/#why-do-most-of-pluralkit-s-messages-look-blank-or-empty>)", eb.Build());
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DisplayId(Context ctx, PKSystem target)
|
public async Task DisplayId(Context ctx, PKSystem target)
|
||||||
|
|
|
||||||
|
|
@ -37,24 +37,35 @@ public class SystemEdit
|
||||||
if (isOwnSystem)
|
if (isOwnSystem)
|
||||||
noNameSetMessage += " Type `pk;system name <name>` to set one.";
|
noNameSetMessage += " Type `pk;system name <name>` to set one.";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
if (target.Name != null)
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
await ctx.Reply($"```\n{target.Name}\n```");
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
else
|
if (target.Name == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noNameSetMessage);
|
await ctx.Reply(noNameSetMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"` ``\n{target.Name}\n` ``");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing name for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(target.Name, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Name != null)
|
await ctx.Reply(
|
||||||
await ctx.Reply(
|
$"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**."
|
||||||
$"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**."
|
+ (isOwnSystem ? " Type `pk;system name -clear` to clear it." : "")
|
||||||
+ (isOwnSystem ? " Type `pk;system name -clear` to clear it." : "")
|
+ $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters.");
|
||||||
+ $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters.");
|
|
||||||
else
|
|
||||||
await ctx.Reply(noNameSetMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,24 +102,35 @@ public class SystemEdit
|
||||||
|
|
||||||
var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id);
|
var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id);
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
if (settings.DisplayName != null)
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
await ctx.Reply($"```\n{settings.DisplayName}\n```");
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
else
|
if (settings.DisplayName == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noNameSetMessage);
|
await ctx.Reply(noNameSetMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"` ``\n{settings.DisplayName}\n` ``");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing servername for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(settings.DisplayName, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (settings.DisplayName != null)
|
await ctx.Reply(
|
||||||
await ctx.Reply(
|
$"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**."
|
||||||
$"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**."
|
+ (isOwnSystem ? " Type `pk;system servername -clear` to clear it." : "")
|
||||||
+ (isOwnSystem ? " Type `pk;system servername -clear` to clear it." : "")
|
+ $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters.");
|
||||||
+ $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters.");
|
|
||||||
else
|
|
||||||
await ctx.Reply(noNameSetMessage);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,28 +165,39 @@ public class SystemEdit
|
||||||
if (isOwnSystem)
|
if (isOwnSystem)
|
||||||
noDescriptionSetMessage += " To set one, type `pk;s description <description>`.";
|
noDescriptionSetMessage += " To set one, type `pk;s description <description>`.";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Description == null)
|
if (target.Description == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
await ctx.Reply(noDescriptionSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Description}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"` ``\n{target.Description}\n` ``");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing description for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(target.Description, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Description == null)
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
await ctx.Reply(noDescriptionSetMessage);
|
.Title("System description")
|
||||||
else
|
.Description(target.Description)
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
.Footer(new Embed.EmbedFooter(
|
||||||
.Title("System description")
|
"To print the description with formatting, type `pk;s description -raw`."
|
||||||
.Description(target.Description)
|
+ (isOwnSystem ? " To clear it, type `pk;s description -clear`. To change it, type `pk;s description <new description>`." : "")
|
||||||
.Footer(new Embed.EmbedFooter(
|
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
||||||
"To print the description with formatting, type `pk;s description -raw`."
|
.Build());
|
||||||
+ (isOwnSystem ? " To clear it, type `pk;s description -clear`. To change it, type `pk;s description <new description>`." : "")
|
|
||||||
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
|
|
||||||
.Build());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +224,7 @@ 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 matchedFormat = ctx.MatchFormat();
|
||||||
var matchedClear = ctx.MatchClear();
|
var matchedClear = ctx.MatchClear();
|
||||||
|
|
||||||
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
|
||||||
|
|
@ -199,8 +232,10 @@ public class SystemEdit
|
||||||
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)
|
else if (matchedFormat == ReplyFormat.Raw)
|
||||||
await ctx.Reply("```\n#" + target.Color + "\n```");
|
await ctx.Reply("```\n#" + target.Color + "\n```");
|
||||||
|
else if (matchedFormat == ReplyFormat.Plaintext)
|
||||||
|
await ctx.Reply(target.Color);
|
||||||
else
|
else
|
||||||
await ctx.Reply(embed: new EmbedBuilder()
|
await ctx.Reply(embed: new EmbedBuilder()
|
||||||
.Title("System color")
|
.Title("System color")
|
||||||
|
|
@ -246,22 +281,33 @@ public class SystemEdit
|
||||||
? "You currently have no system tag set. To set one, type `pk;s tag <tag>`."
|
? "You currently have no system tag set. To set one, type `pk;s tag <tag>`."
|
||||||
: "This system currently has no system tag set.";
|
: "This system currently has no system tag set.";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Tag == null)
|
if (target.Tag == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noTagSetMessage);
|
await ctx.Reply(noTagSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Tag}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.Tag}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing tag for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(target.Tag, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Tag == null)
|
await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current system tag is {target.Tag.AsCode()}."
|
||||||
await ctx.Reply(noTagSetMessage);
|
+ (isOwnSystem ? "To change it, type `pk;s tag <tag>`. To clear it, type `pk;s tag -clear`." : ""));
|
||||||
else
|
|
||||||
await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current system tag is {target.Tag.AsCode()}."
|
|
||||||
+ (isOwnSystem ? "To change it, type `pk;s tag <tag>`. To clear it, type `pk;s tag -clear`." : ""));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -296,15 +342,22 @@ public class SystemEdit
|
||||||
|
|
||||||
var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id);
|
var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id);
|
||||||
|
|
||||||
async Task Show(bool raw = false)
|
async Task Show(ReplyFormat format = ReplyFormat.Standard)
|
||||||
{
|
{
|
||||||
if (settings.Tag != null)
|
if (settings.Tag != null)
|
||||||
{
|
{
|
||||||
if (raw)
|
if (format == ReplyFormat.Raw)
|
||||||
{
|
{
|
||||||
await ctx.Reply($"```{settings.Tag}```");
|
await ctx.Reply($"```{settings.Tag}```");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing servertag for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(settings.Tag, embed: eb.Build());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}";
|
var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}";
|
||||||
if (!settings.TagEnabled)
|
if (!settings.TagEnabled)
|
||||||
|
|
@ -400,8 +453,8 @@ public class SystemEdit
|
||||||
await EnableDisable(false);
|
await EnableDisable(false);
|
||||||
else if (ctx.Match("enable") || ctx.MatchFlag("enable"))
|
else if (ctx.Match("enable") || ctx.MatchFlag("enable"))
|
||||||
await EnableDisable(true);
|
await EnableDisable(true);
|
||||||
else if (ctx.MatchRaw())
|
else if (ctx.MatchFormat() != ReplyFormat.Standard)
|
||||||
await Show(true);
|
await Show(ctx.MatchFormat());
|
||||||
else if (!ctx.HasNext(false))
|
else if (!ctx.HasNext(false))
|
||||||
await Show();
|
await Show();
|
||||||
else
|
else
|
||||||
|
|
@ -418,24 +471,35 @@ public class SystemEdit
|
||||||
if (isOwnSystem)
|
if (isOwnSystem)
|
||||||
noPronounsSetMessage += " To set some, type `pk;system pronouns <pronouns>`";
|
noPronounsSetMessage += " To set some, type `pk;system pronouns <pronouns>`";
|
||||||
|
|
||||||
if (ctx.MatchRaw())
|
var format = ctx.MatchFormat();
|
||||||
{
|
|
||||||
|
// if there's nothing next or what's next is "raw"/"plaintext" we're doing a query, so check for null
|
||||||
|
if (!ctx.HasNext(false) || format != ReplyFormat.Standard)
|
||||||
if (target.Pronouns == null)
|
if (target.Pronouns == null)
|
||||||
|
{
|
||||||
await ctx.Reply(noPronounsSetMessage);
|
await ctx.Reply(noPronounsSetMessage);
|
||||||
else
|
return;
|
||||||
await ctx.Reply($"```\n{target.Pronouns}\n```");
|
}
|
||||||
|
|
||||||
|
if (format == ReplyFormat.Raw)
|
||||||
|
{
|
||||||
|
await ctx.Reply($"```\n{target.Pronouns}\n```");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (format == ReplyFormat.Plaintext)
|
||||||
|
{
|
||||||
|
var eb = new EmbedBuilder()
|
||||||
|
.Description($"Showing pronouns for system {target.DisplayHid()}");
|
||||||
|
await ctx.Reply(target.Pronouns, embed: eb.Build());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.HasNext(false))
|
if (!ctx.HasNext(false))
|
||||||
{
|
{
|
||||||
if (target.Pronouns == null)
|
await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;system pronouns -raw`."
|
||||||
await ctx.Reply(noPronounsSetMessage);
|
+ (isOwnSystem ? " To clear them, type `pk;system pronouns -clear`."
|
||||||
else
|
: "")
|
||||||
await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;system pronouns -raw`."
|
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
|
||||||
+ (isOwnSystem ? " To clear them, type `pk;system pronouns -clear`."
|
|
||||||
: "")
|
|
||||||
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public class SystemList
|
||||||
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
|
// - ParseListOptions checks list access privacy and sets the privacy filter (which members show up in list)
|
||||||
// - RenderMemberList checks the indivual privacy for each member (NameFor, etc)
|
// - RenderMemberList checks the indivual privacy for each member (NameFor, etc)
|
||||||
// the own system is always allowed to look up their list
|
// the own system is always allowed to look up their list
|
||||||
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id));
|
var opts = ctx.ParseListOptions(ctx.DirectLookupContextFor(target.Id), ctx.LookupContextFor(target.Id));
|
||||||
await ctx.RenderMemberList(
|
await ctx.RenderMemberList(
|
||||||
ctx.LookupContextFor(target.Id),
|
ctx.LookupContextFor(target.Id),
|
||||||
target.Id,
|
target.Id,
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ public interface IEventHandler<in T> where T : IGatewayEvent
|
||||||
{
|
{
|
||||||
Task Handle(int shardId, T evt);
|
Task Handle(int shardId, T evt);
|
||||||
|
|
||||||
ulong? ErrorChannelFor(T evt, ulong userId) => null;
|
(ulong?, ulong?) ErrorChannelFor(T evt, ulong userId) => (null, null);
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
_dmCache = dmCache;
|
_dmCache = dmCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ulong? ErrorChannelFor(MessageCreateEvent evt, ulong userId) => evt.ChannelId;
|
public (ulong?, ulong?) ErrorChannelFor(MessageCreateEvent evt, ulong userId) => (evt.GuildId, evt.ChannelId);
|
||||||
private bool IsDuplicateMessage(Message msg) =>
|
private bool IsDuplicateMessage(Message msg) =>
|
||||||
// We consider a message duplicate if it has the same ID as the previous message that hit the gateway
|
// We consider a message duplicate if it has the same ID as the previous message that hit the gateway
|
||||||
_lastMessageCache.GetLastMessage(msg.ChannelId)?.Current.Id == msg.Id;
|
_lastMessageCache.GetLastMessage(msg.ChannelId)?.Current.Id == msg.Id;
|
||||||
|
|
@ -63,7 +63,7 @@ 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;
|
||||||
|
|
||||||
var botPermissions = await _cache.BotPermissionsIn(evt.ChannelId);
|
var botPermissions = await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId);
|
||||||
if (!botPermissions.HasFlag(PermissionSet.SendMessages)) return;
|
if (!botPermissions.HasFlag(PermissionSet.SendMessages)) return;
|
||||||
|
|
||||||
// spawn off saving the private channel into another thread
|
// spawn off saving the private channel into another thread
|
||||||
|
|
@ -71,8 +71,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
_ = _dmCache.TrySavePrivateChannel(evt);
|
_ = _dmCache.TrySavePrivateChannel(evt);
|
||||||
|
|
||||||
var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null;
|
var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null;
|
||||||
var channel = await _cache.GetChannel(evt.ChannelId);
|
var channel = await _cache.GetChannel(evt.GuildId ?? 0, evt.ChannelId);
|
||||||
var rootChannel = await _cache.GetRootChannel(evt.ChannelId);
|
var rootChannel = await _cache.GetRootChannel(evt.GuildId ?? 0, evt.ChannelId);
|
||||||
|
|
||||||
// Log metrics and message info
|
// Log metrics and message info
|
||||||
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
|
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
|
||||||
|
|
@ -90,7 +90,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
|
||||||
if (await TryHandleCommand(shardId, evt, guild, channel))
|
if (await TryHandleCommand(shardId, evt, guild, channel))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await TryHandleProxy(evt, guild, channel, rootChannel.Id, botPermissions);
|
if (evt.GuildId != null)
|
||||||
|
await TryHandleProxy(evt, guild, channel, rootChannel.Id, botPermissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TryHandleLogClean(Channel channel, MessageCreateEvent evt)
|
private async Task TryHandleLogClean(Channel channel, MessageCreateEvent evt)
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,12 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
|
||||||
if (!evt.Content.HasValue || !evt.Author.HasValue || !evt.Member.HasValue)
|
if (!evt.Content.HasValue || !evt.Author.HasValue || !evt.Member.HasValue)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var channel = await _cache.GetChannel(evt.ChannelId);
|
var guildIdMaybe = evt.GuildId.HasValue ? evt.GuildId.Value ?? 0 : 0;
|
||||||
|
|
||||||
|
var channel = await _cache.GetChannel(guildIdMaybe, evt.ChannelId); // todo: is this correct for message update?
|
||||||
if (!DiscordUtils.IsValidGuildChannel(channel))
|
if (!DiscordUtils.IsValidGuildChannel(channel))
|
||||||
return;
|
return;
|
||||||
var rootChannel = await _cache.GetRootChannel(channel.Id);
|
var rootChannel = await _cache.GetRootChannel(guildIdMaybe, channel.Id);
|
||||||
var guild = await _cache.GetGuild(channel.GuildId!.Value);
|
var guild = await _cache.GetGuild(channel.GuildId!.Value);
|
||||||
var lastMessage = _lastMessageCache.GetLastMessage(evt.ChannelId)?.Current;
|
var lastMessage = _lastMessageCache.GetLastMessage(evt.ChannelId)?.Current;
|
||||||
|
|
||||||
|
|
@ -69,7 +71,7 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
|
||||||
ctx = await _repo.GetMessageContext(evt.Author.Value!.Id, channel.GuildId!.Value, rootChannel.Id, evt.ChannelId);
|
ctx = await _repo.GetMessageContext(evt.Author.Value!.Id, channel.GuildId!.Value, rootChannel.Id, evt.ChannelId);
|
||||||
|
|
||||||
var equivalentEvt = await GetMessageCreateEvent(evt, lastMessage, channel);
|
var equivalentEvt = await GetMessageCreateEvent(evt, lastMessage, channel);
|
||||||
var botPermissions = await _cache.BotPermissionsIn(channel.Id);
|
var botPermissions = await _cache.BotPermissionsIn(guildIdMaybe, channel.Id);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -91,7 +93,7 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
|
||||||
private async Task<MessageCreateEvent> GetMessageCreateEvent(MessageUpdateEvent evt, CachedMessage lastMessage,
|
private async Task<MessageCreateEvent> GetMessageCreateEvent(MessageUpdateEvent evt, CachedMessage lastMessage,
|
||||||
Channel channel)
|
Channel channel)
|
||||||
{
|
{
|
||||||
var referencedMessage = await GetReferencedMessage(evt.ChannelId, lastMessage.ReferencedMessage);
|
var referencedMessage = await GetReferencedMessage(evt.GuildId.HasValue ? evt.GuildId.Value ?? 0 : 0, evt.ChannelId, lastMessage.ReferencedMessage);
|
||||||
|
|
||||||
var messageReference = lastMessage.ReferencedMessage != null
|
var messageReference = lastMessage.ReferencedMessage != null
|
||||||
? new Message.Reference(channel.GuildId, evt.ChannelId, lastMessage.ReferencedMessage.Value)
|
? new Message.Reference(channel.GuildId, evt.ChannelId, lastMessage.ReferencedMessage.Value)
|
||||||
|
|
@ -118,12 +120,12 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
|
||||||
return equivalentEvt;
|
return equivalentEvt;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Message?> GetReferencedMessage(ulong channelId, ulong? referencedMessageId)
|
private async Task<Message?> GetReferencedMessage(ulong guildId, ulong channelId, ulong? referencedMessageId)
|
||||||
{
|
{
|
||||||
if (referencedMessageId == null)
|
if (referencedMessageId == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var botPermissions = await _cache.BotPermissionsIn(channelId);
|
var botPermissions = await _cache.BotPermissionsIn(guildId, channelId);
|
||||||
if (!botPermissions.HasFlag(PermissionSet.ReadMessageHistory))
|
if (!botPermissions.HasFlag(PermissionSet.ReadMessageHistory))
|
||||||
{
|
{
|
||||||
_logger.Warning(
|
_logger.Warning(
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
// but we aren't able to get DMs from bots anyway, so it's not really needed
|
// but we aren't able to get DMs from bots anyway, so it's not really needed
|
||||||
if (evt.GuildId != null && (evt.Member?.User?.Bot ?? false)) return;
|
if (evt.GuildId != null && (evt.Member?.User?.Bot ?? false)) return;
|
||||||
|
|
||||||
var channel = await _cache.GetChannel(evt.ChannelId);
|
var channel = await _cache.GetChannel(evt.GuildId ?? 0, evt.ChannelId);
|
||||||
|
|
||||||
// check if it's a command message first
|
// check if it's a command message first
|
||||||
// since this can happen in DMs as well
|
// since this can happen in DMs as well
|
||||||
|
|
@ -75,10 +75,10 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var (authorId, _) = await _commandMessageService.GetCommandMessage(evt.MessageId);
|
var cmessage = await _commandMessageService.GetCommandMessage(evt.MessageId);
|
||||||
if (authorId != null)
|
if (cmessage != null)
|
||||||
{
|
{
|
||||||
await HandleCommandDeleteReaction(evt, authorId.Value, false);
|
await HandleCommandDeleteReaction(evt, cmessage.AuthorId, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +123,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
|
|
||||||
private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, PKMessage msg)
|
private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, PKMessage msg)
|
||||||
{
|
{
|
||||||
if (!(await _cache.BotPermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
if (!(await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var isSameSystem = msg.Member != null && await _repo.IsMemberOwnedByAccount(msg.Member.Value, evt.UserId);
|
var isSameSystem = msg.Member != null && await _repo.IsMemberOwnedByAccount(msg.Member.Value, evt.UserId);
|
||||||
|
|
@ -150,7 +150,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
if (authorId != null && authorId != evt.UserId)
|
if (authorId != null && authorId != evt.UserId)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (!((await _cache.BotPermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
|
if (!((await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
|
||||||
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
|
||||||
|
|
@ -206,14 +206,14 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
|
|
||||||
private async ValueTask HandlePingReaction(MessageReactionAddEvent evt, FullMessage msg)
|
private async ValueTask HandlePingReaction(MessageReactionAddEvent evt, FullMessage msg)
|
||||||
{
|
{
|
||||||
if (!(await _cache.BotPermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
if (!(await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Check if the "pinger" has permission to send messages in this channel
|
// Check if the "pinger" has permission to send messages in this channel
|
||||||
// (if not, PK shouldn't send messages on their behalf)
|
// (if not, PK shouldn't send messages on their behalf)
|
||||||
var member = await _rest.GetGuildMember(evt.GuildId!.Value, evt.UserId);
|
var member = await _rest.GetGuildMember(evt.GuildId!.Value, evt.UserId);
|
||||||
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
|
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
|
||||||
if (member == null || !(await _cache.PermissionsForMemberInChannel(evt.ChannelId, member)).HasFlag(requiredPerms)) return;
|
if (member == null || !(await _cache.PermissionsForMemberInChannel(evt.GuildId ?? 0, evt.ChannelId, member)).HasFlag(requiredPerms)) return;
|
||||||
|
|
||||||
if (msg.Member == null) return;
|
if (msg.Member == null) return;
|
||||||
|
|
||||||
|
|
@ -266,7 +266,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
|
||||||
|
|
||||||
private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt)
|
private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt)
|
||||||
{
|
{
|
||||||
if ((await _cache.BotPermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
if ((await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
|
||||||
await _rest.DeleteUserReaction(evt.ChannelId, evt.MessageId, evt.Emoji, evt.UserId);
|
await _rest.DeleteUserReaction(evt.ChannelId, evt.MessageId, evt.Emoji, evt.UserId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -42,10 +42,10 @@ public class Init
|
||||||
|
|
||||||
using var _ = SentrySdk.Init(opts =>
|
using var _ = SentrySdk.Init(opts =>
|
||||||
{
|
{
|
||||||
opts.Dsn = services.Resolve<CoreConfig>().SentryUrl;
|
opts.Dsn = services.Resolve<CoreConfig>().SentryUrl ?? "";
|
||||||
opts.Release = BuildInfoService.FullVersion;
|
opts.Release = BuildInfoService.FullVersion;
|
||||||
opts.AutoSessionTracking = true;
|
opts.AutoSessionTracking = true;
|
||||||
opts.DisableTaskUnobservedTaskExceptionCapture();
|
// opts.DisableTaskUnobservedTaskExceptionCapture();
|
||||||
});
|
});
|
||||||
|
|
||||||
var config = services.Resolve<BotConfig>();
|
var config = services.Resolve<BotConfig>();
|
||||||
|
|
@ -56,8 +56,6 @@ public class Init
|
||||||
await redis.InitAsync(coreConfig);
|
await redis.InitAsync(coreConfig);
|
||||||
|
|
||||||
var cache = services.Resolve<IDiscordCache>();
|
var cache = services.Resolve<IDiscordCache>();
|
||||||
if (cache is RedisDiscordCache)
|
|
||||||
await (cache as RedisDiscordCache).InitAsync(coreConfig.RedisAddr);
|
|
||||||
|
|
||||||
if (config.Cluster == null)
|
if (config.Cluster == null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -84,20 +84,32 @@ public class YesNoPrompt: BaseInteractive
|
||||||
|
|
||||||
var queue = _ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>();
|
var queue = _ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>();
|
||||||
|
|
||||||
var messageDispatch = queue.WaitFor(MessagePredicate, Timeout, cts.Token);
|
async Task WaitForMessage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await queue.WaitFor(MessagePredicate, Timeout, cts.Token);
|
||||||
|
}
|
||||||
|
catch (TimeoutException e)
|
||||||
|
{
|
||||||
|
if (e.Message != "HandlerQueue#WaitFor timed out")
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await Start();
|
await Start();
|
||||||
|
|
||||||
cts.Token.Register(() => _tcs.TrySetException(new TimeoutException("Action timed out")));
|
var messageDispatch = WaitForMessage();
|
||||||
|
|
||||||
|
cts.Token.Register(() => _tcs.TrySetException(new TimeoutException("YesNoPrompt timed out")));
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var doneTask = await Task.WhenAny(_tcs.Task, messageDispatch);
|
var doneTask = await Task.WhenAny(_tcs.Task, messageDispatch);
|
||||||
if (doneTask == messageDispatch)
|
|
||||||
await Finish();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
await Finish();
|
||||||
Cleanup();
|
Cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,25 @@ public class BotModule: Module
|
||||||
{
|
{
|
||||||
var botConfig = c.Resolve<BotConfig>();
|
var botConfig = c.Resolve<BotConfig>();
|
||||||
|
|
||||||
if (botConfig.UseRedisCache)
|
if (botConfig.HttpCacheUrl != null)
|
||||||
return new RedisDiscordCache(c.Resolve<ILogger>(), botConfig.ClientId);
|
{
|
||||||
|
var cache = new HttpDiscordCache(c.Resolve<ILogger>(),
|
||||||
|
c.Resolve<HttpClient>(), botConfig.HttpCacheUrl, botConfig.Cluster?.TotalShards ?? 1, botConfig.ClientId, botConfig.HttpUseInnerCache);
|
||||||
|
|
||||||
|
var metrics = c.Resolve<IMetrics>();
|
||||||
|
|
||||||
|
cache.OnDebug += (_, ev) =>
|
||||||
|
{
|
||||||
|
var (remote, key) = ev;
|
||||||
|
metrics.Measure.Meter.Mark(BotMetrics.CacheDebug, new MetricTags(
|
||||||
|
new[] { "remote", "key" },
|
||||||
|
new[] { remote.ToString(), key }
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
return new MemoryDiscordCache(botConfig.ClientId);
|
return new MemoryDiscordCache(botConfig.ClientId);
|
||||||
}).AsSelf().SingleInstance();
|
}).AsSelf().SingleInstance();
|
||||||
builder.RegisterType<PrivateChannelService>().AsSelf().SingleInstance();
|
builder.RegisterType<PrivateChannelService>().AsSelf().SingleInstance();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.0" />
|
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.0" />
|
||||||
<PackageReference Include="Grpc.Tools" Version="2.47.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="4.12.1" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ public class ProxyService
|
||||||
public async Task<bool> HandleIncomingMessage(MessageCreateEvent message, MessageContext ctx,
|
public async Task<bool> HandleIncomingMessage(MessageCreateEvent message, MessageContext ctx,
|
||||||
Guild guild, Channel channel, bool allowAutoproxy, PermissionSet botPermissions)
|
Guild guild, Channel channel, bool allowAutoproxy, PermissionSet botPermissions)
|
||||||
{
|
{
|
||||||
var rootChannel = await _cache.GetRootChannel(message.ChannelId);
|
var rootChannel = await _cache.GetRootChannel(message.GuildId!.Value, message.ChannelId);
|
||||||
|
|
||||||
if (!ShouldProxy(channel, rootChannel, message, ctx))
|
if (!ShouldProxy(channel, rootChannel, message, ctx))
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -111,31 +111,10 @@ public class ProxyService
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#pragma warning disable CA1822 // Mark members as static
|
// Proxy checks that give user errors
|
||||||
internal bool CanProxyInChannel(Channel ch, bool isRootChannel = false)
|
|
||||||
#pragma warning restore CA1822 // Mark members as static
|
|
||||||
{
|
|
||||||
// this is explicitly selecting known channel types so that when Discord add new
|
|
||||||
// ones, users don't get flooded with error codes if that new channel type doesn't
|
|
||||||
// support a feature we need for proxying
|
|
||||||
return ch.Type switch
|
|
||||||
{
|
|
||||||
Channel.ChannelType.GuildText => true,
|
|
||||||
Channel.ChannelType.GuildPublicThread => true,
|
|
||||||
Channel.ChannelType.GuildPrivateThread => true,
|
|
||||||
Channel.ChannelType.GuildNews => true,
|
|
||||||
Channel.ChannelType.GuildNewsThread => true,
|
|
||||||
Channel.ChannelType.GuildVoice => true,
|
|
||||||
Channel.ChannelType.GuildStageVoice => true,
|
|
||||||
Channel.ChannelType.GuildForum => isRootChannel,
|
|
||||||
Channel.ChannelType.GuildMedia => isRootChannel,
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> CanProxy(Channel channel, Channel rootChannel, Message msg, MessageContext ctx)
|
public async Task<string> CanProxy(Channel channel, Channel rootChannel, Message msg, MessageContext ctx)
|
||||||
{
|
{
|
||||||
if (!(CanProxyInChannel(channel) && CanProxyInChannel(rootChannel, true)))
|
if (!DiscordUtils.IsValidGuildChannel(channel))
|
||||||
return $"PluralKit cannot proxy messages in this type of channel.";
|
return $"PluralKit cannot proxy messages in this type of channel.";
|
||||||
|
|
||||||
// Check if the message does not go over any Discord Nitro limits
|
// Check if the message does not go over any Discord Nitro limits
|
||||||
|
|
@ -159,6 +138,7 @@ public class ProxyService
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proxy checks that don't give user errors unless `pk;debug proxy` is used
|
||||||
public bool ShouldProxy(Channel channel, Channel rootChannel, Message msg, MessageContext ctx)
|
public bool ShouldProxy(Channel channel, Channel rootChannel, Message msg, MessageContext ctx)
|
||||||
{
|
{
|
||||||
// Make sure author has a system
|
// Make sure author has a system
|
||||||
|
|
@ -189,9 +169,9 @@ public class ProxyService
|
||||||
throw new ProxyChecksFailedException(
|
throw new ProxyChecksFailedException(
|
||||||
"Your system has proxying disabled in this server. Type `pk;proxy on` to enable it.");
|
"Your system has proxying disabled in this server. Type `pk;proxy on` to enable it.");
|
||||||
|
|
||||||
// Make sure we have either an attachment or message content
|
// Make sure we have an attachment, message content, or poll
|
||||||
var isMessageBlank = msg.Content == null || msg.Content.Trim().Length == 0;
|
var isMessageBlank = msg.Content == null || msg.Content.Trim().Length == 0;
|
||||||
if (isMessageBlank && msg.Attachments.Length == 0)
|
if (isMessageBlank && msg.Attachments.Length == 0 && msg.Poll == null)
|
||||||
throw new ProxyChecksFailedException("Message cannot be blank.");
|
throw new ProxyChecksFailedException("Message cannot be blank.");
|
||||||
|
|
||||||
if (msg.Activity != null)
|
if (msg.Activity != null)
|
||||||
|
|
@ -227,8 +207,8 @@ public class ProxyService
|
||||||
var content = match.ProxyContent;
|
var content = match.ProxyContent;
|
||||||
if (!allowEmbeds) content = content.BreakLinkEmbeds();
|
if (!allowEmbeds) content = content.BreakLinkEmbeds();
|
||||||
|
|
||||||
var messageChannel = await _cache.GetChannel(trigger.ChannelId);
|
var messageChannel = await _cache.GetChannel(trigger.GuildId!.Value, trigger.ChannelId);
|
||||||
var rootChannel = await _cache.GetRootChannel(trigger.ChannelId);
|
var rootChannel = await _cache.GetRootChannel(trigger.GuildId!.Value, trigger.ChannelId);
|
||||||
var threadId = messageChannel.IsThread() ? messageChannel.Id : (ulong?)null;
|
var threadId = messageChannel.IsThread() ? messageChannel.Id : (ulong?)null;
|
||||||
var guild = await _cache.GetGuild(trigger.GuildId.Value);
|
var guild = await _cache.GetGuild(trigger.GuildId.Value);
|
||||||
var guildMember = await _rest.GetGuildMember(trigger.GuildId!.Value, trigger.Author.Id);
|
var guildMember = await _rest.GetGuildMember(trigger.GuildId!.Value, trigger.Author.Id);
|
||||||
|
|
@ -242,6 +222,7 @@ public class ProxyService
|
||||||
GuildId = trigger.GuildId!.Value,
|
GuildId = trigger.GuildId!.Value,
|
||||||
ChannelId = rootChannel.Id,
|
ChannelId = rootChannel.Id,
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
|
MessageId = trigger.Id,
|
||||||
Name = await FixSameName(messageChannel.Id, ctx, match.Member),
|
Name = await FixSameName(messageChannel.Id, ctx, match.Member),
|
||||||
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
|
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
|
||||||
Content = content,
|
Content = content,
|
||||||
|
|
@ -252,6 +233,7 @@ public class ProxyService
|
||||||
AllowEveryone = allowEveryone,
|
AllowEveryone = allowEveryone,
|
||||||
Flags = trigger.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
|
Flags = trigger.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
|
||||||
Tts = tts,
|
Tts = tts,
|
||||||
|
Poll = trigger.Poll,
|
||||||
});
|
});
|
||||||
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
|
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
|
||||||
}
|
}
|
||||||
|
|
@ -310,6 +292,7 @@ public class ProxyService
|
||||||
GuildId = guild.Id,
|
GuildId = guild.Id,
|
||||||
ChannelId = rootChannel.Id,
|
ChannelId = rootChannel.Id,
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
|
MessageId = originalMsg.Id,
|
||||||
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 = match.ProxyContent!,
|
Content = match.ProxyContent!,
|
||||||
|
|
@ -320,6 +303,7 @@ public class ProxyService
|
||||||
AllowEveryone = allowEveryone,
|
AllowEveryone = allowEveryone,
|
||||||
Flags = originalMsg.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
|
Flags = originalMsg.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
|
||||||
Tts = tts,
|
Tts = tts,
|
||||||
|
Poll = originalMsg.Poll,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,35 @@ public class AvatarHostingService
|
||||||
private readonly BotConfig _config;
|
private readonly BotConfig _config;
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
public AvatarHostingService(BotConfig config, HttpClient client)
|
public AvatarHostingService(BotConfig config)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_client = client;
|
_client = new HttpClient
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
|
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
|
||||||
{
|
{
|
||||||
var uploaded = await TryUploadAvatar(input.Url, type, userId, system);
|
try
|
||||||
if (uploaded != null)
|
|
||||||
{
|
{
|
||||||
// todo: make new image type called Cdn?
|
var uploaded = await TryUploadAvatar(input.Url, type, userId, system);
|
||||||
return new ParsedImage { Url = uploaded, Source = AvatarSource.HostedCdn };
|
if (uploaded != null)
|
||||||
}
|
{
|
||||||
|
// todo: make new image type called Cdn?
|
||||||
|
return new ParsedImage { Url = uploaded, Source = AvatarSource.HostedCdn };
|
||||||
|
}
|
||||||
|
|
||||||
return input;
|
return input;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException e)
|
||||||
|
{
|
||||||
|
// don't show an internal error to users
|
||||||
|
if (e.Message.Contains("HttpClient.Timeout"))
|
||||||
|
throw new PKError("Temporary error setting image, please try again later");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> TryUploadAvatar(string? avatarUrl, RehostedImageType type, ulong userId, PKSystem? system)
|
public async Task<string?> TryUploadAvatar(string? avatarUrl, RehostedImageType type, ulong userId, PKSystem? system)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ public class CommandMessageService
|
||||||
_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 guildId, ulong channelId, ulong authorId)
|
||||||
{
|
{
|
||||||
if (_redis.Connection == null) return;
|
if (_redis.Connection == null) return;
|
||||||
|
|
||||||
|
|
@ -27,17 +27,19 @@ public class CommandMessageService
|
||||||
messageId, authorId, channelId
|
messageId, authorId, channelId
|
||||||
);
|
);
|
||||||
|
|
||||||
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}", expiry: CommandMessageRetention);
|
await _redis.Connection.GetDatabase().StringSetAsync(messageId.ToString(), $"{authorId}-{channelId}-{guildId}", expiry: CommandMessageRetention);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(ulong?, ulong?)> GetCommandMessage(ulong messageId)
|
public async Task<CommandMessage?> GetCommandMessage(ulong messageId)
|
||||||
{
|
{
|
||||||
var str = await _redis.Connection.GetDatabase().StringGetAsync(messageId.ToString());
|
var str = await _redis.Connection.GetDatabase().StringGetAsync(messageId.ToString());
|
||||||
if (str.HasValue)
|
if (str.HasValue)
|
||||||
{
|
{
|
||||||
var split = ((string)str).Split("-");
|
var split = ((string)str).Split("-");
|
||||||
return (ulong.Parse(split[0]), ulong.Parse(split[1]));
|
return new CommandMessage(ulong.Parse(split[0]), ulong.Parse(split[1]), ulong.Parse(split[2]));
|
||||||
}
|
}
|
||||||
return (null, null);
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record CommandMessage(ulong AuthorId, ulong ChannelId, ulong GuildId);
|
||||||
|
|
@ -263,7 +263,7 @@ public class EmbedService
|
||||||
else if (system.NameFor(ctx) != null)
|
else if (system.NameFor(ctx) != null)
|
||||||
nameField = $"{nameField} ({system.NameFor(ctx)})";
|
nameField = $"{nameField} ({system.NameFor(ctx)})";
|
||||||
else
|
else
|
||||||
nameField = $"{nameField} ({system.Name})";
|
nameField = $"{nameField}";
|
||||||
|
|
||||||
var eb = new EmbedBuilder()
|
var eb = new EmbedBuilder()
|
||||||
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
|
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
|
||||||
|
|
@ -336,7 +336,7 @@ public class EmbedService
|
||||||
|
|
||||||
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
|
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent, SystemConfig? ccfg = null)
|
||||||
{
|
{
|
||||||
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Channel);
|
var channel = await _cache.GetOrFetchChannel(_rest, msg.Message.Guild ?? 0, msg.Message.Channel);
|
||||||
var ctx = LookupContext.ByNonOwner;
|
var ctx = LookupContext.ByNonOwner;
|
||||||
|
|
||||||
var serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
|
var serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
|
||||||
|
|
@ -403,14 +403,15 @@ public class EmbedService
|
||||||
var roles = memberInfo?.Roles?.ToList();
|
var roles = memberInfo?.Roles?.ToList();
|
||||||
if (roles != null && roles.Count > 0 && showContent)
|
if (roles != null && roles.Count > 0 && showContent)
|
||||||
{
|
{
|
||||||
var rolesString = string.Join(", ", (await Task.WhenAll(roles
|
var guild = await _cache.GetGuild(channel.GuildId!.Value);
|
||||||
.Select(async id =>
|
var rolesString = string.Join(", ", (roles
|
||||||
|
.Select(id =>
|
||||||
{
|
{
|
||||||
var role = await _cache.TryGetRole(id);
|
var role = Array.Find(guild.Roles, r => r.Id == id);
|
||||||
if (role != null)
|
if (role != null)
|
||||||
return role;
|
return role;
|
||||||
return new Role { Name = "*(unknown role)*", Position = 0 };
|
return new Role { Name = "*(unknown role)*", Position = 0 };
|
||||||
})))
|
}))
|
||||||
.OrderByDescending(role => role.Position)
|
.OrderByDescending(role => role.Position)
|
||||||
.Select(role => role.Name));
|
.Select(role => role.Name));
|
||||||
eb.Field(new Embed.Field($"Account roles ({roles.Count})", rolesString.Truncate(1024)));
|
eb.Field(new Embed.Field($"Account roles ({roles.Count})", rolesString.Truncate(1024)));
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public class LogChannelService
|
||||||
if (logChannelId == null)
|
if (logChannelId == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var triggerChannel = await _cache.GetChannel(proxiedMessage.Channel);
|
var triggerChannel = await _cache.GetChannel(proxiedMessage.Guild!.Value, proxiedMessage.Channel);
|
||||||
|
|
||||||
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
|
var member = await _repo.GetMember(proxiedMessage.Member!.Value);
|
||||||
var system = await _repo.GetSystem(member.System);
|
var system = await _repo.GetSystem(member.System);
|
||||||
|
|
@ -63,7 +63,7 @@ public class LogChannelService
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
|
var guildId = proxiedMessage.Guild ?? trigger.GuildId.Value;
|
||||||
var rootChannel = await _cache.GetRootChannel(trigger.ChannelId);
|
var rootChannel = await _cache.GetRootChannel(guildId, trigger.ChannelId);
|
||||||
|
|
||||||
// get log channel info from the database
|
// get log channel info from the database
|
||||||
var guild = await _repo.GetGuild(guildId);
|
var guild = await _repo.GetGuild(guildId);
|
||||||
|
|
@ -109,7 +109,7 @@ public class LogChannelService
|
||||||
private async Task<Channel?> FindLogChannel(ulong guildId, ulong channelId)
|
private async Task<Channel?> FindLogChannel(ulong guildId, ulong channelId)
|
||||||
{
|
{
|
||||||
// TODO: fetch it directly on cache miss?
|
// TODO: fetch it directly on cache miss?
|
||||||
if (await _cache.TryGetChannel(channelId) is Channel channel)
|
if (await _cache.TryGetChannel(guildId, channelId) is Channel channel)
|
||||||
return channel;
|
return channel;
|
||||||
|
|
||||||
if (await _rest.GetChannelOrNull(channelId) is Channel restChannel)
|
if (await _rest.GetChannelOrNull(channelId) is Channel restChannel)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ public class LoggerCleanService
|
||||||
private static readonly Regex _basicRegex = new("(\\d{17,19})");
|
private static readonly Regex _basicRegex = new("(\\d{17,19})");
|
||||||
private static readonly Regex _dynoRegex = new("Message ID: (\\d{17,19})");
|
private static readonly Regex _dynoRegex = new("Message ID: (\\d{17,19})");
|
||||||
private static readonly Regex _carlRegex = new("Message ID: (\\d{17,19})");
|
private static readonly Regex _carlRegex = new("Message ID: (\\d{17,19})");
|
||||||
|
private static readonly Regex _makiRegex = new("Message ID: (\\d{17,19})");
|
||||||
private static readonly Regex _circleRegex = new("\\(`(\\d{17,19})`\\)");
|
private static readonly Regex _circleRegex = new("\\(`(\\d{17,19})`\\)");
|
||||||
private static readonly Regex _loggerARegex = new("Message = (\\d{17,19})");
|
private static readonly Regex _loggerARegex = new("Message = (\\d{17,19})");
|
||||||
private static readonly Regex _loggerBRegex = new("MessageID:(\\d{17,19})");
|
private static readonly Regex _loggerBRegex = new("MessageID:(\\d{17,19})");
|
||||||
|
|
@ -60,6 +61,7 @@ public class LoggerCleanService
|
||||||
new LoggerBot("Dyno#8389", 470724017205149701, ExtractDyno), // webhook
|
new LoggerBot("Dyno#8389", 470724017205149701, ExtractDyno), // webhook
|
||||||
new LoggerBot("Dyno#5714", 470723870270160917, ExtractDyno), // webhook
|
new LoggerBot("Dyno#5714", 470723870270160917, ExtractDyno), // webhook
|
||||||
new LoggerBot("Dyno#1961", 347378323418251264, ExtractDyno), // webhook
|
new LoggerBot("Dyno#1961", 347378323418251264, ExtractDyno), // webhook
|
||||||
|
new LoggerBot("Maki", 563434444321587202, ExtractMaki), // webhook
|
||||||
new LoggerBot("Auttaja", 242730576195354624, ExtractAuttaja), // webhook
|
new LoggerBot("Auttaja", 242730576195354624, ExtractAuttaja), // webhook
|
||||||
new LoggerBot("GenericBot", 295329346590343168, ExtractGenericBot),
|
new LoggerBot("GenericBot", 295329346590343168, ExtractGenericBot),
|
||||||
new LoggerBot("blargbot", 134133271750639616, ExtractBlargBot),
|
new LoggerBot("blargbot", 134133271750639616, ExtractBlargBot),
|
||||||
|
|
@ -98,10 +100,10 @@ public class LoggerCleanService
|
||||||
|
|
||||||
public async ValueTask HandleLoggerBotCleanup(Message msg)
|
public async ValueTask HandleLoggerBotCleanup(Message msg)
|
||||||
{
|
{
|
||||||
var channel = await _cache.GetChannel(msg.ChannelId);
|
var channel = await _cache.GetChannel(msg.GuildId!.Value, msg.ChannelId!);
|
||||||
|
|
||||||
if (channel.Type != Channel.ChannelType.GuildText) return;
|
if (channel.Type != Channel.ChannelType.GuildText) return;
|
||||||
if (!(await _cache.BotPermissionsIn(channel.Id)).HasFlag(PermissionSet.ManageMessages)) return;
|
if (!(await _cache.BotPermissionsIn(msg.GuildId!.Value, channel.Id)).HasFlag(PermissionSet.ManageMessages)) return;
|
||||||
|
|
||||||
// If this message is from a *webhook*, check if the application ID matches one of the bots we know
|
// If this message is from a *webhook*, check if the application ID matches one of the bots we know
|
||||||
// If it's from a *bot*, check the bot ID to see if we know it.
|
// If it's from a *bot*, check the bot ID to see if we know it.
|
||||||
|
|
@ -231,6 +233,15 @@ public class LoggerCleanService
|
||||||
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
|
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ulong? ExtractMaki(Message msg)
|
||||||
|
{
|
||||||
|
// Embed, Message Author Name field: "Message Deleted", footer is "Message ID: [id]"
|
||||||
|
var embed = msg.Embeds?.FirstOrDefault();
|
||||||
|
if (embed.Author?.Name == null || embed?.Footer == null || !embed.Author.Name.StartsWith("Message Deleted")) return null;
|
||||||
|
var match = _makiRegex.Match(embed.Footer.Text ?? "");
|
||||||
|
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private static FuzzyExtractResult? ExtractCircle(Message msg)
|
private static FuzzyExtractResult? ExtractCircle(Message msg)
|
||||||
{
|
{
|
||||||
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.
|
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.
|
||||||
|
|
|
||||||
|
|
@ -54,33 +54,6 @@ public class PeriodicStatCollector
|
||||||
var stopwatch = new Stopwatch();
|
var stopwatch = new Stopwatch();
|
||||||
stopwatch.Start();
|
stopwatch.Start();
|
||||||
|
|
||||||
// Aggregate guild/channel stats
|
|
||||||
var guildCount = 0;
|
|
||||||
var channelCount = 0;
|
|
||||||
|
|
||||||
// No LINQ today, sorry
|
|
||||||
await foreach (var guild in _cache.GetAllGuilds())
|
|
||||||
{
|
|
||||||
guildCount++;
|
|
||||||
foreach (var channel in await _cache.GetGuildChannels(guild.Id))
|
|
||||||
if (DiscordUtils.IsValidGuildChannel(channel))
|
|
||||||
channelCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_config.UseRedisMetrics)
|
|
||||||
{
|
|
||||||
var db = _redis.Connection.GetDatabase();
|
|
||||||
await db.HashSetAsync("pluralkit:cluster_stats", new StackExchange.Redis.HashEntry[] {
|
|
||||||
new(_botConfig.Cluster.NodeIndex, JsonConvert.SerializeObject(new ClusterMetricInfo
|
|
||||||
{
|
|
||||||
GuildCount = guildCount,
|
|
||||||
ChannelCount = channelCount,
|
|
||||||
DatabaseConnectionCount = _countHolder.ConnectionCount,
|
|
||||||
WebhookCacheSize = _webhookCache.CacheSize,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process info
|
// Process info
|
||||||
var process = Process.GetCurrentProcess();
|
var process = Process.GetCurrentProcess();
|
||||||
_metrics.Measure.Gauge.SetValue(CoreMetrics.ProcessPhysicalMemory, process.WorkingSet64);
|
_metrics.Measure.Gauge.SetValue(CoreMetrics.ProcessPhysicalMemory, process.WorkingSet64);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ using App.Metrics;
|
||||||
|
|
||||||
using Humanizer;
|
using Humanizer;
|
||||||
|
|
||||||
|
using NodaTime.Text;
|
||||||
|
|
||||||
using Myriad.Cache;
|
using Myriad.Cache;
|
||||||
using Myriad.Extensions;
|
using Myriad.Extensions;
|
||||||
using Myriad.Rest;
|
using Myriad.Rest;
|
||||||
|
|
@ -35,6 +37,7 @@ public record ProxyRequest
|
||||||
public ulong GuildId { get; init; }
|
public ulong GuildId { get; init; }
|
||||||
public ulong ChannelId { get; init; }
|
public ulong ChannelId { get; init; }
|
||||||
public ulong? ThreadId { get; init; }
|
public ulong? ThreadId { get; init; }
|
||||||
|
public ulong MessageId { get; init; }
|
||||||
public string Name { get; init; }
|
public string Name { get; init; }
|
||||||
public string? AvatarUrl { get; init; }
|
public string? AvatarUrl { get; init; }
|
||||||
public string? Content { get; init; }
|
public string? Content { get; init; }
|
||||||
|
|
@ -45,6 +48,7 @@ public record ProxyRequest
|
||||||
public bool AllowEveryone { get; init; }
|
public bool AllowEveryone { get; init; }
|
||||||
public Message.MessageFlags? Flags { get; init; }
|
public Message.MessageFlags? Flags { get; init; }
|
||||||
public bool Tts { get; init; }
|
public bool Tts { get; init; }
|
||||||
|
public Message.MessagePoll? Poll { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class WebhookExecutorService
|
public class WebhookExecutorService
|
||||||
|
|
@ -83,7 +87,7 @@ public class WebhookExecutorService
|
||||||
return webhookMessage;
|
return webhookMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Message> EditWebhookMessage(ulong channelId, ulong messageId, string newContent, bool clearEmbeds = false)
|
public async Task<Message> EditWebhookMessage(ulong guildId, ulong channelId, ulong messageId, string newContent, bool clearEmbeds = false)
|
||||||
{
|
{
|
||||||
var allowedMentions = newContent.ParseMentions() with
|
var allowedMentions = newContent.ParseMentions() with
|
||||||
{
|
{
|
||||||
|
|
@ -92,7 +96,7 @@ public class WebhookExecutorService
|
||||||
};
|
};
|
||||||
|
|
||||||
ulong? threadId = null;
|
ulong? threadId = null;
|
||||||
var channel = await _cache.GetOrFetchChannel(_rest, channelId);
|
var channel = await _cache.GetOrFetchChannel(_rest, guildId, channelId);
|
||||||
if (channel.IsThread())
|
if (channel.IsThread())
|
||||||
{
|
{
|
||||||
threadId = channelId;
|
threadId = channelId;
|
||||||
|
|
@ -154,6 +158,26 @@ public class WebhookExecutorService
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.Poll is Message.MessagePoll poll)
|
||||||
|
{
|
||||||
|
int? duration = null;
|
||||||
|
if (poll.Expiry is string expiry)
|
||||||
|
{
|
||||||
|
var then = OffsetDateTimePattern.ExtendedIso.Parse(expiry).Value.ToInstant();
|
||||||
|
var now = DiscordUtils.SnowflakeToInstant(req.MessageId);
|
||||||
|
// in theory .TotalHours should be exact, but just in case
|
||||||
|
duration = (int)Math.Round((then - now).TotalMinutes / 60.0);
|
||||||
|
}
|
||||||
|
webhookReq.Poll = new ExecuteWebhookRequest.WebhookPoll
|
||||||
|
{
|
||||||
|
Question = poll.Question,
|
||||||
|
Answers = poll.Answers,
|
||||||
|
Duration = duration,
|
||||||
|
AllowMultiselect = poll.AllowMultiselect,
|
||||||
|
LayoutType = poll.LayoutType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Message webhookMessage;
|
Message webhookMessage;
|
||||||
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
|
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -49,17 +49,17 @@ public static class MiscUtils
|
||||||
if (e is WebhookExecutionErrorOnDiscordsEnd) return false;
|
if (e is WebhookExecutionErrorOnDiscordsEnd) return false;
|
||||||
|
|
||||||
// Socket errors are *not our problem*
|
// Socket errors are *not our problem*
|
||||||
if (e.GetBaseException() is SocketException) return false;
|
// if (e.GetBaseException() is SocketException) return false;
|
||||||
|
|
||||||
// Tasks being cancelled for whatver reason are, you guessed it, also not our problem.
|
// Tasks being cancelled for whatver reason are, you guessed it, also not our problem.
|
||||||
if (e is TaskCanceledException) return false;
|
// if (e is TaskCanceledException) return false;
|
||||||
|
|
||||||
// Sometimes Discord just times everything out.
|
// Sometimes Discord just times everything out.
|
||||||
if (e is TimeoutException) return false;
|
// if (e is TimeoutException) return false;
|
||||||
if (e is UnknownDiscordRequestException tde && tde.Message == "Request Timeout") return false;
|
if (e is UnknownDiscordRequestException tde && tde.Message == "Request Timeout") return false;
|
||||||
|
|
||||||
// HTTP/2 streams are complicated and break sometimes.
|
// HTTP/2 streams are complicated and break sometimes.
|
||||||
if (e is HttpRequestException) return false;
|
// if (e is HttpRequestException) return false;
|
||||||
|
|
||||||
// This may expanded at some point.
|
// This may expanded at some point.
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,14 @@ public static class ModelUtils
|
||||||
|
|
||||||
|
|
||||||
public static string DisplayHid(this PKSystem system, SystemConfig? cfg = null, bool isList = false) => HidTransform(system.Hid, cfg, isList);
|
public static string DisplayHid(this PKSystem system, SystemConfig? cfg = null, bool isList = false) => HidTransform(system.Hid, cfg, isList);
|
||||||
public static string DisplayHid(this PKGroup group, SystemConfig? cfg = null, bool isList = false) => HidTransform(group.Hid, cfg, isList);
|
public static string DisplayHid(this PKGroup group, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidTransform(group.Hid, cfg, isList, shouldPad);
|
||||||
public static string DisplayHid(this PKMember member, SystemConfig? cfg = null, bool isList = false) => HidTransform(member.Hid, cfg, isList);
|
public static string DisplayHid(this PKMember member, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) => HidTransform(member.Hid, cfg, isList, shouldPad);
|
||||||
private static string HidTransform(string hid, SystemConfig? cfg = null, bool isList = false) =>
|
private static string HidTransform(string hid, SystemConfig? cfg = null, bool isList = false, bool shouldPad = true) =>
|
||||||
HidUtils.HidTransform(
|
HidUtils.HidTransform(
|
||||||
hid,
|
hid,
|
||||||
cfg != null && cfg.HidDisplaySplit,
|
cfg != null && cfg.HidDisplaySplit,
|
||||||
cfg != null && cfg.HidDisplayCaps,
|
cfg != null && cfg.HidDisplayCaps,
|
||||||
isList ? (cfg?.HidListPadding ?? SystemConfig.HidPadFormat.None) : SystemConfig.HidPadFormat.None // padding only on lists
|
isList && shouldPad ? (cfg?.HidListPadding ?? SystemConfig.HidPadFormat.None) : SystemConfig.HidPadFormat.None // padding only on lists
|
||||||
);
|
);
|
||||||
|
|
||||||
private static string EntityReference(string hid, string name)
|
private static string EntityReference(string hid, string name)
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,11 @@ public class SerilogGatewayEnricherFactory
|
||||||
{
|
{
|
||||||
props.Add(new LogEventProperty("ChannelId", new ScalarValue(channel.Value)));
|
props.Add(new LogEventProperty("ChannelId", new ScalarValue(channel.Value)));
|
||||||
|
|
||||||
if (await _cache.TryGetChannel(channel.Value) != null)
|
var guildIdForCache = guild != null ? guild.Value : 0;
|
||||||
|
|
||||||
|
if (await _cache.TryGetChannel(guildIdForCache, channel.Value) != null)
|
||||||
{
|
{
|
||||||
var botPermissions = await _cache.BotPermissionsIn(channel.Value);
|
var botPermissions = await _cache.BotPermissionsIn(guildIdForCache, channel.Value);
|
||||||
props.Add(new LogEventProperty("BotPermissions", new ScalarValue(botPermissions)));
|
props.Add(new LogEventProperty("BotPermissions", new ScalarValue(botPermissions)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,9 @@
|
||||||
},
|
},
|
||||||
"Sentry": {
|
"Sentry": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[3.11.1, )",
|
"requested": "[4.12.1, )",
|
||||||
"resolved": "3.11.1",
|
"resolved": "4.12.1",
|
||||||
"contentHash": "T/NLfs6MMkUSYsPEDajB9ad0124T18I0uUod5MNOev3iwjvcnIEQBrStEX2olbIxzqfvGXzQ/QFqTfA2ElLPlA=="
|
"contentHash": "OLf7885OKHWLaTLTyw884mwOT4XKCWj2Hz5Wuz/TJemJqXwCIdIljkJBIoeHviRUPvtB7ulDgeYXf/Z7ScToSA=="
|
||||||
},
|
},
|
||||||
"SixLabors.ImageSharp": {
|
"SixLabors.ImageSharp": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ public class CoreConfig
|
||||||
public string? MessagesDatabase { get; set; }
|
public string? MessagesDatabase { get; set; }
|
||||||
public string? DatabasePassword { get; set; }
|
public string? DatabasePassword { get; set; }
|
||||||
public string RedisAddr { get; set; }
|
public string RedisAddr { get; set; }
|
||||||
public bool UseRedisMetrics { get; set; } = false;
|
|
||||||
public string SentryUrl { get; set; }
|
public string SentryUrl { get; set; }
|
||||||
public string InfluxUrl { get; set; }
|
public string InfluxUrl { get; set; }
|
||||||
public string InfluxDb { get; set; }
|
public string InfluxDb { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,11 @@ public static class DatabaseViewsExt
|
||||||
public static Task<IEnumerable<ListedGroup>> QueryGroupList(this IPKConnection conn, SystemId system,
|
public static Task<IEnumerable<ListedGroup>> QueryGroupList(this IPKConnection conn, SystemId system,
|
||||||
ListQueryOptions opts)
|
ListQueryOptions opts)
|
||||||
{
|
{
|
||||||
StringBuilder query = new StringBuilder("select * from group_list where system = @system");
|
StringBuilder query;
|
||||||
|
if (opts.MemberFilter == null)
|
||||||
|
query = new StringBuilder("select * from group_list where system = @system");
|
||||||
|
else
|
||||||
|
query = new StringBuilder("select group_list.* from group_members inner join group_list on group_list.id = group_members.group_id where member_id = @MemberFilter");
|
||||||
|
|
||||||
if (opts.PrivacyFilter != null)
|
if (opts.PrivacyFilter != null)
|
||||||
query.Append($" and visibility = {(int)opts.PrivacyFilter}");
|
query.Append($" and visibility = {(int)opts.PrivacyFilter}");
|
||||||
|
|
@ -20,7 +24,8 @@ public static class DatabaseViewsExt
|
||||||
static string Filter(string column) =>
|
static string Filter(string column) =>
|
||||||
$"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";
|
$"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";
|
||||||
|
|
||||||
query.Append($" and ({Filter("name")} or {Filter("display_name")}");
|
var nameColumn = opts.Context == LookupContext.ByOwner ? "name" : "public_name";
|
||||||
|
query.Append($" and ({Filter(nameColumn)} or {Filter("display_name")}");
|
||||||
if (opts.SearchDescription)
|
if (opts.SearchDescription)
|
||||||
{
|
{
|
||||||
// We need to account for the possibility of description privacy when searching
|
// We need to account for the possibility of description privacy when searching
|
||||||
|
|
@ -36,7 +41,7 @@ public static class DatabaseViewsExt
|
||||||
|
|
||||||
return conn.QueryAsync<ListedGroup>(
|
return conn.QueryAsync<ListedGroup>(
|
||||||
query.ToString(),
|
query.ToString(),
|
||||||
new { system, filter = opts.Search });
|
new { system, filter = opts.Search, memberFilter = opts.MemberFilter });
|
||||||
}
|
}
|
||||||
public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system,
|
public static Task<IEnumerable<ListedMember>> QueryMemberList(this IPKConnection conn, SystemId system,
|
||||||
ListQueryOptions opts)
|
ListQueryOptions opts)
|
||||||
|
|
@ -56,7 +61,8 @@ public static class DatabaseViewsExt
|
||||||
static string Filter(string column) =>
|
static string Filter(string column) =>
|
||||||
$"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";
|
$"(position(lower(@filter) in lower(coalesce({column}, ''))) > 0)";
|
||||||
|
|
||||||
query.Append($" and ({Filter("name")} or {Filter("display_name")}");
|
var nameColumn = opts.Context == LookupContext.ByOwner ? "name" : "public_name";
|
||||||
|
query.Append($" and ({Filter(nameColumn)} or {Filter("display_name")}");
|
||||||
if (opts.SearchDescription)
|
if (opts.SearchDescription)
|
||||||
{
|
{
|
||||||
// We need to account for the possibility of description privacy when searching
|
// We need to account for the possibility of description privacy when searching
|
||||||
|
|
@ -81,5 +87,6 @@ public static class DatabaseViewsExt
|
||||||
public bool SearchDescription;
|
public bool SearchDescription;
|
||||||
public LookupContext Context;
|
public LookupContext Context;
|
||||||
public GroupId? GroupFilter;
|
public GroupId? GroupFilter;
|
||||||
|
public MemberId? MemberFilter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -30,7 +30,15 @@ select members.*,
|
||||||
-- Privacy '1' = public; just return description as normal
|
-- Privacy '1' = public; just return description as normal
|
||||||
when members.description_privacy = 1 then members.description
|
when members.description_privacy = 1 then members.description
|
||||||
-- Any other privacy (rn just '2'), return null description (missing case = null in SQL)
|
-- Any other privacy (rn just '2'), return null description (missing case = null in SQL)
|
||||||
end as public_description
|
end as public_description,
|
||||||
|
|
||||||
|
-- Extract member name as seen by "the public"
|
||||||
|
case
|
||||||
|
-- Privacy '1' = public; just return name as normal
|
||||||
|
when members.name_privacy = 1 then members.name
|
||||||
|
-- Any other privacy (rn just '2'), return display name
|
||||||
|
else coalesce(members.display_name, members.name)
|
||||||
|
end as public_name
|
||||||
from members;
|
from members;
|
||||||
|
|
||||||
create view group_list as
|
create view group_list as
|
||||||
|
|
@ -48,5 +56,20 @@ select groups.*,
|
||||||
inner join members on group_members.member_id = members.id
|
inner join members on group_members.member_id = members.id
|
||||||
where
|
where
|
||||||
group_members.group_id = groups.id
|
group_members.group_id = groups.id
|
||||||
) as total_member_count
|
) as total_member_count,
|
||||||
from groups;
|
|
||||||
|
-- Extract group description as seen by "the public"
|
||||||
|
case
|
||||||
|
-- Privacy '1' = public; just return description as normal
|
||||||
|
when groups.description_privacy = 1 then groups.description
|
||||||
|
-- Any other privacy (rn just '2'), return null description (missing case = null in SQL)
|
||||||
|
end as public_description,
|
||||||
|
|
||||||
|
-- Extract member name as seen by "the public"
|
||||||
|
case
|
||||||
|
-- Privacy '1' = public; just return name as normal
|
||||||
|
when groups.name_privacy = 1 then groups.name
|
||||||
|
-- Any other privacy (rn just '2'), return display name
|
||||||
|
else coalesce(groups.display_name, groups.name)
|
||||||
|
end as public_name
|
||||||
|
from groups;
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ public class DispatchService
|
||||||
{
|
{
|
||||||
var repo = _provider.Resolve<ModelRepository>();
|
var repo = _provider.Resolve<ModelRepository>();
|
||||||
var system = await repo.GetSystemByAccount(accountId);
|
var system = await repo.GetSystemByAccount(accountId);
|
||||||
if (system.WebhookUrl == null)
|
if (system?.WebhookUrl == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var data = new UpdateDispatchData();
|
var data = new UpdateDispatchData();
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ public class HandlerQueue<T>
|
||||||
{
|
{
|
||||||
var theTask = await Task.WhenAny(timeoutTask, tcs.Task);
|
var theTask = await Task.WhenAny(timeoutTask, tcs.Task);
|
||||||
if (theTask == timeoutTask)
|
if (theTask == timeoutTask)
|
||||||
throw new TimeoutException();
|
throw new TimeoutException("HandlerQueue#WaitFor timed out");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
"bootstrap": "^5.1.3",
|
"bootstrap": "^5.1.3",
|
||||||
"bootstrap-dark-5": "^1.1.3",
|
"bootstrap-dark-5": "^1.1.3",
|
||||||
"core-js-pure": "^3.23.4",
|
"core-js-pure": "^3.23.4",
|
||||||
"discord-markdown": "github:draconizations/discord-markdown#1f74a7094777d5bdfd123c0aac59d8b10db89b30",
|
"discord-markdown": "github:draconizations/discord-markdown#9d25e45015766779916baea52c37ae0fe12aac73",
|
||||||
"gh-pages": "^3.2.3",
|
"gh-pages": "^3.2.3",
|
||||||
"highlight.js": "^11.7.0",
|
"highlight.js": "^11.7.0",
|
||||||
"import": "^0.0.6",
|
"import": "^0.0.6",
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@
|
||||||
else icon_url = item.webhook_avatar_url ?? default_avatar
|
else icon_url = item.webhook_avatar_url ?? default_avatar
|
||||||
}
|
}
|
||||||
|
|
||||||
$: icon_url_resized = icon_url ? resizeMedia(icon_url) : default_avatar
|
|
||||||
|
|
||||||
let avatarOpen = false;
|
let avatarOpen = false;
|
||||||
const toggleAvatarModal = () => (avatarOpen = !avatarOpen);
|
const toggleAvatarModal = () => (avatarOpen = !avatarOpen);
|
||||||
|
|
||||||
|
|
@ -65,8 +63,8 @@
|
||||||
<span bind:this={nameElement} style="vertical-align: middle;"><AwaitHtml htmlPromise={htmlNamePromise} /> ({item.id})</span>
|
<span bind:this={nameElement} style="vertical-align: middle;"><AwaitHtml htmlPromise={htmlNamePromise} /> ({item.id})</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-left: auto;">
|
<div style="margin-left: auto;">
|
||||||
{#if item && (item.avatar_url || item.icon)}
|
{#if item && (item.avatar_url || item.webhook_avatar_url || item.icon)}
|
||||||
<img tabindex={0} on:keydown|stopPropagation={(event) => {if (event.key === "Enter") {avatarOpen = true}}} on:click|stopPropagation={toggleAvatarModal} class="rounded-circle avatar" src={icon_url_resized} alt={altText} />
|
<img tabindex={0} on:keydown|stopPropagation={(event) => {if (event.key === "Enter") {avatarOpen = true}}} on:click|stopPropagation={toggleAvatarModal} class="rounded-circle avatar" src={icon_url} alt={altText} />
|
||||||
{:else}
|
{:else}
|
||||||
<img class="rounded-circle avatar" src={default_avatar} alt="icon (default)" tabindex={0} />
|
<img class="rounded-circle avatar" src={default_avatar} alt="icon (default)" tabindex={0} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,9 @@
|
||||||
err = [];
|
err = [];
|
||||||
success = false;
|
success = false;
|
||||||
|
|
||||||
|
// trim all string fields
|
||||||
|
Object.keys(data).forEach(k => data[k] = typeof data[k] == "string" ? data[k].trim() : data[k]);
|
||||||
|
|
||||||
if (!data.name) err.push("Group name cannot be empty.")
|
if (!data.name) err.push("Group name cannot be empty.")
|
||||||
|
|
||||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||||
|
|
@ -35,9 +38,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim all string fields
|
|
||||||
Object.keys(data).forEach(k => data[k] = typeof data[k] == "string" ? data[k].trim() : data[k]);
|
|
||||||
|
|
||||||
err = err;
|
err = err;
|
||||||
if (err.length > 0) return;
|
if (err.length > 0) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@
|
||||||
let data = input;
|
let data = input;
|
||||||
err = [];
|
err = [];
|
||||||
|
|
||||||
|
// trim all string fields
|
||||||
|
Object.keys(data).forEach(k => data[k] = typeof data[k] == "string" ? data[k].trim() : data[k]);
|
||||||
|
|
||||||
if (!data.name) err.push("Member name cannot be empty.")
|
if (!data.name) err.push("Member name cannot be empty.")
|
||||||
|
|
||||||
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
if (data.color && !/^#?[A-Fa-f0-9]{6}$/.test(input.color)) {
|
||||||
|
|
@ -58,9 +61,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim all string fields
|
|
||||||
Object.keys(data).forEach(k => data[k] = typeof data[k] == "string" ? data[k].trim() : data[k]);
|
|
||||||
|
|
||||||
err = err;
|
err = err;
|
||||||
if (err.length > 0) return;
|
if (err.length > 0) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ if (!String.prototype.replaceAll)
|
||||||
String.prototype.replaceAll = replaceAll;
|
String.prototype.replaceAll = replaceAll;
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: "https://973beecd91934f9992c72c942770bdd2@sentry.pluralkit.me/3",
|
dsn: "https://79ba4b55fdce475ebc5d37df8b75d72a@gt.pluralkit.me/5",
|
||||||
integrations: [new Integrations.BrowserTracing()],
|
integrations: [new Integrations.BrowserTracing()],
|
||||||
|
|
||||||
enabled: !window.location.origin.includes("localhost"),
|
enabled: !window.location.origin.includes("localhost"),
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,8 @@
|
||||||
.d-spoiler:active {
|
.d-spoiler:active {
|
||||||
color: $body-color-alt; //overwrite
|
color: $body-color-alt; //overwrite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: #707070;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,6 +193,12 @@ code {
|
||||||
vertical-align: -0.1125em;
|
vertical-align: -0.1125em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
display: block;
|
||||||
|
color: #808080;
|
||||||
|
font-size: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
//twemoji
|
//twemoji
|
||||||
img.emoji {
|
img.emoji {
|
||||||
height: 1.125em;
|
height: 1.125em;
|
||||||
|
|
|
||||||
|
|
@ -341,9 +341,9 @@ detect-indent@^6.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
|
resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
|
||||||
integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
|
integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
|
||||||
|
|
||||||
"discord-markdown@github:draconizations/discord-markdown#1f74a7094777d5bdfd123c0aac59d8b10db89b30":
|
"discord-markdown@github:draconizations/discord-markdown#9d25e45015766779916baea52c37ae0fe12aac73":
|
||||||
version "2.5.1"
|
version "2.5.1"
|
||||||
resolved "https://codeload.github.com/draconizations/discord-markdown/tar.gz/1f74a7094777d5bdfd123c0aac59d8b10db89b30"
|
resolved "https://codeload.github.com/draconizations/discord-markdown/tar.gz/9d25e45015766779916baea52c37ae0fe12aac73"
|
||||||
dependencies:
|
dependencies:
|
||||||
js-base64 "^3.7.7"
|
js-base64 "^3.7.7"
|
||||||
simple-markdown "^0.7.3"
|
simple-markdown "^0.7.3"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ permalink: /api/changelog
|
||||||
|
|
||||||
# Version history
|
# Version history
|
||||||
|
|
||||||
* 2024-08-84
|
* 2024-08-04
|
||||||
* Added ratelimit scopes (separate limits for different sets of endpoints)
|
* Added ratelimit scopes (separate limits for different sets of endpoints)
|
||||||
* 2024-05-01
|
* 2024-05-01
|
||||||
* Short IDs (the `id` field in system / member / group models) can now be either 5 or 6 characters.
|
* Short IDs (the `id` field in system / member / group models) can now be either 5 or 6 characters.
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@ You can have a space after `pk;`, e.g. `pk;system` and `pk; system` will do the
|
||||||
## Switching commands
|
## Switching commands
|
||||||
- `pk;switch [member...]` - Registers a switch with the given members.
|
- `pk;switch [member...]` - Registers a switch with the given members.
|
||||||
- `pk;switch out` - Registers a 'switch-out' - a switch with no associated members.
|
- `pk;switch out` - Registers a 'switch-out' - a switch with no associated members.
|
||||||
- `pk;switch edit <member...|out>` - Edits the members in the latest switch.
|
- `pk;switch edit <member...|out>` - Edits the members in the latest switch.
|
||||||
|
- `pk;switch add <member...>` - Makes a new switch based off the current switch with the listed members added or removed.
|
||||||
- `pk;switch move <time>` - Moves the latest switch backwards in time.
|
- `pk;switch move <time>` - Moves the latest switch backwards in time.
|
||||||
- `pk;switch delete` - Deletes the latest switch.
|
- `pk;switch delete` - Deletes the latest switch.
|
||||||
- `pk;switch delete all` - Deletes all logged switches.
|
- `pk;switch delete all` - Deletes all logged switches.
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ We also track feature requests through [Github Issues](https://github.com/Plural
|
||||||
### How can I support the bot's development?
|
### How can I support the bot's development?
|
||||||
I (the bot author, [Ske](https://twitter.com/floofstrid)) have a Patreon. The income from there goes towards server hosting, domains, infrastructure, my Monster Energy addiction, et cetera. There are no benefits. There might never be any. But nevertheless, it can be found here: [https://www.patreon.com/floofstrid](https://www.patreon.com/floofstrid)
|
I (the bot author, [Ske](https://twitter.com/floofstrid)) have a Patreon. The income from there goes towards server hosting, domains, infrastructure, my Monster Energy addiction, et cetera. There are no benefits. There might never be any. But nevertheless, it can be found here: [https://www.patreon.com/floofstrid](https://www.patreon.com/floofstrid)
|
||||||
|
|
||||||
|
### Can I recover my system if I lose access to my Discord account?
|
||||||
|
Yes, through one of two methods. Both require you to do preparations **before** you lose the account.
|
||||||
|
|
||||||
|
Option 1: If you have an alternate discord account you can link your PluralKit system to that account using `pk;link <@account>`. Then if you use access to your main discord account, you already have access on your alternate account.
|
||||||
|
|
||||||
|
Option 2: The PluralKit staff can help you recover your system if you have your token (gotten using `pk;token`). This is the *only* way you can prove ownership so we can help you recover your system, so store it in a safe place. Make sure to keep your token safe; if other people get access to it they can also use it to access your system. If your token is ever compromised run `pk;token refresh` to invalidate the old token and get a new one.
|
||||||
|
|
||||||
## Privacy / safety
|
## Privacy / safety
|
||||||
|
|
||||||
### Who has access to the bot's data?
|
### Who has access to the bot's data?
|
||||||
|
|
@ -91,3 +98,5 @@ It is not possible to edit messages via ID. Please use the full link, or reply t
|
||||||
### How do I reply-ping/reply-@ proxied messages?
|
### How do I reply-ping/reply-@ proxied messages?
|
||||||
You cannot reply-@ a proxied messages due to their nature as webhooks. If you want to "reply-@" a proxied message, you must react to the message with 🔔, 🛎, or 🏓. This will send a message from PluralKit that reads "Psst, MEMBER (@User), you have been pinged by @You", which will ping the Discord account behind the proxied message.
|
You cannot reply-@ a proxied messages due to their nature as webhooks. If you want to "reply-@" a proxied message, you must react to the message with 🔔, 🛎, or 🏓. This will send a message from PluralKit that reads "Psst, MEMBER (@User), you have been pinged by @You", which will ping the Discord account behind the proxied message.
|
||||||
|
|
||||||
|
### Why do most of PluralKit's messages look blank or empty?
|
||||||
|
A lot of PluralKit's command responses use Discord embeds. If you can't see them, it's likely you have embeds turned off. To change this, go into your discord settings and find the tab "Chat" under "App Settings". Find the setting "Show embeds and preview website links" and turn it on. If it's already on, try turning it off and then on again.
|
||||||
|
|
@ -61,7 +61,7 @@ If you don't have a link, you can leave that out entirely, and then **attach** t
|
||||||
Avatars have some restrictions:
|
Avatars have some restrictions:
|
||||||
- The image must be in **.jpg**, **.png**, or **.webp** format
|
- The image must be in **.jpg**, **.png**, or **.webp** format
|
||||||
- The image must be under **1024 KB** in size
|
- The image must be under **1024 KB** in size
|
||||||
- The image must be below **1024 x 1024 pixels** in resolution (along the smallest axis).
|
- The image must be below **1000 x 1000 pixels** in resolution (along the smallest axis).
|
||||||
- Animated GIFs are **not** supported (even if you have Nitro).
|
- Animated GIFs are **not** supported (even if you have Nitro).
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
@ -73,4 +73,4 @@ You could...
|
||||||
- [configure privacy settings](/guide/#privacy)
|
- [configure privacy settings](/guide/#privacy)
|
||||||
- or something else!
|
- or something else!
|
||||||
|
|
||||||
See the [User Guide](/guide) for a more complete reference of the bot's features.
|
See the [User Guide](/guide) for a more complete reference of the bot's features.
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,16 @@ You cannot look up private members or groups of another system.
|
||||||
|pk;system frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%|
|
|pk;system frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%|
|
||||||
|pk;group \<group> frontpercent|-fronters-only|-fo|Show a group's frontpercent without the "no fronter" entry|
|
|pk;group \<group> frontpercent|-fronters-only|-fo|Show a group's frontpercent without the "no fronter" entry|
|
||||||
|pk;group \<group> frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%|
|
|pk;group \<group> frontpercent|-flat||Show "flat" frontpercent - percentages add up to 100%|
|
||||||
|
|pk;group \<group> \<add/remove>|-all|-a|Add or remove all members from a group.|
|
||||||
|pk;edit|-append|-a|Append the new content to the old message instead of overwriting it|
|
|pk;edit|-append|-a|Append the new content to the old message instead of overwriting it|
|
||||||
|pk;edit|-prepend|-p|Prepend the new content to the old message instead of overwriting it|
|
|pk;edit|-prepend|-p|Prepend the new content to the old message instead of overwriting it|
|
||||||
|pk;edit|-nospace|-ns|Append/prepend without adding a space|
|
|pk;edit|-nospace|-ns|Append/prepend without adding a space|
|
||||||
|pk;edit|-clear-embed|-ce|Remove embeds from a message|
|
|pk;edit|-clear-embed|-ce|Remove embeds from a message|
|
||||||
|pk;edit|-regex|-x|Edit using a C# Regex formatted like s\|X\|Y or s\|X\|Y\|F, where \| is any character, X is a Regex, Y is a substitution string, and F is a set of Regex flags|
|
|pk;edit|-regex|-x|Edit using a C# Regex formatted like s\|X\|Y or s\|X\|Y\|F, where \| is any character, X is a Regex, Y is a substitution string, and F is a set of Regex flags|
|
||||||
|
|pk;switch edit and pk;switch add|-append|-a|Append members to the current switch or make a new switch with members appended|
|
||||||
|
|pk;switch edit and pk;switch add|-prepend|-p|Prepend members to the current switch or make a new switch with members prepended|
|
||||||
|
|pk;switch edit and pk;switch add|-first|-f|Move member to the front of the current switch or make a new switch with the member at the front|
|
||||||
|
|pk;switch edit and pk;switch add|-remove|-r|Remove members from the current switch or make a new switch with members removed|
|
||||||
|Most commands|-all|-a|Show hidden/private information|
|
|Most commands|-all|-a|Show hidden/private information|
|
||||||
|Most commands|-raw|-r|Show text with formatting, for easier copy-pasting|
|
|Most commands|-raw|-r|Show text with formatting, for easier copy-pasting|
|
||||||
|All commands|-private|-priv|Show private information|
|
|All commands|-private|-priv|Show private information|
|
||||||
|
|
|
||||||
1
go.work
1
go.work
|
|
@ -2,5 +2,4 @@ go 1.19
|
||||||
|
|
||||||
use (
|
use (
|
||||||
./services/scheduled_tasks
|
./services/scheduled_tasks
|
||||||
./services/web-proxy
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,21 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
config = "0.13.3"
|
config = "0.14.0"
|
||||||
fred = { workspace = true }
|
fred = { workspace = true }
|
||||||
gethostname = "0.4.1"
|
gethostname = "0.4.1"
|
||||||
lazy_static = { workspace = true }
|
lazy_static = { workspace = true }
|
||||||
metrics = { workspace = true }
|
metrics = { workspace = true }
|
||||||
metrics-exporter-prometheus = { version = "0.11.0", default-features = false, features = ["tokio", "http-listener", "tracing"] }
|
metrics-exporter-prometheus = { version = "0.15.3", default-features = false, features = ["tokio", "http-listener", "tracing"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
time = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
tracing-gelf = "0.7.1"
|
||||||
tracing-subscriber = { workspace = true}
|
tracing-subscriber = { workspace = true}
|
||||||
|
twilight-model = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
prost = { workspace = true }
|
prost = { workspace = true }
|
||||||
prost-types = { workspace = true }
|
prost-types = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,27 @@ use lazy_static::lazy_static;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use twilight_model::id::{marker::UserMarker, Id};
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Debug)]
|
||||||
|
pub struct ClusterSettings {
|
||||||
|
pub node_id: u32,
|
||||||
|
pub total_shards: u32,
|
||||||
|
pub total_nodes: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct DiscordConfig {
|
pub struct DiscordConfig {
|
||||||
pub client_id: u32,
|
pub client_id: Id<UserMarker>,
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
pub client_secret: String,
|
pub client_secret: String,
|
||||||
|
pub max_concurrency: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cluster: Option<ClusterSettings>,
|
||||||
|
pub api_base_url: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default = "_default_api_addr")]
|
||||||
|
pub cache_api_addr: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
|
|
@ -24,7 +40,7 @@ fn _default_api_addr() -> String {
|
||||||
"0.0.0.0:5000".to_string()
|
"0.0.0.0:5000".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct ApiConfig {
|
pub struct ApiConfig {
|
||||||
#[serde(default = "_default_api_addr")]
|
#[serde(default = "_default_api_addr")]
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
|
|
@ -38,27 +54,69 @@ pub struct ApiConfig {
|
||||||
pub temp_token2: Option<String>,
|
pub temp_token2: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct AvatarsConfig {
|
||||||
|
pub s3: S3Config,
|
||||||
|
pub cdn_url: String,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub migrate_worker_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct S3Config {
|
||||||
|
pub bucket: String,
|
||||||
|
pub application_id: String,
|
||||||
|
pub application_key: String,
|
||||||
|
pub endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn _metrics_default() -> bool {
|
fn _metrics_default() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
fn _json_log_default() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct PKConfig {
|
pub struct PKConfig {
|
||||||
pub db: DatabaseConfig,
|
pub db: DatabaseConfig,
|
||||||
|
|
||||||
pub discord: DiscordConfig,
|
#[serde(default)]
|
||||||
pub api: ApiConfig,
|
pub discord: Option<DiscordConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api: Option<ApiConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatars: Option<AvatarsConfig>,
|
||||||
|
|
||||||
#[serde(default = "_metrics_default")]
|
#[serde(default = "_metrics_default")]
|
||||||
pub run_metrics_server: bool,
|
pub run_metrics_server: bool,
|
||||||
|
|
||||||
pub(crate) gelf_log_url: Option<String>,
|
#[serde(default = "_json_log_default")]
|
||||||
|
pub(crate) json_log: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PKConfig {
|
||||||
|
pub fn api(self) -> ApiConfig {
|
||||||
|
self.api.expect("missing api config")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn discord_config(self) -> DiscordConfig {
|
||||||
|
self.discord.expect("missing discord config")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub static ref CONFIG: Arc<PKConfig> = Arc::new(Config::builder()
|
pub static ref CONFIG: Arc<PKConfig> = {
|
||||||
.add_source(config::Environment::with_prefix("pluralkit").separator("__"))
|
if let Ok(var) = std::env::var("NOMAD_ALLOC_INDEX")
|
||||||
.build().unwrap()
|
&& std::env::var("pluralkit__discord__cluster__total_nodes").is_ok() {
|
||||||
.try_deserialize::<PKConfig>().unwrap());
|
std::env::set_var("pluralkit__discord__cluster__node_id", var);
|
||||||
|
}
|
||||||
|
|
||||||
|
Arc::new(Config::builder()
|
||||||
|
.add_source(config::Environment::with_prefix("pluralkit").separator("__"))
|
||||||
|
.build().unwrap()
|
||||||
|
.try_deserialize::<PKConfig>().unwrap())
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::str::FromStr;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
pub async fn init_redis() -> anyhow::Result<RedisPool> {
|
pub async fn init_redis() -> anyhow::Result<RedisPool> {
|
||||||
info!("connecting to redis");
|
info!("connecting to redis");
|
||||||
|
|
|
||||||
87
lib/libpk/src/db/repository/avatars.rs
Normal file
87
lib/libpk/src/db/repository/avatars.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
use sqlx::{PgPool, Postgres, Transaction};
|
||||||
|
|
||||||
|
use crate::db::types::avatars::*;
|
||||||
|
|
||||||
|
pub async fn get_by_original_url(
|
||||||
|
pool: &PgPool,
|
||||||
|
original_url: &str,
|
||||||
|
) -> anyhow::Result<Option<ImageMeta>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as("select * from images where original_url = $1")
|
||||||
|
.bind(original_url)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_by_attachment_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
attachment_id: u64,
|
||||||
|
) -> anyhow::Result<Option<ImageMeta>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as("select * from images where original_attachment_id = $1")
|
||||||
|
.bind(attachment_id as i64)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pop_queue(
|
||||||
|
pool: &PgPool,
|
||||||
|
) -> anyhow::Result<Option<(Transaction<Postgres>, ImageQueueEntry)>> {
|
||||||
|
let mut tx = pool.begin().await?;
|
||||||
|
let res: Option<ImageQueueEntry> = sqlx::query_as("delete from image_queue where itemid = (select itemid from image_queue order by itemid for update skip locked limit 1) returning *")
|
||||||
|
.fetch_optional(&mut *tx).await?;
|
||||||
|
Ok(res.map(|x| (tx, x)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_queue_length(pool: &PgPool) -> anyhow::Result<i64> {
|
||||||
|
Ok(sqlx::query_scalar("select count(*) from image_queue")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_stats(pool: &PgPool) -> anyhow::Result<Stats> {
|
||||||
|
Ok(sqlx::query_as(
|
||||||
|
"select count(*) as total_images, sum(file_size) as total_file_size from images",
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_image(pool: &PgPool, meta: ImageMeta) -> anyhow::Result<bool> {
|
||||||
|
let kind_str = match meta.kind {
|
||||||
|
ImageKind::Avatar => "avatar",
|
||||||
|
ImageKind::Banner => "banner",
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = sqlx::query("insert into images (id, url, content_type, original_url, file_size, width, height, original_file_size, original_type, original_attachment_id, kind, uploaded_by_account, uploaded_by_system, uploaded_at) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, (now() at time zone 'utc')) on conflict (id) do nothing")
|
||||||
|
.bind(meta.id)
|
||||||
|
.bind(meta.url)
|
||||||
|
.bind(meta.content_type)
|
||||||
|
.bind(meta.original_url)
|
||||||
|
.bind(meta.file_size)
|
||||||
|
.bind(meta.width)
|
||||||
|
.bind(meta.height)
|
||||||
|
.bind(meta.original_file_size)
|
||||||
|
.bind(meta.original_type)
|
||||||
|
.bind(meta.original_attachment_id)
|
||||||
|
.bind(kind_str)
|
||||||
|
.bind(meta.uploaded_by_account)
|
||||||
|
.bind(meta.uploaded_by_system)
|
||||||
|
.execute(pool).await?;
|
||||||
|
Ok(res.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_queue(
|
||||||
|
conn: &mut sqlx::PgConnection,
|
||||||
|
url: &str,
|
||||||
|
kind: ImageKind,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
sqlx::query("insert into image_queue (url, kind) values ($1, $2)")
|
||||||
|
.bind(url)
|
||||||
|
.bind(kind)
|
||||||
|
.execute(conn)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
mod stats;
|
mod stats;
|
||||||
pub use stats::*;
|
pub use stats::*;
|
||||||
|
|
||||||
|
pub mod avatars;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub use auth::*;
|
pub use auth::*;
|
||||||
|
|
|
||||||
53
lib/libpk/src/db/types/avatars.rs
Normal file
53
lib/libpk/src/db/types/avatars.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::FromRow;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct ImageMeta {
|
||||||
|
pub id: String,
|
||||||
|
pub kind: ImageKind,
|
||||||
|
pub content_type: String,
|
||||||
|
pub url: String,
|
||||||
|
pub file_size: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub uploaded_at: Option<OffsetDateTime>,
|
||||||
|
|
||||||
|
pub original_url: Option<String>,
|
||||||
|
pub original_attachment_id: Option<i64>,
|
||||||
|
pub original_file_size: Option<i32>,
|
||||||
|
pub original_type: Option<String>,
|
||||||
|
pub uploaded_by_account: Option<i64>,
|
||||||
|
pub uploaded_by_system: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow, Serialize)]
|
||||||
|
pub struct Stats {
|
||||||
|
pub total_images: i64,
|
||||||
|
pub total_file_size: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, sqlx::Type, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[sqlx(rename_all = "snake_case", type_name = "text")]
|
||||||
|
pub enum ImageKind {
|
||||||
|
Avatar,
|
||||||
|
Banner,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageKind {
|
||||||
|
pub fn size(&self) -> (u32, u32) {
|
||||||
|
match self {
|
||||||
|
Self::Avatar => (512, 512),
|
||||||
|
Self::Banner => (1024, 1024),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(FromRow)]
|
||||||
|
pub struct ImageQueueEntry {
|
||||||
|
pub itemid: i32,
|
||||||
|
pub url: String,
|
||||||
|
pub kind: ImageKind,
|
||||||
|
}
|
||||||
1
lib/libpk/src/db/types/mod.rs
Normal file
1
lib/libpk/src/db/types/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod avatars;
|
||||||
|
|
@ -1,30 +1,25 @@
|
||||||
use gethostname::gethostname;
|
#![feature(let_chains)]
|
||||||
use metrics_exporter_prometheus::PrometheusBuilder;
|
use metrics_exporter_prometheus::PrometheusBuilder;
|
||||||
use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, EnvFilter, Registry};
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod proto;
|
pub mod proto;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
pub mod _config;
|
pub mod _config;
|
||||||
pub use crate::_config::CONFIG as config;
|
pub use crate::_config::CONFIG as config;
|
||||||
|
|
||||||
pub fn init_logging(component: &str) -> anyhow::Result<()> {
|
pub fn init_logging(component: &str) -> anyhow::Result<()> {
|
||||||
let subscriber = Registry::default()
|
// todo: fix component
|
||||||
.with(EnvFilter::from_default_env())
|
if config.json_log {
|
||||||
.with(tracing_subscriber::fmt::layer());
|
tracing_subscriber::fmt()
|
||||||
|
.json()
|
||||||
if let Some(gelf_url) = &config.gelf_log_url {
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
let gelf_logger = tracing_gelf::Logger::builder()
|
.init();
|
||||||
.additional_field("component", component)
|
|
||||||
.additional_field("hostname", gethostname().to_str());
|
|
||||||
let mut conn_handle = gelf_logger
|
|
||||||
.init_udp_with_subscriber(gelf_url, subscriber)
|
|
||||||
.unwrap();
|
|
||||||
tokio::spawn(async move { conn_handle.connect().await });
|
|
||||||
} else {
|
} else {
|
||||||
// gelf_logger internally sets the global subscriber
|
tracing_subscriber::fmt()
|
||||||
tracing::subscriber::set_global_default(subscriber)
|
.with_env_filter(EnvFilter::from_default_env())
|
||||||
.expect("unable to set global subscriber");
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
1
lib/libpk/src/util/mod.rs
Normal file
1
lib/libpk/src/util/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod redis;
|
||||||
15
lib/libpk/src/util/redis.rs
Normal file
15
lib/libpk/src/util/redis.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
use fred::error::RedisError;
|
||||||
|
|
||||||
|
pub trait RedisErrorExt<T> {
|
||||||
|
fn to_option_or_error(self) -> Result<Option<T>, RedisError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RedisErrorExt<T> for Result<T, RedisError> {
|
||||||
|
fn to_option_or_error(self) -> Result<Option<T>, RedisError> {
|
||||||
|
match self {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(error) if error.is_not_found() => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let db = libpk::db::init_data_db().await?;
|
let db = libpk::db::init_data_db().await?;
|
||||||
let redis = libpk::db::init_redis().await?;
|
let redis = libpk::db::init_redis().await?;
|
||||||
|
|
||||||
let rproxy_uri = Uri::from_static(&libpk::config.api.remote_url).to_string();
|
let rproxy_uri = Uri::from_static(&libpk::config.api.as_ref().expect("missing api config").remote_url).to_string();
|
||||||
let rproxy_client = hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
|
let rproxy_client = hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
|
||||||
.build(HttpConnector::new());
|
.build(HttpConnector::new());
|
||||||
|
|
||||||
|
|
@ -145,8 +145,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
|
|
||||||
.route("/", get(|| async { axum::response::Redirect::to("https://pluralkit.me/api") }));
|
.route("/", get(|| async { axum::response::Redirect::to("https://pluralkit.me/api") }));
|
||||||
|
|
||||||
let addr: &str = libpk::config.api.addr.as_ref();
|
let addr: &str = libpk::config.api.as_ref().expect("missing api config").addr.as_ref();
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
info!("listening on {}", addr);
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ fn add_cors_headers(headers: &mut HeaderMap) {
|
||||||
headers.append("Access-Control-Allow-Methods", HeaderValue::from_static("*"));
|
headers.append("Access-Control-Allow-Methods", HeaderValue::from_static("*"));
|
||||||
headers.append("Access-Control-Allow-Credentials", HeaderValue::from_static("true"));
|
headers.append("Access-Control-Allow-Credentials", HeaderValue::from_static("true"));
|
||||||
headers.append("Access-Control-Allow-Headers", HeaderValue::from_static("Content-Type, Authorization, sentry-trace, User-Agent"));
|
headers.append("Access-Control-Allow-Headers", HeaderValue::from_static("Content-Type, Authorization, sentry-trace, User-Agent"));
|
||||||
headers.append("Access-Control-Expose-Headers", HeaderValue::from_static("X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset"));
|
headers.append("Access-Control-Expose-Headers", HeaderValue::from_static("X-PluralKit-Version, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-RateLimit-Scope"));
|
||||||
headers.append("Access-Control-Max-Age", HeaderValue::from_static("86400"));
|
headers.append("Access-Control-Max-Age", HeaderValue::from_static("86400"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,11 @@ pub async fn logger(request: Request, next: Next) -> Response {
|
||||||
);
|
);
|
||||||
histogram!(
|
histogram!(
|
||||||
"pk_http_requests",
|
"pk_http_requests",
|
||||||
(elapsed as f64) / 1_000_f64,
|
|
||||||
"method" => method.to_string(),
|
"method" => method.to_string(),
|
||||||
"route" => endpoint.clone(),
|
"route" => endpoint.clone(),
|
||||||
"status" => response.status().to_string()
|
"status" => response.status().to_string()
|
||||||
);
|
)
|
||||||
|
.record((elapsed as f64) / 1_000_f64);
|
||||||
|
|
||||||
if elapsed > MIN_LOG_TIME {
|
if elapsed > MIN_LOG_TIME {
|
||||||
warn!(
|
warn!(
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@
|
||||||
-- redis.replicate_commands()
|
-- redis.replicate_commands()
|
||||||
|
|
||||||
local rate_limit_key = KEYS[1]
|
local rate_limit_key = KEYS[1]
|
||||||
local burst = ARGV[1]
|
local rate = ARGV[1]
|
||||||
local rate = ARGV[2]
|
local period = ARGV[2]
|
||||||
local period = ARGV[3]
|
local cost = tonumber(ARGV[3])
|
||||||
|
|
||||||
-- we're only ever asking for 1 request at a time
|
local burst = rate
|
||||||
-- todo: this is no longer true
|
|
||||||
local cost = 1 --local cost = tonumber(ARGV[4])
|
|
||||||
|
|
||||||
local emission_interval = period / rate
|
local emission_interval = period / rate
|
||||||
local increment = emission_interval * cost
|
local increment = emission_interval * cost
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use fred::{pool::RedisPool, prelude::LuaInterface, types::ReconnectPolicy, util::sha1_hash};
|
use fred::{pool::RedisPool, prelude::LuaInterface, types::ReconnectPolicy, util::sha1_hash};
|
||||||
use metrics::increment_counter;
|
use metrics::counter;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::util::{header_or_unknown, json_err};
|
use crate::util::{header_or_unknown, json_err};
|
||||||
|
|
@ -20,32 +20,38 @@ lazy_static::lazy_static! {
|
||||||
|
|
||||||
// this is awful but it works
|
// this is awful but it works
|
||||||
pub fn ratelimiter<F, T>(f: F) -> FromFnLayer<F, Option<RedisPool>, T> {
|
pub fn ratelimiter<F, T>(f: F) -> FromFnLayer<F, Option<RedisPool>, T> {
|
||||||
let redis = libpk::config.api.ratelimit_redis_addr.as_ref().map(|val| {
|
let redis = libpk::config
|
||||||
let r = fred::pool::RedisPool::new(
|
.api
|
||||||
fred::types::RedisConfig::from_url_centralized(val.as_ref())
|
.as_ref()
|
||||||
.expect("redis url is invalid"),
|
.expect("missing api config")
|
||||||
10,
|
.ratelimit_redis_addr
|
||||||
)
|
.as_ref()
|
||||||
.expect("failed to connect to redis");
|
.map(|val| {
|
||||||
|
let r = fred::pool::RedisPool::new(
|
||||||
|
fred::types::RedisConfig::from_url_centralized(val.as_ref())
|
||||||
|
.expect("redis url is invalid"),
|
||||||
|
10,
|
||||||
|
)
|
||||||
|
.expect("failed to connect to redis");
|
||||||
|
|
||||||
let handle = r.connect(Some(ReconnectPolicy::default()));
|
let handle = r.connect(Some(ReconnectPolicy::default()));
|
||||||
|
|
||||||
tokio::spawn(async move { handle });
|
tokio::spawn(async move { handle });
|
||||||
|
|
||||||
let rscript = r.clone();
|
let rscript = r.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Ok(()) = rscript.wait_for_connect().await {
|
if let Ok(()) = rscript.wait_for_connect().await {
|
||||||
match rscript.script_load(LUA_SCRIPT).await {
|
match rscript.script_load(LUA_SCRIPT).await {
|
||||||
Ok(_) => info!("connected to redis for request rate limiting"),
|
Ok(_) => info!("connected to redis for request rate limiting"),
|
||||||
Err(err) => error!("could not load redis script: {}", err),
|
Err(err) => error!("could not load redis script: {}", err),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error!("could not wait for connection to load redis script!");
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
error!("could not wait for connection to load redis script!");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
r
|
r
|
||||||
});
|
});
|
||||||
|
|
||||||
if redis.is_none() {
|
if redis.is_none() {
|
||||||
warn!("running without request rate limiting!");
|
warn!("running without request rate limiting!");
|
||||||
|
|
@ -95,7 +101,12 @@ pub async fn do_request_ratelimited(
|
||||||
// https://github.com/rust-lang/rust/issues/53667
|
// https://github.com/rust-lang/rust/issues/53667
|
||||||
let is_temp_token2 = if let Some(header) = request.headers().clone().get("X-PluralKit-App")
|
let is_temp_token2 = if let Some(header) = request.headers().clone().get("X-PluralKit-App")
|
||||||
{
|
{
|
||||||
if let Some(token2) = &libpk::config.api.temp_token2 {
|
if let Some(token2) = &libpk::config
|
||||||
|
.api
|
||||||
|
.as_ref()
|
||||||
|
.expect("missing api config")
|
||||||
|
.temp_token2
|
||||||
|
{
|
||||||
if header.to_str().unwrap_or("invalid") == token2 {
|
if header.to_str().unwrap_or("invalid") == token2 {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -137,24 +148,23 @@ pub async fn do_request_ratelimited(
|
||||||
rlimit.key()
|
rlimit.key()
|
||||||
);
|
);
|
||||||
|
|
||||||
let burst = 5;
|
|
||||||
let period = 1; // seconds
|
let period = 1; // seconds
|
||||||
|
let cost = 1; // todo: update this for group member endpoints
|
||||||
|
|
||||||
// local rate_limit_key = KEYS[1]
|
// local rate_limit_key = KEYS[1]
|
||||||
// local burst = ARGV[1]
|
// local rate = ARGV[1]
|
||||||
// local rate = ARGV[2]
|
// local period = ARGV[2]
|
||||||
// local period = ARGV[3]
|
|
||||||
// return {remaining, tostring(retry_after), reset_after}
|
// return {remaining, tostring(retry_after), reset_after}
|
||||||
let resp = redis
|
let resp = redis
|
||||||
.evalsha::<(i32, String, u64), String, Vec<String>, Vec<i32>>(
|
.evalsha::<(i32, String, u64), String, Vec<String>, Vec<i32>>(
|
||||||
LUA_SCRIPT_SHA.to_string(),
|
LUA_SCRIPT_SHA.to_string(),
|
||||||
vec![rl_key.clone()],
|
vec![rl_key.clone()],
|
||||||
vec![burst, rlimit.rate(), period],
|
vec![rlimit.rate(), period, cost],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match resp {
|
match resp {
|
||||||
Ok((mut remaining, retry_after, reset_after)) => {
|
Ok((remaining, retry_after, reset_after)) => {
|
||||||
// redis's lua doesn't support returning floats
|
// redis's lua doesn't support returning floats
|
||||||
let retry_after: f64 = retry_after
|
let retry_after: f64 = retry_after
|
||||||
.parse()
|
.parse()
|
||||||
|
|
@ -165,7 +175,7 @@ pub async fn do_request_ratelimited(
|
||||||
} else {
|
} else {
|
||||||
let retry_after = (retry_after * 1_000_f64).ceil() as u64;
|
let retry_after = (retry_after * 1_000_f64).ceil() as u64;
|
||||||
debug!("ratelimited request from {rl_key}, retry_after={retry_after}",);
|
debug!("ratelimited request from {rl_key}, retry_after={retry_after}",);
|
||||||
increment_counter!("pk_http_requests_ratelimited");
|
counter!("pk_http_requests_ratelimited").increment(1);
|
||||||
json_err(
|
json_err(
|
||||||
StatusCode::TOO_MANY_REQUESTS,
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -175,9 +185,6 @@ pub async fn do_request_ratelimited(
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// the redis script puts burst in remaining for ??? some reason
|
|
||||||
remaining -= burst - rlimit.rate();
|
|
||||||
|
|
||||||
let reset_time = SystemTime::now()
|
let reset_time = SystemTime::now()
|
||||||
.checked_add(Duration::from_secs(reset_after))
|
.checked_add(Duration::from_secs(reset_after))
|
||||||
.expect("invalid timestamp")
|
.expect("invalid timestamp")
|
||||||
|
|
|
||||||
25
services/avatars/Cargo.toml
Normal file
25
services/avatars/Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[package]
|
||||||
|
name = "avatars"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
libpk = { path = "../../lib/libpk" }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
data-encoding = "2.5.0"
|
||||||
|
form_urlencoded = "1.2.1"
|
||||||
|
futures = { workspace = true }
|
||||||
|
gif = "0.13.1"
|
||||||
|
image = { version = "0.24.8", default-features = false, features = ["gif", "jpeg", "png", "webp", "tiff"] }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
rust-s3 = { version = "0.33.0", default-features = false, features = ["tokio-rustls-tls"] }
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
serde = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
thiserror = "1.0.56"
|
||||||
|
time = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
webp = "0.2.6"
|
||||||
21
services/avatars/src/hash.rs
Normal file
21
services/avatars/src/hash.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use std::fmt::Display;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Hash([u8; 32]);
|
||||||
|
|
||||||
|
impl Hash {
|
||||||
|
pub fn sha256(data: &[u8]) -> Hash {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(data);
|
||||||
|
Hash(hasher.finalize().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Hash {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let encoding = data_encoding::BASE32_NOPAD;
|
||||||
|
write!(f, "{}", encoding.encode(&self.0[..16]).to_lowercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
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