Merge branch 'main' of https://github.com/rladenson/PluralKit into logclean_annabelle

This commit is contained in:
rladenson 2024-11-18 23:52:49 -07:00
commit 1c7f950dae
265 changed files with 10696 additions and 2964 deletions

3
.cargo/config.toml Normal file
View file

@ -0,0 +1,3 @@
[build]
rustflags = ["-C", "target-cpu=native"]

View file

@ -3,14 +3,19 @@
# todo: don't use docker/build-push-action
# todo: run builds on pull request
name: Build and push API Docker image
name: Build and push Rust service Docker images
on:
push:
branches:
- main
paths:
- 'lib/pklib/**'
- 'lib/libpk/**'
- 'services/api/**'
- 'services/gateway/**'
- 'services/avatars/**'
- '.github/workflows/rust.yml'
- 'ci/Dockerfile.rust'
- 'ci/rust-docker-target.sh'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
deploy:
@ -35,21 +40,15 @@ jobs:
with:
# https://github.com/docker/build-push-action/issues/378
context: .
file: Dockerfile.rust
file: ci/Dockerfile.rust
push: false
cache-from: type=gha
cache-to: type=gha
cache-from: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust
cache-to: type=registry,ref=ghcr.io/pluralkit/docker-cache:rust,mode=max
outputs: .docker-bin
# add more binaries here
- run: |
for binary in "api"; 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 - .
done
if [ "${{ github.repository }}" == "PluralKit/PluralKit" ]; then
docker push ghcr.io/pluralkit/$binary:${{ env.BRANCH_NAME }}
docker push ghcr.io/pluralkit/$binary:${{ github.sha }}
[ "${{ env.BRANCH_NAME }}" == "main" ] && docker push ghcr.io/pluralkit/$binary:latest
fi
done
tag=${{ github.sha }} \
branch=${{ env.BRANCH_NAME }} \
push=$([ "${{ github.repository }}" == "PluralKit/PluralKit" ] && echo true || echo false) \
ci/rust-docker-target.sh

16
.github/workflows/rustfmt.yml vendored Normal file
View 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

View file

@ -2,8 +2,8 @@ name: Build scheduled tasks runner Docker image
on:
push:
branches: [main]
paths:
- .github/workflows/scheduled_tasks.yml
- 'services/scheduled_tasks/**'
jobs:

1
.gitignore vendored
View file

@ -9,6 +9,7 @@ target/
.idea/
.run/
.vscode/
.mono/
tags/
.DS_Store
mono_crash*

3493
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,45 @@
[workspace]
members = [
"./lib/libpk",
"./services/api"
"./services/api",
"./services/dispatch",
"./services/gateway",
"./services/avatars"
]
[workspace.dependencies]
anyhow = "1"
fred = { version = "5.2.0", default-features = false, features = ["tracing", "pool-prefer-active"] }
axum = "0.7.5"
axum-macros = "0.4.1"
bytes = "1.6.0"
chrono = "0.4"
fred = { version = "9.3.0", default-features = false, features = ["tracing", "i-keys", "i-hashes", "i-scripts", "sha-1"] }
futures = "0.3.30"
lazy_static = "1.4.0"
metrics = "0.20.1"
serde = "1.0.152"
tokio = { version = "1.25.0", features = ["full"] }
tracing = "0.1.37"
metrics = "0.23.0"
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"
signal-hook = "0.3.17"
sqlx = { version = "0.8.2", features = ["runtime-tokio", "postgres", "time", "macros", "uuid"] }
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"] }
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-types = "0.12"
prost-build = "0.12"

View file

@ -1,5 +0,0 @@
FROM alpine:latest
COPY /.docker-bin/__BINARY__ /bin/__BINARY__
CMD ["/bin/__BINARY__"]

View file

@ -1,34 +0,0 @@
FROM alpine:latest AS builder
WORKDIR /build
RUN apk add rustup build-base
# todo: arm64 target
RUN rustup-init --default-host x86_64-unknown-linux-musl --default-toolchain stable --profile default -y
ENV PATH=/root/.cargo/bin:$PATH
ENV RUSTFLAGS='-C link-arg=-s'
RUN cargo install cargo-chef --locked
# build dependencies first to cache
FROM builder AS recipe-builder
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM builder AS binary-builder
COPY --from=recipe-builder /build/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json --target x86_64-unknown-linux-musl
COPY Cargo.toml /build/
COPY Cargo.lock /build/
# this needs to match workspaces in Cargo.toml
COPY lib/libpk /build/lib/libpk
COPY services/api/ /build/services/api
RUN cargo build --bin api --release --target x86_64-unknown-linux-musl
FROM scratch
COPY --from=binary-builder /build/target/x86_64-unknown-linux-musl/release/api /api

View file

@ -100,15 +100,19 @@ public static class DiscordCacheExtensions
await cache.SaveChannel(thread);
}
public static async Task<PermissionSet> PermissionsIn(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)
{
var userId = cache.GetOwnUser();
var member = await cache.TryGetSelfMember(channel.GuildId.Value);
return await cache.PermissionsFor(channelId, userId, member);
return await cache.PermissionsFor2(guildId, channelId, userId, member);
}
return PermissionSet.Dm;

View file

@ -0,0 +1,188 @@
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;
// todo: there should not be infra-specific code here
if (cluster.Contains(".service.consul") || cluster.Contains("process.pluralkit-gateway.internal"))
// 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;
}
}

View file

@ -18,11 +18,9 @@ public interface IDiscordCache
internal ulong GetOwnUser();
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<GuildMemberPartial?> TryGetSelfMember(ulong guildId);
public Task<Role?> TryGetRole(ulong roleId);
public IAsyncEnumerable<Guild> GetAllGuilds();
public Task<IEnumerable<Channel>> GetGuildChannels(ulong guildId);
}

View file

@ -137,7 +137,7 @@ public class MemoryDiscordCache: IDiscordCache
return Task.FromResult(cg?.Guild);
}
public Task<Channel?> TryGetChannel(ulong channelId)
public Task<Channel?> TryGetChannel(ulong _, ulong channelId)
{
_channels.TryGetValue(channelId, out var channel);
return Task.FromResult(channel);
@ -155,19 +155,6 @@ public class MemoryDiscordCache: IDiscordCache
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)
{
if (!_guilds.TryGetValue(guildId, out var guild))

View file

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

View file

@ -13,27 +13,13 @@ public static class CacheExtensions
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");
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,
ulong userId)
{
@ -47,9 +33,9 @@ public static class CacheExtensions
}
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;
var restChannel = await rest.GetChannel(channelId);
@ -58,13 +44,14 @@ public static class CacheExtensions
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())
return channel;
var parent = await cache.GetChannel(channel.ParentId!.Value);
var parent = await cache.TryGetChannel(guildId, channel.ParentId!.Value);
if (parent == null) throw new Exception($"failed to find parent channel for thread {channelOrThread} in cache");
return parent;
}
}

View file

@ -31,31 +31,27 @@ public static class PermissionExtensions
PermissionSet.AttachFiles |
PermissionSet.EmbedLinks;
public static Task<PermissionSet> PermissionsFor(this IDiscordCache cache, MessageCreateEvent message) =>
PermissionsFor(cache, message.ChannelId, message.Author.Id, message.Member, message.WebhookId != null);
public static Task<PermissionSet> PermissionsForMCE(this IDiscordCache cache, MessageCreateEvent message) =>
PermissionsFor2(cache, message.GuildId ?? 0, message.ChannelId, message.Author.Id, message.Member, message.WebhookId != null);
public static Task<PermissionSet>
PermissionsFor(this IDiscordCache cache, ulong channelId, GuildMember member) =>
PermissionsFor(cache, channelId, member.User.Id, member);
PermissionsForMemberInChannel(this IDiscordCache cache, ulong guildId, ulong channelId, GuildMember member) =>
PermissionsFor2(cache, guildId, channelId, member.User.Id, member);
public static async Task<PermissionSet> PermissionsFor(this IDiscordCache cache, ulong channelId, ulong userId,
GuildMemberPartial? member, bool isWebhook = false,
bool isThread = false)
public static async Task<PermissionSet> PermissionsFor2(this IDiscordCache cache, ulong guildId, ulong channelId, ulong userId,
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
return PermissionSet.Dm;
if (channel.GuildId == null)
return PermissionSet.Dm;
var rootChannel = await cache.GetRootChannel(channelId);
var rootChannel = await cache.GetRootChannel(guildId, channelId);
var guild = await cache.GetGuild(channel.GuildId.Value);
if (isWebhook)
return EveryonePermissions(guild);
return PermissionsFor(guild, rootChannel, userId, member, isThread: isThread);
}
@ -79,9 +75,6 @@ public static class PermissionExtensions
return perms;
}
public static PermissionSet PermissionsFor(Guild guild, Channel channel, MessageCreateEvent msg, bool isThread = false) =>
PermissionsFor(guild, channel, msg.Author.Id, msg.Member, isThread: isThread);
public static PermissionSet PermissionsFor(Guild guild, Channel channel, ulong userId,
GuildMemberPartial? member, bool isThread = false)
{

View file

@ -72,7 +72,8 @@ public class ShardConnection: IAsyncDisposable
}
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"
await CloseInner(WebSocketCloseStatus.NormalClosure, null);
}

View file

@ -159,6 +159,9 @@ public class DiscordApiClient
public Task<Channel> CreateDm(ulong recipientId) =>
_client.Post<Channel>("/users/@me/channels", ("CreateDM", default), new CreateDmRequest(recipientId))!;
public Task<RefreshedUrlsResponse> RefreshUrls(string[] urls) =>
_client.Post<RefreshedUrlsResponse>("/attachments/refresh-urls", ("RefreshUrls", default), new RefreshUrlsRequest(urls));
private static string EncodeEmoji(Emoji emoji) =>
WebUtility.UrlEncode(emoji.Id != null ? $"{emoji.Name}:{emoji.Id}" : emoji.Name) ??
throw new ArgumentException("Could not encode emoji");

View file

@ -13,4 +13,14 @@ public record ExecuteWebhookRequest
public AllowedMentions? AllowedMentions { get; init; }
public bool? Tts { get; init; }
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; }
}
}

View file

@ -0,0 +1,3 @@
namespace Myriad.Rest.Types.Requests;
public record RefreshUrlsRequest(string[] AttachmentUrls);

View file

@ -15,4 +15,7 @@ public record WebhookMessageEditRequest
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Optional<Embed[]?> Embeds { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Optional<Message.Attachment[]?> Attachments { get; init; }
}

View file

@ -3,6 +3,7 @@ namespace Myriad.Types;
public record Activity
{
public string Name { get; init; }
public string State { get; init; }
public ActivityType Type { get; init; }
public string? Url { get; init; }
}

View file

@ -70,6 +70,8 @@ public record Message
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public Optional<Message?> ReferencedMessage { get; init; }
public MessagePoll? Poll { get; init; }
// public MessageComponent[]? Components { get; init; }
public record Reference(ulong? GuildId, ulong? ChannelId, ulong? MessageId);
@ -96,4 +98,17 @@ public record Message
public bool Me { 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; }
}
}

View file

@ -0,0 +1,11 @@
namespace Myriad.Types;
public record RefreshedUrlsResponse
{
public record RefreshedUrl
{
public string Original;
public string Refreshed;
}
public RefreshedUrl[] RefreshedUrls;
}

View file

@ -5,4 +5,5 @@ public class ApiConfig
public int Port { get; set; } = 5000;
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public bool TrustAuth { get; set; } = false;
}

View file

@ -13,19 +13,13 @@ public class AuthorizationTokenHandlerMiddleware
_next = next;
}
public async Task Invoke(HttpContext ctx, IDatabase db)
public async Task Invoke(HttpContext ctx, IDatabase db, ApiConfig cfg)
{
ctx.Request.Headers.TryGetValue("authorization", out var authHeaders);
if (authHeaders.Count > 0)
{
var systemId = await db.Execute(conn => conn.QuerySingleOrDefaultAsync<SystemId?>(
"select id from systems where token = @token",
new { token = authHeaders[0] }
));
if (systemId != null)
ctx.Items.Add("SystemId", systemId);
}
if (cfg.TrustAuth
&& ctx.Request.Headers.TryGetValue("X-PluralKit-SystemId", out var sidHeaders)
&& sidHeaders.Count > 0
&& int.TryParse(sidHeaders[0], out var systemId))
ctx.Items.Add("SystemId", new SystemId(systemId));
await _next.Invoke(ctx);
}

View file

@ -9,7 +9,6 @@ namespace PluralKit.API;
public class PKControllerBase: ControllerBase
{
private readonly Guid _requestId = Guid.NewGuid();
private readonly Regex _shortIdRegex = new("^[a-z]{5}$");
private readonly Regex _snowflakeRegex = new("^[0-9]{17,19}$");
private List<PKMember>? _memberLookupCache { get; set; }
@ -46,8 +45,8 @@ public class PKControllerBase: ControllerBase
if (_snowflakeRegex.IsMatch(systemRef))
return _repo.GetSystemByAccount(ulong.Parse(systemRef));
if (_shortIdRegex.IsMatch(systemRef))
return _repo.GetSystemByHid(systemRef);
if (systemRef.TryParseHid(out var hid))
return _repo.GetSystemByHid(hid);
return Task.FromResult<PKSystem?>(null);
}
@ -71,8 +70,8 @@ public class PKControllerBase: ControllerBase
if (Guid.TryParse(memberRef, out var guid))
return await _repo.GetMemberByGuid(guid);
if (_shortIdRegex.IsMatch(memberRef))
return await _repo.GetMemberByHid(memberRef);
if (memberRef.TryParseHid(out var hid))
return await _repo.GetMemberByHid(hid);
return null;
}
@ -96,8 +95,8 @@ public class PKControllerBase: ControllerBase
if (Guid.TryParse(groupRef, out var guid))
return await _repo.GetGroupByGuid(guid);
if (_shortIdRegex.IsMatch(groupRef))
return await _repo.GetGroupByHid(groupRef);
if (groupRef.TryParseHid(out var hid))
return await _repo.GetGroupByHid(hid);
return null;
}

View file

@ -53,7 +53,7 @@ public class AutoproxyControllerV2: PKControllerBase
private async Task<IActionResult> Patch(PKSystem system, ulong? guildId, ulong? channelId, JObject data, AutoproxySettings oldData)
{
var updateMember = data.ContainsKey("autoproxy_member");
var updateMember = data.ContainsKey("autoproxy_member") && data.Value<string>("autoproxy_member") != null;
PKMember? member = null;
if (updateMember)
@ -64,15 +64,37 @@ public class AutoproxyControllerV2: PKControllerBase
}
var patch = AutoproxyPatch.FromJson(data, member?.Id);
patch.AssertIsValid();
var newAutoproxyMode = patch.AutoproxyMode.IsPresent ? patch.AutoproxyMode : oldData.AutoproxyMode;
var newAutoproxyMember = patch.AutoproxyMember.IsPresent ? patch.AutoproxyMember : oldData.AutoproxyMember;
if (updateMember && member == null)
{
patch.Errors.Add(new("autoproxy_member", "Member not found."));
if (updateMember && !(
(patch.AutoproxyMode.IsPresent && patch.AutoproxyMode.Value == AutoproxyMode.Member)
|| (!patch.AutoproxyMode.IsPresent && oldData.AutoproxyMode == AutoproxyMode.Member))
)
patch.Errors.Add(new("autoproxy_member", "Cannot update autoproxy member if autoproxy mode is set to latch"));
}
if (newAutoproxyMode.Value == AutoproxyMode.Member)
{
if (!updateMember)
{
patch.Errors.Add(new("autoproxy_member", "An autoproxy member must be supplied for autoproxy mode 'member'"));
}
patch.AutoproxyMode = newAutoproxyMode;
patch.AutoproxyMember = newAutoproxyMember;
}
else
{
if (updateMember)
{
patch.Errors.Add(new("autoproxy_member", "Cannot update autoproxy member if autoproxy mode is not set to 'member'"));
}
patch.AutoproxyMode = newAutoproxyMode;
patch.AutoproxyMember = null;
}
if (patch.Errors.Count > 0)
throw new ModelParseError(patch.Errors);

View file

@ -21,9 +21,6 @@ public class GroupControllerV2: PKControllerBase
var ctx = ContextFor(system);
if (with_members && !system.MemberListPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedMemberList;
if (!system.GroupListPrivacy.CanAccess(ContextFor(system)))
throw Errors.UnauthorizedGroupList;
@ -31,20 +28,17 @@ public class GroupControllerV2: PKControllerBase
var j_groups = await groups
.Where(g => g.Visibility.CanAccess(ctx))
.Select(g => g.ToJson(ctx, needsMembersArray: with_members))
.Select(g => g.ToJson(ctx, needsMembersArray: with_members, systemStr: system.Hid))
.ToListAsync();
if (with_members && !system.MemberListPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedMemberList;
if (with_members && j_groups.Count > 0)
{
var q = await _repo.GetGroupMemberInfo(await groups
.Where(g => g.Visibility.CanAccess(ctx))
.Where(g => g.ListPrivacy.CanAccess(ctx))
.Select(x => x.Id)
.ToListAsync());
foreach (var row in q)
if (row.MemberVisibility.CanAccess(ctx))
((JArray)j_groups.Find(x => x.Value<string>("id") == row.Group)["members"]).Add(row.MemberUuid);
@ -86,7 +80,7 @@ public class GroupControllerV2: PKControllerBase
await tx.CommitAsync();
return Ok(newGroup.ToJson(LookupContext.ByOwner));
return Ok(newGroup.ToJson(LookupContext.ByOwner, system.Hid));
}
[HttpGet("groups/{groupRef}")]
@ -133,7 +127,7 @@ public class GroupControllerV2: PKControllerBase
throw new ModelParseError(patch.Errors);
var newGroup = await _repo.UpdateGroup(group.Id, patch);
return Ok(newGroup.ToJson(LookupContext.ByOwner));
return Ok(newGroup.ToJson(LookupContext.ByOwner, system.Hid));
}
[HttpDelete("groups/{groupRef}")]

View file

@ -19,17 +19,19 @@ public class GroupMemberControllerV2: PKControllerBase
if (group == null)
throw Errors.GroupNotFound;
var ctx = ContextFor(group);
if (!group.ListPrivacy.CanAccess(ctx))
throw Errors.UnauthorizedGroupMemberList;
var system = await _repo.GetSystem(group.System);
var members = _repo.GetGroupMembers(group.Id).Where(m => m.MemberVisibility.CanAccess(ctx));
var o = new JArray();
await foreach (var member in members)
o.Add(member.ToJson(ctx));
o.Add(member.ToJson(ctx, systemStr: system.Hid));
return Ok(o);
}
@ -147,6 +149,8 @@ public class GroupMemberControllerV2: PKControllerBase
public async Task<IActionResult> GetMemberGroups(string memberRef)
{
var member = await ResolveMember(memberRef);
if (member == null)
throw Errors.MemberNotFoundWithRef(memberRef);
var ctx = ContextFor(member);
var system = await _repo.GetSystem(member.System);
@ -158,7 +162,7 @@ public class GroupMemberControllerV2: PKControllerBase
var o = new JArray();
await foreach (var group in groups)
o.Add(group.ToJson(ctx));
o.Add(group.ToJson(ctx, system.Hid));
return Ok(o);
}

View file

@ -28,7 +28,7 @@ public class MemberControllerV2: PKControllerBase
var members = _repo.GetSystemMembers(system.Id);
return Ok(await members
.Where(m => m.MemberVisibility.CanAccess(ctx))
.Select(m => m.ToJson(ctx))
.Select(m => m.ToJson(ctx, systemStr: system.Hid))
.ToListAsync());
}
@ -64,7 +64,7 @@ public class MemberControllerV2: PKControllerBase
await tx.CommitAsync();
return Ok(newMember.ToJson(LookupContext.ByOwner));
return Ok(newMember.ToJson(LookupContext.ByOwner, systemStr: system.Hid));
}
[HttpGet("members/{memberRef}")]
@ -111,7 +111,7 @@ public class MemberControllerV2: PKControllerBase
throw new ModelParseError(patch.Errors);
var newMember = await _repo.UpdateMember(member.Id, patch);
return Ok(newMember.ToJson(LookupContext.ByOwner));
return Ok(newMember.ToJson(LookupContext.ByOwner, systemStr: system.Hid));
}
[HttpDelete("members/{memberRef}")]

View file

@ -39,7 +39,7 @@ public class SwitchControllerV2: PKControllerBase
var res = await _db.Execute(conn => conn.QueryAsync<SwitchesReturnNew>(
@"select *, array(
select members.hid from switch_members, members
select trim(members.hid) from switch_members, members
where switch_members.switch = switches.id and members.id = switch_members.member
) as members from switches
where switches.system = @System and switches.timestamp < @Before
@ -70,7 +70,7 @@ public class SwitchControllerV2: PKControllerBase
return Ok(new FrontersReturnNew
{
Timestamp = sw.Timestamp,
Members = await members.Select(m => m.ToJson(ctx)).ToListAsync(),
Members = await members.Select(m => m.ToJson(ctx, systemStr: system.Hid)).ToListAsync(),
Uuid = sw.Uuid,
});
}
@ -124,7 +124,7 @@ public class SwitchControllerV2: PKControllerBase
{
Uuid = newSwitch.Uuid,
Timestamp = data.Timestamp != null ? data.Timestamp.Value : newSwitch.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner)),
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, systemStr: system.Hid)),
});
}
@ -153,7 +153,7 @@ public class SwitchControllerV2: PKControllerBase
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = await members.Select(m => m.ToJson(ctx)).ToListAsync()
Members = await members.Select(m => m.ToJson(ctx, systemStr: system.Hid)).ToListAsync()
});
}
@ -190,7 +190,7 @@ public class SwitchControllerV2: PKControllerBase
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner))
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, systemStr: system.Hid))
});
}
@ -238,7 +238,7 @@ public class SwitchControllerV2: PKControllerBase
{
Uuid = sw.Uuid,
Timestamp = sw.Timestamp,
Members = members.Select(x => x.ToJson(LookupContext.ByOwner))
Members = members.Select(x => x.ToJson(LookupContext.ByOwner, systemStr: system.Hid))
});
}

View file

@ -368,8 +368,8 @@
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "3.1.10",
"contentHash": "bKHbgzbGsPZbEaExRaJqBz3WQ1GfhMttM23e1nivLJ8HbA3Ad526mW2G2K350q3Dc3HG83I5W8uSZWG4Rv4IpA=="
"resolved": "6.0.0",
"contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
@ -398,8 +398,8 @@
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.NETCore.Targets": {
"type": "Transitive",
@ -416,23 +416,6 @@
"System.Runtime": "4.3.0"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==",
"dependencies": {
"System.Security.AccessControl": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0"
}
},
"NETStandard.Library": {
"type": "Transitive",
"resolved": "1.6.1",
@ -516,8 +499,8 @@
},
"Npgsql": {
"type": "Transitive",
"resolved": "4.1.5",
"contentHash": "juDlNse+SKfXRP0VSgpJkpdCcaVLZt8m37EHdRX+8hw+GG69Eat1Y0MdEfl+oetdOnf9E133GjIDEjg9AF6HSQ==",
"resolved": "4.1.13",
"contentHash": "p79cObfuRgS8KD5sFmQUqVlINEkJm39bCrzRclicZE1942mKcbLlc0NdoVKhBeZPv//prK/sVTUmRVxdnoPCoA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.6.0"
}
@ -533,10 +516,10 @@
},
"Pipelines.Sockets.Unofficial": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "7hzHplEIVOGBl5zOQZGX/DiJDHjq+RVRVrYgDiqXb6RriqWAdacXxp+XO9WSrATCEXyNOUOQg9aqQArsjase/A==",
"resolved": "2.2.8",
"contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==",
"dependencies": {
"System.IO.Pipelines": "5.0.0"
"System.IO.Pipelines": "5.0.1"
}
},
"runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
@ -797,11 +780,11 @@
},
"StackExchange.Redis": {
"type": "Transitive",
"resolved": "2.2.88",
"contentHash": "JJi1jcO3/ZiamBhlsC/TR8aZmYf+nqpGzMi0HRRCy5wJkUPmMnRp0kBA6V84uhU8b531FHSdTDaFCAyCUJomjA==",
"resolved": "2.8.16",
"contentHash": "WaoulkOqOC9jHepca3JZKFTqndCWab5uYS7qCzmiQDlrTkFaDN7eLSlEfHycBxipRnQY9ppZM7QSsWAwUEGblw==",
"dependencies": {
"Pipelines.Sockets.Unofficial": "2.2.0",
"System.Diagnostics.PerformanceCounter": "5.0.0"
"Microsoft.Extensions.Logging.Abstractions": "6.0.0",
"Pipelines.Sockets.Unofficial": "2.2.8"
}
},
"System.AppContext": {
@ -844,15 +827,6 @@
"System.Threading.Tasks": "4.3.0"
}
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "aM7cbfEfVNlEEOj3DsZP+2g9NRwbkyiAv2isQEzw7pnkDg9ekCU2m1cdJLM02Uq691OaCS91tooaxcEn8d0q5w==",
"dependencies": {
"System.Security.Cryptography.ProtectedData": "5.0.0",
"System.Security.Permissions": "5.0.0"
}
},
"System.Console": {
"type": "Transitive",
"resolved": "4.3.0",
@ -880,17 +854,6 @@
"resolved": "4.7.1",
"contentHash": "j81Lovt90PDAq8kLpaJfJKV/rWdWuEk6jfV+MBkee33vzYLEUsy4gXK8laa9V2nZlLM9VM9yA/OOQxxPEJKAMw=="
},
"System.Diagnostics.PerformanceCounter": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "kcQWWtGVC3MWMNXdMDWfrmIlFZZ2OdoeT6pSNVRtk9+Sa7jwdPiMlNwb0ZQcS7NRlT92pCfmjRtkSWUW3RAKwg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0",
"Microsoft.Win32.Registry": "5.0.0",
"System.Configuration.ConfigurationManager": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"System.Diagnostics.Tools": {
"type": "Transitive",
"resolved": "4.3.0",
@ -911,14 +874,6 @@
"System.Runtime": "4.3.0"
}
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "SztFwAnpfKC8+sEKXAFxCBWhKQaEd97EiOL7oZJZP56zbqnLpmxACWA8aGseaUExciuEAUuR9dY8f7HkTRAdnw==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "5.0.0"
}
},
"System.Dynamic.Runtime": {
"type": "Transitive",
"resolved": "4.0.11",
@ -1058,8 +1013,8 @@
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "irMYm3vhVgRsYvHTU5b2gsT2CwT/SMM6LZFzuJjpIvT5Z4CshxNsaoBC1X/LltwuR3Opp8d6jOS/60WwOb7Q2Q=="
"resolved": "5.0.1",
"contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg=="
},
"System.Linq": {
"type": "Transitive",
@ -1322,15 +1277,6 @@
"System.Runtime.Extensions": "4.3.0"
}
},
"System.Security.AccessControl": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"System.Security.Cryptography.Algorithms": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1443,11 +1389,6 @@
"System.Threading.Tasks": "4.3.0"
}
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "HGxMSAFAPLNoxBvSfW08vHde0F9uh7BjASwu6JF9JnXuEPhCY3YUqURn0+bQV/4UWeaqymmrHWV+Aw9riQCtCA=="
},
"System.Security.Cryptography.X509Certificates": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1480,20 +1421,6 @@
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Permissions": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "uE8juAhEkp7KDBCdjDIE3H9R1HJuEHqeqX8nLX9gmYKWwsqk3T5qZlPx8qle5DPKimC/Fy3AFTdV7HamgCh9qQ==",
"dependencies": {
"System.Security.AccessControl": "5.0.0",
"System.Windows.Extensions": "5.0.0"
}
},
"System.Security.Principal.Windows": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA=="
},
"System.Text.Encoding": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1567,14 +1494,6 @@
"System.Runtime": "4.3.0"
}
},
"System.Windows.Extensions": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "c1ho9WU9ZxMZawML+ssPKZfdnrg/OjR3pe0m9v8230z3acqphwvPJqzAkH54xRYm5ntZHGG1EPP3sux9H3qSPg==",
"dependencies": {
"System.Drawing.Common": "5.0.0"
}
},
"System.Xml.ReaderWriter": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1637,7 +1556,7 @@
"Newtonsoft.Json": "[13.0.1, )",
"NodaTime": "[3.0.3, )",
"NodaTime.Serialization.JsonNet": "[3.0.0, )",
"Npgsql": "[4.1.5, )",
"Npgsql": "[4.1.13, )",
"Npgsql.NodaTime": "[4.1.5, )",
"Serilog": "[2.12.0, )",
"Serilog.Extensions.Logging": "[3.0.1, )",
@ -1650,7 +1569,7 @@
"Serilog.Sinks.Seq": "[5.2.2, )",
"SqlKata": "[2.3.7, )",
"SqlKata.Execution": "[2.3.7, )",
"StackExchange.Redis": "[2.2.88, )",
"StackExchange.Redis": "[2.8.16, )",
"System.Interactive.Async": "[5.0.0, )",
"ipnetwork2": "[2.5.381, )"
}

View file

@ -12,7 +12,7 @@ public partial class ApplicationCommandTree
else if (ctx.Event.Data!.Name == ProxiedMessageDelete.Name)
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.DeleteMessage(ctx));
else if (ctx.Event.Data!.Name == ProxiedMessagePing.Name)
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.PingMessageAuthor(ctx));
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessagePing, m => m.PingMessageAuthor(ctx));
return null;
}

View file

@ -48,11 +48,12 @@ public class ApplicationCommandProxiedMessage
msg.System,
msg.Member,
guild,
ctx.Config,
LookupContext.ByNonOwner,
DateTimeZone.Utc
));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent, ctx.Config));
await ctx.Reply(embeds: embeds.ToArray());
}
@ -62,14 +63,14 @@ public class ApplicationCommandProxiedMessage
var messageId = ctx.Event.Data!.TargetId!.Value;
// check for command messages
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
if (authorId != null)
var cmessage = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
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.");
var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == channelId;
await DeleteMessageInner(ctx, channelId!.Value, messageId, isDM);
var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == cmessage.ChannelId;
await DeleteMessageInner(ctx, cmessage.GuildId, cmessage.ChannelId, messageId, isDM);
return;
}
@ -77,10 +78,11 @@ public class ApplicationCommandProxiedMessage
var message = await ctx.Repository.GetFullMessage(messageId);
if (message != null)
{
if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.User.Id)
// if user has has a system and their system sent the message, or if user sent the message, do not error
if (!((ctx.System != null && message.System?.Id == ctx.System.Id) || message.Message.Sender == ctx.User.Id))
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;
}
@ -88,9 +90,9 @@ public class ApplicationCommandProxiedMessage
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.PermissionsIn(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."
+ " Please contact a server administrator to remedy this.");
@ -100,6 +102,14 @@ public class ApplicationCommandProxiedMessage
public async Task PingMessageAuthor(InteractionContext ctx)
{
// if the command message was sent by a user account with bot usage disallowed, ignore it
var abuse_log = await _repo.GetAbuseLogByAccount(ctx.User.Id);
if (abuse_log != null && abuse_log.DenyBotUsage)
{
await ctx.Defer();
return;
}
var messageId = ctx.Event.Data!.TargetId!.Value;
var msg = await ctx.Repository.GetFullMessage(messageId);
if (msg == null)
@ -109,7 +119,7 @@ public class ApplicationCommandProxiedMessage
// (if not, PK shouldn't send messages on their behalf)
var member = await _rest.GetGuildMember(ctx.GuildId, ctx.User.Id);
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
if (member == null || !(await _cache.PermissionsFor(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.");
};

View file

@ -67,8 +67,9 @@ public class Bot
{
new Activity
{
Type = ActivityType.Game,
Name = BotStatus
Type = ActivityType.Custom,
Name = BotStatus,
State = BotStatus
}
}
};
@ -83,9 +84,16 @@ public class Bot
// This *probably* doesn't matter in practice but I jut think it's neat, y'know.
var timeNow = SystemClock.Instance.GetCurrentInstant();
var timeTillNextWholeMinute = TimeSpan.FromMilliseconds(60000 - timeNow.ToUnixTimeMilliseconds() % 60000 + 250);
_periodicTask = new Timer(_ =>
_periodicTask = new Timer(async _ =>
{
var __ = UpdatePeriodic();
try
{
await UpdatePeriodic();
}
catch (Exception e)
{
_logger.Error(e, "failed to run once-per-minute scheduled task");
}
}, null, timeTillNextWholeMinute, TimeSpan.FromMinutes(1));
}
@ -93,9 +101,7 @@ public class Bot
{
// we HandleGatewayEvent **before** getting the own user, because the own user is set in HandleGatewayEvent for ReadyEvent
await _cache.HandleGatewayEvent(evt);
await _cache.TryUpdateSelfMember(_config.ClientId, evt);
await OnEventReceivedInner(shardId, evt);
}
@ -134,7 +140,8 @@ public class Bot
new Activity
{
Name = "Restarting... (please wait)",
Type = ActivityType.Game
State = "Restarting... (please wait)",
Type = ActivityType.Custom
}
},
Status = GatewayStatusUpdate.UserStatus.Idle
@ -166,7 +173,16 @@ public class Bot
}
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);
try
@ -234,7 +250,7 @@ public class Bot
if (!exc.ShowToUser()) return;
// 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 (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand)
@ -242,7 +258,7 @@ public class Bot
return;
}
var botPerms = await _cache.PermissionsIn(reportChannel.Value);
var botPerms = await _cache.BotPermissionsIn(guildId ?? 0, reportChannel.Value);
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
await _errorMessageService.SendErrorMessage(reportChannel.Value, sentryEvent.EventId.ToString());
}
@ -269,7 +285,8 @@ public class Bot
new Activity
{
Name = BotStatus,
Type = ActivityType.Game
State = BotStatus,
Type = ActivityType.Custom,
}
},
Status = GatewayStatusUpdate.UserStatus.Online

View file

@ -20,11 +20,14 @@ public class BotConfig
public string? GatewayQueueUrl { get; set; }
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? DiscordBaseUrl { get; set; }
public string? AvatarServiceUrl { get; set; }
public bool DisableErrorReporting { get; set; } = false;

View file

@ -136,4 +136,11 @@ public static class BotMetrics
DurationUnit = TimeUnit.Seconds,
Context = "Bot"
};
public static MeterOptions CacheDebug => new()
{
Name = "Bad responses to cache lookups",
Context = "Bot",
MeasurementUnit = Unit.Calls
};
}

View file

@ -30,6 +30,8 @@ public partial class CommandTree
public static Command ConfigShowPrivate = new Command("config show private", "config show private [on|off]", "Sets whether private information is shown to linked accounts by default");
public static Command ConfigMemberDefaultPrivacy = new("config private member", "config private member [on|off]", "Sets whether member privacy is automatically set to private when creating a new member");
public static Command ConfigGroupDefaultPrivacy = new("config private group", "config private group [on|off]", "Sets whether group privacy is automatically set to private when creating a new group");
public static Command ConfigProxySwitch = new Command("config proxyswitch", "config proxyswitch [on|off]", "Sets whether to log a switch every time a proxy tag is used");
public static Command ConfigNameFormat = new Command("config nameformat", "config nameformat [format]", "Changes your system's username formatting");
public static Command AutoproxySet = new Command("autoproxy", "autoproxy [off|front|latch|member]", "Sets your system's autoproxy mode for the current server");
public static Command AutoproxyOff = new Command("autoproxy off", "autoproxy off", "Disables autoproxying for your system in the current server");
public static Command AutoproxyFront = new Command("autoproxy front", "autoproxy front", "Sets your system's autoproxy in this server to proxy the first member currently registered as front");
@ -58,7 +60,7 @@ public partial class CommandTree
public static Command MemberServerKeepProxy = new Command("member server keepproxy", "member <member> serverkeepproxy [on|off|clear]", "Sets whether to include a member's proxy tags when proxying in the current server.");
public static Command MemberRandom = new Command("system random", "system [system] random", "Shows the info card of a randomly selected member in a system.");
public static Command MemberId = new Command("member id", "member [member] id", "Prints a member's id.");
public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
public static Command MemberPrivacy = new Command("member privacy", "member <member> privacy <name|description|birthday|pronouns|proxy|metadata|visibility|all> <public|private>", "Changes a members's privacy settings");
public static Command GroupInfo = new Command("group", "group <name>", "Looks up information about a group");
public static Command GroupNew = new Command("group new", "group new <name>", "Creates a new group");
public static Command GroupList = new Command("group list", "group list", "Lists all groups in this system");
@ -82,6 +84,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 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 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 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");
@ -92,19 +95,22 @@ public partial class CommandTree
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 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 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 ProxyCheck = new Command("debug proxy", "debug proxy [link|reply]", "Checks why your message has not been proxied");
public static Command LogChannel = new Command("log channel", "log channel <channel>", "Designates a channel to post proxied messages to");
public static Command LogChannelClear = new Command("log channel", "log channel -clear", "Clears the currently set log channel");
public static Command LogEnable = new Command("log enable", "log enable all|<channel> [channel 2] [channel 3...]", "Enables message logging in certain channels");
public static Command LogDisable = new Command("log disable", "log disable all|<channel> [channel 2] [channel 3...]", "Disables message logging in certain channels");
public static Command LogShow = new Command("log show", "log show", "Displays the current list of channels where logging is disabled");
public static Command LogClean = new Command("logclean", "logclean [on|off]", "Toggles whether to clean up other bots' log channels");
public static Command BlacklistShow = new Command("blacklist show", "blacklist show", "Displays the current proxy blacklist");
public static Command BlacklistAdd = new Command("blacklist add", "blacklist add all|<channel> [channel 2] [channel 3...]", "Adds certain channels to the proxy blacklist");
public static Command BlacklistRemove = new Command("blacklist remove", "blacklist remove all|<channel> [channel 2] [channel 3...]", "Removes certain channels from the proxy blacklist");
public static Command LogChannel = new Command("serverconfig log channel", "serverconfig log channel <channel>", "Designates a channel to post proxied messages to");
public static Command LogChannelClear = new Command("serverconfig log channel", "serverconfig log channel -clear", "Clears the currently set log channel");
public static Command LogEnable = new Command("serverconfig log blacklist remove", "serverconfig log blacklist remove all|<channel> [channel 2] [channel 3...]", "Enables message logging in certain channels");
public static Command LogDisable = new Command("serverconfig log blacklist add", "serverconfig log blacklist add all|<channel> [channel 2] [channel 3...]", "Disables message logging in certain channels");
public static Command LogShow = new Command("serverconfig log blacklist", "serverconfig log blacklist", "Displays the current list of channels where logging is disabled");
public static Command BlacklistShow = new Command("serverconfig proxy blacklist", "serverconfig proxy blacklist", "Displays the current list of channels where message proxying is disabled");
public static Command BlacklistAdd = new Command("serverconfig proxy blacklist add", "serverconfig proxy blacklist add all|<channel> [channel 2] [channel 3...]", "Disables message proxying in certain channels");
public static Command BlacklistRemove = new Command("serverconfig blacklist remove", "serverconfig blacklist remove all|<channel> [channel 2] [channel 3...]", "Enables message proxying in certain channels");
public static Command ServerConfigLogClean = new Command("serverconfig log cleanup", "serverconfig log cleanup [on|off]", "Toggles whether to clean up other bots' log channels");
public static Command ServerConfigInvalidCommandResponse = new Command("serverconfig invalid command error", "serverconfig invalid command error [on|off]", "Sets whether to show an error message when an unknown command is sent");
public static Command ServerConfigRequireSystemTag = new Command("serverconfig require tag", "serverconfig require tag [on|off]", "Sets whether server users are required to have a system tag on proxied messages");
public static Command Invite = new Command("invite", "invite", "Gets a link to invite PluralKit to other servers");
public static Command PermCheck = new Command("permcheck", "permcheck <guild>", "Checks whether a server's permission setup is correct");
public static Command Admin = new Command("admin", "admin", "Super secret admin commands (sshhhh)");
@ -137,13 +143,21 @@ public partial class CommandTree
public static Command[] SwitchCommands =
{
Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll
Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut, SwitchDelete, SwitchDeleteAll, SwitchCopy
};
public static Command[] ConfigCommands =
{
ConfigAutoproxyAccount, ConfigAutoproxyTimeout, ConfigTimezone, ConfigPing,
ConfigMemberDefaultPrivacy, ConfigGroupDefaultPrivacy, ConfigShowPrivate
ConfigMemberDefaultPrivacy, ConfigGroupDefaultPrivacy, ConfigShowPrivate,
ConfigProxySwitch, ConfigNameFormat
};
public static Command[] ServerConfigCommands =
{
ServerConfigLogClean, ServerConfigInvalidCommandResponse, ServerConfigRequireSystemTag,
LogChannel, LogChannelClear, LogShow, LogDisable, LogEnable,
BlacklistShow, BlacklistAdd, BlacklistRemove
};
public static Command[] AutoproxyCommands =

View file

@ -18,8 +18,10 @@ public partial class CommandTree
return CommandHelpRoot(ctx);
if (ctx.Match("ap", "autoproxy", "auto"))
return HandleAutoproxyCommand(ctx);
if (ctx.Match("config", "cfg"))
if (ctx.Match("config", "cfg", "configure"))
return HandleConfigCommand(ctx);
if (ctx.Match("serverconfig", "guildconfig", "scfg"))
return HandleServerConfigCommand(ctx);
if (ctx.Match("list", "find", "members", "search", "query", "l", "f", "fd", "ls"))
return ctx.Execute<SystemList>(SystemList, m => m.MemberList(ctx, ctx.System));
if (ctx.Match("link"))
@ -44,36 +46,36 @@ public partial class CommandTree
else return ctx.Execute<Help>(Help, m => m.HelpRoot(ctx));
if (ctx.Match("explain"))
return ctx.Execute<Help>(Explain, m => m.Explain(ctx));
if (ctx.Match("message", "msg"))
if (ctx.Match("message", "msg", "messageinfo"))
return ctx.Execute<ProxiedMessage>(Message, m => m.GetMessage(ctx));
if (ctx.Match("edit", "e"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx));
if (ctx.Match("reproxy", "rp", "crimes"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx, false));
if (ctx.Match("x"))
return ctx.Execute<ProxiedMessage>(MessageEdit, m => m.EditMessage(ctx, true));
if (ctx.Match("reproxy", "rp", "crimes", "crime"))
return ctx.Execute<ProxiedMessage>(MessageReproxy, m => m.ReproxyMessage(ctx));
if (ctx.Match("log"))
if (ctx.Match("channel"))
return ctx.Execute<ServerConfig>(LogChannel, m => m.SetLogChannel(ctx));
return ctx.Execute<ServerConfig>(LogChannel, m => m.SetLogChannel(ctx), true);
else if (ctx.Match("enable", "on"))
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true));
return ctx.Execute<ServerConfig>(LogEnable, m => m.SetLogEnabled(ctx, true), true);
else if (ctx.Match("disable", "off"))
return ctx.Execute<ServerConfig>(LogDisable, m => m.SetLogEnabled(ctx, false));
return ctx.Execute<ServerConfig>(LogDisable, m => m.SetLogEnabled(ctx, false), true);
else if (ctx.Match("list", "show"))
return ctx.Execute<ServerConfig>(LogShow, m => m.ShowLogDisabledChannels(ctx));
else if (ctx.Match("commands"))
return PrintCommandList(ctx, "message logging", LogCommands);
else return PrintCommandExpectedError(ctx, LogCommands);
return ctx.Execute<ServerConfig>(LogShow, m => m.ShowLogDisabledChannels(ctx), true);
else
return ctx.Reply($"{Emojis.Warn} Message logging commands have moved to `pk;serverconfig`.");
if (ctx.Match("logclean"))
return ctx.Execute<ServerConfig>(LogClean, m => m.SetLogCleanup(ctx));
return ctx.Execute<ServerConfig>(ServerConfigLogClean, m => m.SetLogCleanup(ctx), true);
if (ctx.Match("blacklist", "bl"))
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(BlacklistAdd, m => m.SetBlacklisted(ctx, true));
return ctx.Execute<ServerConfig>(BlacklistAdd, m => m.SetProxyBlacklisted(ctx, true), true);
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(BlacklistRemove, m => m.SetBlacklisted(ctx, false));
return ctx.Execute<ServerConfig>(BlacklistRemove, m => m.SetProxyBlacklisted(ctx, false), true);
else if (ctx.Match("list", "show"))
return ctx.Execute<ServerConfig>(BlacklistShow, m => m.ShowBlacklisted(ctx));
else if (ctx.Match("commands"))
return PrintCommandList(ctx, "channel blacklisting", BlacklistCommands);
else return PrintCommandExpectedError(ctx, BlacklistCommands);
return ctx.Execute<ServerConfig>(BlacklistShow, m => m.ShowProxyBlacklisted(ctx), true);
else
return ctx.Reply($"{Emojis.Warn} Blacklist commands have moved to `pk;serverconfig`.");
if (ctx.Match("proxy"))
if (ctx.Match("debug"))
return ctx.Execute<Checks>(ProxyCheck, m => m.MessageProxyCheck(ctx));
@ -89,7 +91,7 @@ public partial class CommandTree
if (ctx.Match("rool")) return ctx.Execute<Fun>(null, m => m.Rool(ctx));
if (ctx.Match("sus")) return ctx.Execute<Fun>(null, m => m.Sus(ctx));
if (ctx.Match("error")) return ctx.Execute<Fun>(null, m => m.Error(ctx));
if (ctx.Match("stats")) return ctx.Execute<Misc>(null, m => m.Stats(ctx));
if (ctx.Match("stats", "status")) return ctx.Execute<Misc>(null, m => m.Stats(ctx));
if (ctx.Match("permcheck"))
return ctx.Execute<Checks>(PermCheck, m => m.PermCheckGuild(ctx));
if (ctx.Match("proxycheck"))
@ -98,17 +100,65 @@ public partial class CommandTree
return HandleDebugCommand(ctx);
if (ctx.Match("admin"))
return HandleAdminCommand(ctx);
if (ctx.Match("random", "r"))
if (ctx.Match("random", "rand", "r"))
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
return ctx.Execute<Random>(GroupRandom, r => r.Group(ctx, ctx.System));
else
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));
// don't send an "invalid command" response if the guild has those turned off
if (ctx.GuildConfig != null && ctx.GuildConfig!.InvalidCommandResponseEnabled != true)
return Task.CompletedTask;
// remove compiler warning
return ctx.Reply(
$"{Emojis.Error} Unknown command {ctx.PeekArgument().AsCode()}. For a list of possible commands, see <https://pluralkit.me/commands>.");
}
private async Task HandleAdminAbuseLogCommand(Context ctx)
{
ctx.AssertBotAdmin();
if (ctx.Match("n", "new", "create"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogCreate(ctx));
else
{
AbuseLog? abuseLog = null!;
var account = await ctx.MatchUser();
if (account != null)
{
abuseLog = await ctx.Repository.GetAbuseLogByAccount(account.Id);
}
else
{
abuseLog = await ctx.Repository.GetAbuseLogByGuid(new Guid(ctx.PopArgument()));
}
if (abuseLog == null)
{
await ctx.Reply($"{Emojis.Error} Could not find an existing abuse log entry for that query.");
return;
}
if (!ctx.HasNext())
await ctx.Execute<Admin>(Admin, a => a.AbuseLogShow(ctx, abuseLog));
else if (ctx.Match("au", "adduser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogAddUser(ctx, abuseLog));
else if (ctx.Match("ru", "removeuser"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogRemoveUser(ctx, abuseLog));
else if (ctx.Match("desc", "description"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDescription(ctx, abuseLog));
else if (ctx.Match("deny", "deny-bot-usage"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogFlagDeny(ctx, abuseLog));
else if (ctx.Match("yeet", "remove", "delete"))
await ctx.Execute<Admin>(Admin, a => a.AbuseLogDelete(ctx, abuseLog));
else
await ctx.Reply($"{Emojis.Error} Unknown subcommand {ctx.PeekArgument().AsCode()}.");
}
}
private async Task HandleAdminCommand(Context ctx)
{
if (ctx.Match("usid", "updatesystemid"))
@ -129,6 +179,10 @@ public partial class CommandTree
await ctx.Execute<Admin>(Admin, a => a.SystemGroupLimit(ctx));
else if (ctx.Match("sr", "systemrecover"))
await ctx.Execute<Admin>(Admin, a => a.SystemRecover(ctx));
else if (ctx.Match("sd", "systemdelete"))
await ctx.Execute<Admin>(Admin, a => a.SystemDelete(ctx));
else if (ctx.Match("al", "abuselog"))
await HandleAdminAbuseLogCommand(ctx);
else
await ctx.Reply($"{Emojis.Error} Unknown command.");
}
@ -161,12 +215,6 @@ public partial class CommandTree
else if (ctx.Match("commands", "help"))
await PrintCommandList(ctx, "systems", SystemCommands);
// these are deprecated (and not accessible by other users anyway), let's leave them out of new parsing
else if (ctx.Match("timezone", "tz"))
await ctx.Execute<Config>(ConfigTimezone, m => m.SystemTimezone(ctx), true);
else if (ctx.Match("ping"))
await ctx.Execute<Config>(ConfigPing, m => m.SystemPing(ctx), true);
// todo: these aren't deprecated but also shouldn't be here
else if (ctx.Match("webhook", "hook"))
await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx));
@ -203,7 +251,7 @@ public partial class CommandTree
// if we *still* haven't matched anything, the user entered an invalid command name or system reference
if (ctx.Parameters._ptr == previousPtr)
{
if (ctx.Parameters.Peek().Length != 5 && !ctx.Parameters.Peek().TryParseMention(out _))
if (!ctx.Parameters.Peek().TryParseHid(out _) && !ctx.Parameters.Peek().TryParseMention(out _))
{
await PrintCommandNotFoundError(ctx, SystemCommands);
return;
@ -227,9 +275,9 @@ public partial class CommandTree
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemTag, m => m.Tag(ctx, target));
else if (ctx.Match("servertag", "st", "stag", "deer"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemServerTag, m => m.ServerTag(ctx, target));
else if (ctx.Match("description", "desc", "bio", "info", "text"))
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDesc, m => m.Description(ctx, target));
else if (ctx.Match("pronouns", "prns"))
else if (ctx.Match("pronouns", "pronoun", "prns", "pn"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemPronouns, m => m.Pronouns(ctx, target));
else if (ctx.Match("color", "colour"))
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemColor, m => m.Color(ctx, target));
@ -267,7 +315,7 @@ public partial class CommandTree
await ctx.CheckSystem(target).Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx, target));
else if (ctx.Match("id"))
await ctx.CheckSystem(target).Execute<System>(SystemId, m => m.DisplayId(ctx, target));
else if (ctx.Match("random", "r"))
else if (ctx.Match("random", "rand", "r"))
if (ctx.Match("group", "g") || ctx.MatchFlag("group", "g"))
await ctx.CheckSystem(target).Execute<Random>(GroupRandom, r => r.Group(ctx, target));
else
@ -297,13 +345,13 @@ public partial class CommandTree
// Commands that have a member target (eg. pk;member <member> delete)
if (ctx.Match("rename", "name", "changename", "setname", "rn"))
await ctx.Execute<MemberEdit>(MemberRename, m => m.Name(ctx, target));
else if (ctx.Match("description", "info", "bio", "text", "desc"))
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.Execute<MemberEdit>(MemberDesc, m => m.Description(ctx, target));
else if (ctx.Match("pronouns", "pronoun", "prns", "pn"))
await ctx.Execute<MemberEdit>(MemberPronouns, m => m.Pronouns(ctx, target));
else if (ctx.Match("color", "colour"))
await ctx.Execute<MemberEdit>(MemberColor, m => m.Color(ctx, target));
else if (ctx.Match("birthday", "bday", "birthdate", "cakeday", "bdate"))
else if (ctx.Match("birthday", "birth", "bday", "birthdate", "cakeday", "bdate", "bd"))
await ctx.Execute<MemberEdit>(MemberBirthday, m => m.Birthday(ctx, target));
else if (ctx.Match("proxy", "tags", "proxytags", "brackets"))
await ctx.Execute<MemberProxy>(MemberProxy, m => m.Proxy(ctx, target));
@ -311,7 +359,7 @@ public partial class CommandTree
await ctx.Execute<MemberEdit>(MemberDelete, m => m.Delete(ctx, target));
else if (ctx.Match("avatar", "profile", "picture", "icon", "image", "pfp", "pic"))
await ctx.Execute<MemberAvatar>(MemberAvatar, m => m.Avatar(ctx, target));
else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa"))
else if (ctx.Match("proxyavatar", "proxypfp", "webhookavatar", "webhookpfp", "pa", "pavatar", "ppfp"))
await ctx.Execute<MemberAvatar>(MemberAvatar, m => m.WebhookAvatar(ctx, target));
else if (ctx.Match("banner", "splash", "cover"))
await ctx.Execute<MemberEdit>(MemberBannerImage, m => m.BannerImage(ctx, target));
@ -319,7 +367,7 @@ public partial class CommandTree
if (ctx.Match("add", "a"))
await ctx.Execute<GroupMember>(MemberGroupAdd,
m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem", "r"))
else if (ctx.Match("remove", "rem"))
await ctx.Execute<GroupMember>(MemberGroupRemove,
m => m.AddRemoveGroups(ctx, target, Groups.AddRemoveOperation.Remove));
else
@ -346,7 +394,7 @@ public partial class CommandTree
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, null));
else if (ctx.Match("private", "hidden", "hide"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Private));
else if (ctx.Match("public", "shown", "show"))
else if (ctx.Match("public", "shown", "show", "unhide", "unhidden"))
await ctx.Execute<MemberEdit>(MemberPrivacy, m => m.Privacy(ctx, target, PrivacyLevel.Public));
else if (ctx.Match("soulscream"))
await ctx.Execute<Member>(MemberInfo, m => m.Soulscream(ctx, target));
@ -374,17 +422,17 @@ public partial class CommandTree
await ctx.Execute<Groups>(GroupRename, g => g.RenameGroup(ctx, target));
else if (ctx.Match("nick", "dn", "displayname", "nickname"))
await ctx.Execute<Groups>(GroupDisplayName, g => g.GroupDisplayName(ctx, target));
else if (ctx.Match("description", "info", "bio", "text", "desc"))
else if (ctx.Match("description", "desc", "describe", "d", "bio", "info", "text", "intro"))
await ctx.Execute<Groups>(GroupDesc, g => g.GroupDescription(ctx, target));
else if (ctx.Match("add", "a"))
await ctx.Execute<GroupMember>(GroupAdd,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Add));
else if (ctx.Match("remove", "rem", "r"))
else if (ctx.Match("remove", "rem"))
await ctx.Execute<GroupMember>(GroupRemove,
g => g.AddRemoveMembers(ctx, target, Groups.AddRemoveOperation.Remove));
else if (ctx.Match("members", "list", "ms", "l", "ls"))
await ctx.Execute<GroupMember>(GroupMemberList, g => g.ListGroupMembers(ctx, target));
else if (ctx.Match("random"))
else if (ctx.Match("random", "rand", "r"))
await ctx.Execute<Random>(GroupMemberRandom, r => r.GroupMember(ctx, target));
else if (ctx.Match("privacy"))
await ctx.Execute<Groups>(GroupPrivacy, g => g.GroupPrivacy(ctx, target, null));
@ -428,13 +476,15 @@ public partial class CommandTree
await ctx.Execute<Switch>(SwitchEdit, m => m.SwitchEdit(ctx));
else if (ctx.Match("delete", "remove", "erase", "cancel", "yeet"))
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"))
await PrintCommandList(ctx, "switching", SwitchCommands);
else if (ctx.HasNext()) // there are following arguments
await ctx.Execute<Switch>(Switch, m => m.SwitchDo(ctx));
else
await PrintCommandNotFoundError(ctx, Switch, SwitchOut, SwitchMove, SwitchEdit, SwitchEditOut,
SwitchDelete, SystemFronter, SystemFrontHistory);
SwitchDelete, SwitchCopy, SystemFronter, SystemFrontHistory);
}
private async Task CommandHelpRoot(Context ctx)
@ -482,6 +532,11 @@ public partial class CommandTree
case "cfg":
await PrintCommandList(ctx, "settings", ConfigCommands);
break;
case "serverconfig":
case "guildconfig":
case "scfg":
await PrintCommandList(ctx, "server settings", ServerConfigCommands);
break;
case "autoproxy":
case "ap":
await PrintCommandList(ctx, "autoproxy", AutoproxyCommands);
@ -500,13 +555,6 @@ public partial class CommandTree
if (ctx.System == null)
return ctx.Reply($"{Emojis.Error} {Errors.NoSystemError.Message}");
// todo: move this whole block to Autoproxy.cs when these are removed
if (ctx.Match("account", "ac"))
return ctx.Execute<Config>(ConfigAutoproxyAccount, m => m.AutoproxyAccount(ctx), true);
if (ctx.Match("timeout", "tm"))
return ctx.Execute<Config>(ConfigAutoproxyTimeout, m => m.AutoproxyTimeout(ctx), true);
return ctx.Execute<Autoproxy>(AutoproxySet, m => m.SetAutoproxyMode(ctx));
}
@ -536,8 +584,56 @@ public partial class CommandTree
return ctx.Execute<Config>(null, m => m.CaseSensitiveProxyTags(ctx));
if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "error" }) || ctx.Match("pe"))
return ctx.Execute<Config>(null, m => m.ProxyErrorMessageEnabled(ctx));
if (ctx.MatchMultiple(new[] { "split" }, new[] { "id", "ids" }) || ctx.Match("sid", "sids"))
return ctx.Execute<Config>(null, m => m.HidDisplaySplit(ctx));
if (ctx.MatchMultiple(new[] { "cap", "caps", "capitalize", "capitalise" }, new[] { "id", "ids" }) || ctx.Match("capid", "capids"))
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"))
return ctx.Execute<Config>(null, m => m.HidListPadding(ctx));
if (ctx.MatchMultiple(new[] { "name" }, new[] { "format" }) || ctx.Match("nameformat", "nf"))
return ctx.Execute<Config>(null, m => m.NameFormat(ctx));
if (ctx.MatchMultiple(new[] { "member", "group" }, new[] { "limit" }) || ctx.Match("limit"))
return ctx.Execute<Config>(null, m => m.LimitUpdate(ctx));
if (ctx.MatchMultiple(new[] { "proxy" }, new[] { "switch" }) || ctx.Match("proxyswitch", "ps"))
return ctx.Execute<Config>(null, m => m.ProxySwitch(ctx));
// 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.");
}
private Task HandleServerConfigCommand(Context ctx)
{
if (!ctx.HasNext())
return ctx.Execute<ServerConfig>(null, m => m.ShowConfig(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "cleanup", "clean" }) || ctx.Match("logclean"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogCleanup(ctx));
if (ctx.MatchMultiple(new[] { "invalid", "unknown" }, new[] { "command" }, new[] { "error", "response" }) || ctx.Match("invalidcommanderror", "unknowncommanderror"))
return ctx.Execute<ServerConfig>(null, m => m.InvalidCommandResponse(ctx));
if (ctx.MatchMultiple(new[] { "require", "enforce" }, new[] { "tag", "systemtag" }) || ctx.Match("requiretag", "enforcetag"))
return ctx.Execute<ServerConfig>(null, m => m.RequireSystemTag(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "channel" }))
return ctx.Execute<ServerConfig>(null, m => m.SetLogChannel(ctx));
if (ctx.MatchMultiple(new[] { "log" }, new[] { "blacklist" }))
{
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogBlacklisted(ctx, true));
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(null, m => m.SetLogBlacklisted(ctx, false));
else
return ctx.Execute<ServerConfig>(null, m => m.ShowLogDisabledChannels(ctx));
}
if (ctx.MatchMultiple(new[] { "proxy", "proxying" }, new[] { "blacklist" }))
{
if (ctx.Match("enable", "on", "add", "deny"))
return ctx.Execute<ServerConfig>(null, m => m.SetProxyBlacklisted(ctx, true));
else if (ctx.Match("disable", "off", "remove", "allow"))
return ctx.Execute<ServerConfig>(null, m => m.SetProxyBlacklisted(ctx, false));
else
return ctx.Execute<ServerConfig>(null, m => m.ShowProxyBlacklisted(ctx));
}
// 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 serverconfig` for the list of possible config settings.");
}
}

View file

@ -29,11 +29,13 @@ public class Context
private Command? _currentCommand;
public Context(ILifetimeScope provider, int shardId, Guild? guild, Channel channel, MessageCreateEvent message,
int commandParseOffset, PKSystem senderSystem, SystemConfig config)
int commandParseOffset, PKSystem senderSystem, SystemConfig config,
GuildConfig? guildConfig)
{
Message = (Message)message;
ShardId = shardId;
Guild = guild;
GuildConfig = guildConfig;
Channel = channel;
System = senderSystem;
Config = config;
@ -59,11 +61,12 @@ public class Context
public readonly Message Message;
public readonly Guild Guild;
public readonly GuildConfig? GuildConfig;
public readonly int ShardId;
public readonly Cluster Cluster;
public Task<PermissionSet> BotPermissions => Cache.PermissionsIn(Channel.Id);
public Task<PermissionSet> UserPermissions => Cache.PermissionsFor((MessageCreateEvent)Message);
public Task<PermissionSet> BotPermissions => Cache.BotPermissionsIn(Guild?.Id ?? 0, Channel.Id);
public Task<PermissionSet> UserPermissions => Cache.PermissionsForMCE((MessageCreateEvent)Message);
public readonly PKSystem System;
@ -100,7 +103,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)
// 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;
@ -112,8 +115,7 @@ public class Context
if (deprecated && commandDef != null)
{
await Reply($"{Emojis.Warn} This command has been removed. please use `pk;{commandDef.Key}` instead.");
return;
await Reply($"{Emojis.Warn} Server configuration has moved to `pk;serverconfig`. The command you are trying to run is now `pk;{commandDef.Key}`.");
}
try

View file

@ -91,8 +91,12 @@ public static class ContextArgumentsExt
public static bool MatchClear(this Context ctx)
=> ctx.Match("clear", "reset", "default") || ctx.MatchFlag("c", "clear");
public static bool MatchRaw(this Context ctx) =>
ctx.Match("r", "raw") || ctx.MatchFlag("r", "raw");
public static ReplyFormat MatchFormat(this Context ctx)
{
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)
{
@ -184,4 +188,11 @@ public static class ContextArgumentsExt
return groups;
}
}
public enum ReplyFormat
{
Standard,
Raw,
Plaintext
}

View file

@ -33,11 +33,14 @@ public static class ContextAvatarExt
// If we have an attachment, use that
if (ctx.Message.Attachments.FirstOrDefault() is { } attachment)
{
// XXX: strip query params from attachment URLs because of new Discord CDN shenanigans
// XXX: discord attachment URLs are unable to be validated without their query params
// keep both the URL with query (for validation) and the clean URL (for storage) around
var uriBuilder = new UriBuilder(attachment.ProxyUrl);
uriBuilder.Query = "";
return new ParsedImage { Url = uriBuilder.Uri.AbsoluteUri, Source = AvatarSource.Attachment };
ParsedImage img = new ParsedImage { Url = uriBuilder.Uri.AbsoluteUri, Source = AvatarSource.Attachment };
uriBuilder.Query = "";
img.CleanUrl = uriBuilder.Uri.AbsoluteUri;
return img;
}
// We should only get here if there are no arguments (which would get parsed as URL + throw if error)
@ -49,6 +52,7 @@ public static class ContextAvatarExt
public struct ParsedImage
{
public string Url;
public string? CleanUrl;
public AvatarSource Source;
public User? SourceUser;
}
@ -57,5 +61,6 @@ public enum AvatarSource
{
Url,
User,
Attachment
Attachment,
HostedCdn
}

View file

@ -14,7 +14,11 @@ public static class ContextEntityArgumentsExt
{
var text = ctx.PeekArgument();
if (text.TryParseMention(out var id))
return await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
{
var user = await ctx.Cache.GetOrFetchUser(ctx.Rest, id);
if (user != null) ctx.PopArgument();
return user;
}
return null;
}
@ -53,8 +57,10 @@ public static class ContextEntityArgumentsExt
return await ctx.Repository.GetSystemByAccount(id);
// Finally, try HID parsing
var system = await ctx.Repository.GetSystemByHid(input);
return system;
if (input.TryParseHid(out var hid))
return await ctx.Repository.GetSystemByHid(hid);
return null;
}
public static async Task<PKMember> PeekMember(this Context ctx, SystemId? restrictToSystem = null)
@ -83,7 +89,7 @@ public static class ContextEntityArgumentsExt
// Finally (or if by-HID lookup is specified), check if input is a valid HID and then try member HID parsing:
if (!Regex.IsMatch(input, @"^[a-zA-Z]{5}$"))
if (!input.TryParseHid(out var hid))
return null;
// For posterity:
@ -94,21 +100,21 @@ public static class ContextEntityArgumentsExt
PKMember memberByHid = null;
if (restrictToSystem != null)
{
memberByHid = await ctx.Repository.GetMemberByHid(input, restrictToSystem);
memberByHid = await ctx.Repository.GetMemberByHid(hid, restrictToSystem);
if (memberByHid != null)
return memberByHid;
}
// otherwise we try the querier's system and if that doesn't work we do global
else
{
memberByHid = await ctx.Repository.GetMemberByHid(input, ctx.System?.Id);
memberByHid = await ctx.Repository.GetMemberByHid(hid, ctx.System?.Id);
if (memberByHid != null)
return memberByHid;
// ff ctx.System was null then this would be a duplicate of above and we don't want to run it again
if (ctx.System != null)
{
memberByHid = await ctx.Repository.GetMemberByHid(input);
memberByHid = await ctx.Repository.GetMemberByHid(hid);
if (memberByHid != null)
return memberByHid;
}
@ -148,7 +154,10 @@ public static class ContextEntityArgumentsExt
return byDisplayName;
}
if (await ctx.Repository.GetGroupByHid(input, restrictToSystem) is { } byHid)
if (!input.TryParseHid(out var hid))
return null;
if (await ctx.Repository.GetGroupByHid(hid, restrictToSystem) is { } byHid)
return byHid;
return null;
@ -164,17 +173,18 @@ public static class ContextEntityArgumentsExt
public static string CreateNotFoundError(this Context ctx, string entity, string input)
{
var isIDOnlyQuery = ctx.System == null || ctx.MatchFlag("id", "by-id");
var inputIsHid = HidUtils.ParseHid(input) != null;
if (isIDOnlyQuery)
{
if (input.Length == 5)
if (inputIsHid)
return $"{entity} with ID \"{input}\" not found.";
return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 characters long.";
return $"{entity} not found. Note that a {entity.ToLower()} ID is 5 or 6 characters long.";
}
if (input.Length == 5)
if (inputIsHid)
return $"{entity} with ID or name \"{input}\" not found.";
return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 characters long.";
return $"{entity} with name \"{input}\" not found. Note that a {entity.ToLower()} ID is 5 or 6 characters long.";
}
public static async Task<Channel> MatchChannel(this Context ctx)
@ -182,7 +192,8 @@ public static class ContextEntityArgumentsExt
if (!MentionUtils.TryParseChannel(ctx.PeekArgument(), out var id))
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)
channel = await ctx.Rest.GetChannelOrNull(id);
if (channel == null)

View file

@ -6,10 +6,10 @@ public static class ContextPrivacyExt
{
public static PrivacyLevel PopPrivacyLevel(this Context ctx)
{
if (ctx.Match("public", "show", "shown", "visible"))
if (ctx.Match("public", "pub", "show", "shown", "visible", "unhide", "unhidden"))
return PrivacyLevel.Public;
if (ctx.Match("private", "hide", "hidden"))
if (ctx.Match("private", "priv", "hide", "hidden"))
return PrivacyLevel.Private;
if (!ctx.HasNext())
@ -33,7 +33,7 @@ public static class ContextPrivacyExt
{
if (!MemberPrivacyUtils.TryParseMemberPrivacy(ctx.PeekArgument(), out var subject))
throw new PKSyntaxError(
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `metadata`, `visibility`, or `all`).");
$"Invalid privacy subject {ctx.PopArgument().AsCode()} (must be `name`, `description`, `avatar`, `birthday`, `pronouns`, `proxy`, `metadata`, `visibility`, or `all`).");
ctx.PopArgument();
return subject;

View file

@ -1,8 +1,12 @@
using System.Text.RegularExpressions;
using Humanizer;
using Dapper;
using SqlKata;
using Myriad.Builders;
using Myriad.Extensions;
using Myriad.Cache;
using Myriad.Rest;
using Myriad.Types;
@ -14,11 +18,95 @@ public class Admin
{
private readonly BotConfig _botConfig;
private readonly DiscordApiClient _rest;
private readonly IDiscordCache _cache;
public Admin(BotConfig botConfig, DiscordApiClient rest)
public Admin(BotConfig botConfig, DiscordApiClient rest, IDiscordCache cache)
{
_botConfig = botConfig;
_rest = rest;
_cache = cache;
}
private 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));
}
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 "";
}
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<Embed> CreateAbuseLogEmbed(Context ctx, AbuseLog abuseLog)
{
// Fetch/render info for all accounts simultaneously
var accounts = await ctx.Repository.GetAbuseLogAccounts(abuseLog.Id);
var systems = await Task.WhenAll(accounts.Select(x => ctx.Repository.GetSystemByAccount(x)));
var users = (await GetUsers(accounts)).Select(x => x.User?.NameAndMention() ?? $"(deleted: `{x.Id}`)");
List<string> flagstr = new();
if (abuseLog.DenyBotUsage)
flagstr.Add("- bot usage denied");
var eb = new EmbedBuilder()
.Title($"Abuse log: {abuseLog.Uuid.ToString()}")
.Color(DiscordUtils.Red)
.Footer(new Embed.EmbedFooter($"Created on {abuseLog.Created.FormatZoned(ctx.Zone)}"));
if (systems.Any(x => x != null))
{
var sysList = string.Join(", ", systems.Select(x => $"`{x.DisplayHid()}`"));
eb.Field(new Embed.Field($"{Emojis.Warn} Accounts have registered system(s)", sysList));
}
eb.Field(new Embed.Field("Accounts", string.Join("\n", users).Truncate(1000), true));
eb.Field(new Embed.Field("Flags", flagstr.Any() ? string.Join("\n", flagstr) : "(none)", true));
if (abuseLog.Description != null)
eb.Field(new Embed.Field("Description", abuseLog.Description.Truncate(1000)));
return eb.Build();
}
public async Task UpdateSystemId(Context ctx)
@ -29,14 +117,16 @@ public class Admin
if (target == null)
throw new PKError("Unknown system.");
var newHid = ctx.PopArgument();
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
throw new PKError($"Invalid new system ID `{newHid}`.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new system ID `{input}`.");
var existingSystem = await ctx.Repository.GetSystemByHid(newHid);
if (existingSystem != null)
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"))
throw new PKError("ID change cancelled.");
@ -52,14 +142,17 @@ public class Admin
if (target == null)
throw new PKError("Unknown member.");
var newHid = ctx.PopArgument();
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
throw new PKError($"Invalid new member ID `{newHid}`.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new member ID `{input}`.");
var existingMember = await ctx.Repository.GetMemberByHid(newHid);
if (existingMember != null)
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(
$"Change member ID of **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`) to `{newHid}`?",
"Change"
@ -78,14 +171,17 @@ public class Admin
if (target == null)
throw new PKError("Unknown group.");
var newHid = ctx.PopArgument();
if (!Regex.IsMatch(newHid, "^[a-z]{5}$"))
throw new PKError($"Invalid new group ID `{newHid}`.");
var input = ctx.PopArgument();
if (!input.TryParseHid(out var newHid))
throw new PKError($"Invalid new group ID `{input}`.");
var existingGroup = await ctx.Repository.GetGroupByHid(newHid);
if (existingGroup != null)
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}`?",
"Change"
))
@ -103,6 +199,8 @@ public class Admin
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
if (!await ctx.PromptYesNo($"Reroll system ID `{target.Hid}`?", "Reroll"))
throw new PKError("ID change cancelled.");
@ -124,6 +222,9 @@ public class Admin
if (target == null)
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(
$"Reroll member ID for **{target.NameFor(LookupContext.ByNonOwner)}** (`{target.Hid}`)?",
"Reroll"
@ -148,6 +249,9 @@ public class Admin
if (target == null)
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}`)?",
"Change"
))
@ -176,7 +280,7 @@ public class Admin
var currentLimit = config.MemberLimitOverride ?? Limits.MaxMemberCount;
if (!ctx.HasNext())
{
await ctx.Reply($"Current member limit is **{currentLimit}** members.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
@ -184,6 +288,7 @@ public class Admin
if (!int.TryParse(newLimitStr, out var newLimit))
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"))
throw new PKError("Member limit change cancelled.");
@ -204,7 +309,7 @@ public class Admin
var currentLimit = config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (!ctx.HasNext())
{
await ctx.Reply($"Current group limit is **{currentLimit}** groups.");
await ctx.Reply(null, await CreateEmbed(ctx, target));
return;
}
@ -212,6 +317,7 @@ public class Admin
if (!int.TryParse(newLimitStr, out var newLimit))
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"))
throw new PKError("Group limit change cancelled.");
@ -240,9 +346,10 @@ public class Admin
var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id);
if (existingAccount != null)
throw Errors.AccountInOtherSystem(existingAccount);
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config);
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"))
throw new PKError("System recovery cancelled.");
@ -266,4 +373,127 @@ public class Admin
Color = DiscordUtils.Green,
});
}
public async Task SystemDelete(Context ctx)
{
ctx.AssertBotAdmin();
var target = await ctx.MatchSystem();
if (target == null)
throw new PKError("Unknown system.");
await ctx.Reply($"To delete the following system, reply with the system's UUID: `{target.Uuid.ToString()}`",
await CreateEmbed(ctx, target));
if (!await ctx.ConfirmWithReply(target.Uuid.ToString()))
throw new PKError("System deletion cancelled.");
await ctx.BusyIndicator(async () =>
await ctx.Repository.DeleteSystem(target.Id));
await ctx.Reply($"{Emojis.Success} System deletion succesful.");
}
public async Task AbuseLogCreate(Context ctx)
{
var denyBotUsage = ctx.MatchFlag("deny", "deny-bot-usage");
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
string? desc = null!;
if (ctx.HasNext(false))
desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
var abuseLog = await ctx.Repository.CreateAbuseLog(desc, denyBotUsage);
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
$"Created new abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogShow(Context ctx, AbuseLog abuseLog)
{
await ctx.Reply(null, await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogFlagDeny(Context ctx, AbuseLog abuseLog)
{
if (!ctx.HasNext())
{
await ctx.Reply(
$"Bot usage is currently {(abuseLog.DenyBotUsage ? "denied" : "allowed")} "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
else
{
var value = ctx.MatchToggle(true);
if (abuseLog.DenyBotUsage != value)
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { DenyBotUsage = value });
await ctx.Reply(
$"Bot usage is now **{(value ? "denied" : "allowed")}** "
+ $"for accounts associated with abuse log `{abuseLog.Uuid}`.");
}
}
public async Task AbuseLogDescription(Context ctx, AbuseLog abuseLog)
{
if (ctx.MatchClear() && await ctx.ConfirmClear("this abuse log description"))
{
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = null });
await ctx.Reply($"{Emojis.Success} Abuse log description cleared.");
}
else if (ctx.HasNext())
{
var desc = ctx.RemainderOrNull(false).NormalizeLineEndSpacing();
await ctx.Repository.UpdateAbuseLog(abuseLog.Id, new AbuseLogPatch { Description = desc });
await ctx.Reply($"{Emojis.Success} Abuse log description updated.");
}
else
{
var eb = new EmbedBuilder()
.Description($"Showing description for abuse log `{abuseLog.Uuid}`");
await ctx.Reply(abuseLog.Description, eb.Build());
}
}
public async Task AbuseLogAddUser(Context ctx, AbuseLog abuseLog)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to associate the abuse log with (either ID or @mention).");
await ctx.Repository.AddAbuseLogAccount(abuseLog.Id, account.Id);
await ctx.Reply(
$"Added user {account.NameAndMention()} to the abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogRemoveUser(Context ctx, AbuseLog abuseLog)
{
var account = await ctx.MatchUser();
if (account == null)
throw new PKError("You must pass an account to remove from the abuse log (either ID or @mention).");
await ctx.Repository.UpdateAccount(account.Id, new()
{
AbuseLog = null,
});
await ctx.Reply(
$"Removed user {account.NameAndMention()} from the abuse log with UUID `{abuseLog.Uuid.ToString()}`.",
await CreateAbuseLogEmbed(ctx, abuseLog));
}
public async Task AbuseLogDelete(Context ctx, AbuseLog abuseLog)
{
if (!await ctx.PromptYesNo($"Really delete abuse log entry `{abuseLog.Uuid}`?", "Delete", matchFlag: false))
{
await ctx.Reply($"{Emojis.Error} Deletion cancelled.");
return;
}
await ctx.Repository.DeleteAbuseLog(abuseLog.Id);
await ctx.Reply($"{Emojis.Success} Successfully deleted abuse log entry.");
}
}

View file

@ -143,25 +143,33 @@ public class Api
if (_webhookRegex.IsMatch(newUrl))
throw new PKError("PluralKit does not currently support setting a Discord webhook URL as your system's webhook URL.");
try
{
await _dispatch.DoPostRequest(ctx.System.Id, newUrl, null, true);
}
catch (Exception e)
{
throw new PKError($"Could not verify that the new URL is working: {e.Message}");
}
var newToken = StringUtils.GenerateToken();
await ctx.Reply($"{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
+ " If it is exposed publicly, you **must** clear and re-set the webhook URL to get a new token."
+ "\n\n**Please review the security requirements at <https://pluralkit.me/api/dispatch#security> before continuing.**"
+ "\n\nWhen the server is correctly validating the token, click or reply 'yes' to continue."
);
if (!await ctx.PromptYesNo(newToken, "Continue", matchFlag: false))
throw Errors.GenericCancelled();
var status = await _dispatch.TestUrl(ctx.System.Uuid, newUrl, newToken);
if (status != "OK")
{
var message = status switch
{
"BadData" => "the webhook url is invalid",
"NoIPs" => "could not find any valid IP addresses for the provided domain",
"InvalidIP" => "could not find any valid IP addresses for the provided domain",
"FetchFailed" => "unable to reach server",
"TestFailed" => "server failed to validate the signing token",
_ => $"an unknown error occurred ({status})"
};
throw new PKError($"Failed to validate the webhook url: {message}");
}
await ctx.Repository.UpdateSystem(ctx.System.Id, new SystemPatch { WebhookUrl = newUrl, WebhookToken = newToken });
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system."
+ $"\n\n{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
+ " If it leaks, you should clear and re-set the webhook URL to get a new token."
+ "\ntodo: add link to docs or something"
);
await ctx.Reply(newToken);
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system.");
}
}

View file

@ -130,7 +130,7 @@ public class Autoproxy
{
if (relevantMember == null)
throw new ArgumentException("Attempted to print member autoproxy status, but the linked member ID wasn't found in the database. Should be handled appropriately.");
eb.Description($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
eb.Description($"Autoproxy is currently set to **front mode** in this server. The current (first) fronter is **{relevantMember.NameFor(ctx).EscapeMarkdown()}** (`{relevantMember.DisplayHid(ctx.Config)}`). To disable, type `pk;autoproxy off`.");
}
break;
@ -142,7 +142,7 @@ public class Autoproxy
// ideally we would set it to off in the database though...
eb.Description($"Autoproxy is currently **off** in this server. To enable it, use one of the following commands:\n{commandList}");
else
eb.Description($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`) in this server. To disable, type `pk;autoproxy off`.");
eb.Description($"Autoproxy is active for member **{relevantMember.NameFor(ctx)}** (`{relevantMember.DisplayHid(ctx.Config)}`) in this server. To disable, type `pk;autoproxy off`.");
break;
}
@ -150,7 +150,7 @@ public class Autoproxy
if (relevantMember == null)
eb.Description("Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. **No member is currently latched.** To disable, type `pk;autoproxy off`.");
else
eb.Description($"Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. The currently latched member is **{relevantMember.NameFor(ctx)}** (`{relevantMember.Hid}`). To disable, type `pk;autoproxy off`.");
eb.Description($"Autoproxy is currently set to **latch mode**, meaning the *last-proxied member* will be autoproxied. The currently latched member is **{relevantMember.NameFor(ctx)}** (`{relevantMember.DisplayHid(ctx.Config)}`). To disable, type `pk;autoproxy off`.");
break;

View file

@ -143,6 +143,7 @@ public class Checks
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
// with new cache it breaks if channel is not in current guild
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId == null)
throw new PKError(error);
@ -156,7 +157,8 @@ public class Checks
if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
throw new PKError(error);
var botPermissions = PermissionExtensions.PermissionsFor(guild, channel, _botConfig.ClientId, guildMember);
// 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
ulong missingPermissions = 0;
@ -231,15 +233,21 @@ public class Checks
var channel = await _rest.GetChannelOrNull(channelId.Value);
if (channel == null)
throw new PKError("Unable to get the channel associated with this message.");
var rootChannel = await _cache.GetRootChannel(channel.Id);
if (channel.GuildId == null)
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
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();
if (await ctx.Repository.GetSystemByAccount(msg.Author.Id) == null)
{
await ctx.Reply("Your account does not have a system registered.");
return;
}
// for now this is just server
var autoproxySettings = await ctx.Repository.GetAutoproxySettings(ctx.System.Id, channel.GuildId.Value, null);

View file

@ -97,11 +97,46 @@ public class Config
items.Add(new(
"Proxy error",
"Whether to send an error message when proxying fails.",
"Whether to send an error message when proxying fails",
EnabledDisabled(ctx.Config.ProxyErrorMessageEnabled),
"enabled"
));
items.Add(new(
"Split IDs",
"Whether to display 6-character IDs split with a hyphen, to ease readability",
EnabledDisabled(ctx.Config.HidDisplaySplit),
"disabled"
));
items.Add(new(
"Capitalize IDs",
"Whether to display IDs as capital letters, to ease readability",
EnabledDisabled(ctx.Config.HidDisplayCaps),
"disabled"
));
items.Add(new(
"Pad IDs",
"Whether to pad 5-character IDs in lists (left/right)",
ctx.Config.HidListPadding.ToUserString(),
"off"
));
items.Add(new(
"Proxy Switch",
"Whether using a proxy tag logs a switch",
EnabledDisabled(ctx.Config.ProxySwitch),
"disabled"
));
items.Add(new(
"Name Format",
"Format string used to display a member's name https://pluralkit.me/guide/#setting-a-custom-name-format",
ctx.Config.NameFormat,
ProxyMember.DefaultFormat
));
await ctx.Paginate<PaginatedConfigItem>(
items.ToAsyncEnumerable(),
items.Count,
@ -443,4 +478,115 @@ public class Config
await ctx.Reply("Proxy error messages are now disabled. Messages that fail to proxy (due to message or attachment size) will not throw an error message.");
}
}
public async Task HidDisplaySplit(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Splitting of 6-character IDs with a hyphen is currently **{EnabledDisabled(ctx.Config.HidDisplaySplit)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplaySplit = newVal });
await ctx.Reply($"Splitting of 6-character IDs with a hyphen is now {EnabledDisabled(newVal)}.");
}
public async Task HidDisplayCaps(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Displaying IDs as capital letters is currently **{EnabledDisabled(ctx.Config.HidDisplayCaps)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidDisplayCaps = newVal });
await ctx.Reply($"Displaying IDs as capital letters is now {EnabledDisabled(newVal)}.");
}
public async Task HidListPadding(Context ctx)
{
if (!ctx.HasNext())
{
string message;
switch (ctx.Config.HidListPadding)
{
case SystemConfig.HidPadFormat.None: message = "Padding 5-character IDs in lists is currently disabled."; break;
case SystemConfig.HidPadFormat.Left: message = "5-character IDs displayed in lists will have a padding space added to the beginning."; break;
case SystemConfig.HidPadFormat.Right: message = "5-character IDs displayed in lists will have a padding space added to the end."; break;
default: throw new Exception("unreachable");
}
await ctx.Reply(message);
return;
}
var badInputError = "Valid padding settings are `left`, `right`, or `off`.";
var toggleOff = ctx.MatchToggleOrNull(false);
switch (toggleOff)
{
case true: throw new PKError(badInputError);
case false:
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.None });
await ctx.Reply("Padding 5-character IDs in lists has been disabled.");
return;
}
}
if (ctx.Match("left", "l"))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Left });
await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the beginning.");
}
else if (ctx.Match("right", "r"))
{
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { HidListPadding = SystemConfig.HidPadFormat.Right });
await ctx.Reply("5-character IDs displayed in lists will now have a padding space added to the end.");
}
else throw new PKError(badInputError);
}
public async Task ProxySwitch(Context ctx)
{
if (!ctx.HasNext())
{
var msg = $"Logging a switch every time a proxy tag is used is currently **{EnabledDisabled(ctx.Config.ProxySwitch)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { ProxySwitch = newVal });
await ctx.Reply($"Logging a switch every time a proxy tag is used is now {EnabledDisabled(newVal)}.");
}
public async Task NameFormat(Context ctx)
{
var clearFlag = ctx.MatchClear();
if (!ctx.HasNext() && !clearFlag)
{
await ctx.Reply($"Member names are currently formatted as `{ctx.Config.NameFormat ?? ProxyMember.DefaultFormat}`");
return;
}
string formatString;
if (clearFlag)
formatString = ProxyMember.DefaultFormat;
else
formatString = ctx.RemainderOrNull();
await ctx.Repository.UpdateSystemConfig(ctx.System.Id, new() { NameFormat = formatString });
await ctx.Reply($"Member names are now formatted as `{formatString}`");
}
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");
}
}

View file

@ -53,41 +53,55 @@ public class GroupMember
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)
.Where(g => g.Visibility.CanAccess(pctx))
.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.Hid}`] **{g.DisplayName ?? g.Name}**"));
if (pctx == LookupContext.ByOwner)
var title = new StringBuilder($"Groups containing {target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
if (ctx.Guild != null)
{
msg +=
$"\n\nTo add this member to one or more groups, use `pk;m {target.Reference(ctx)} group add <group> [group 2] [group 3...]`";
if (groups.Count > 0)
msg +=
$"\nTo remove this member from one or more groups, use `pk;m {target.Reference(ctx)} group remove <group> [group 2] [group 3...]`";
var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, targetSystem.Id);
if (guildSettings.DisplayName != null)
title.Append($"{guildSettings.DisplayName} (`{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)}`");
}
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)
{
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)
.Distinct()
.ToList();
}
var existingMembersInGroup = (await ctx.Database.Execute(conn => conn.QueryMemberList(target.System,
new DatabaseViewsExt.ListQueryOptions { GroupFilter = target.Id })))
@ -109,6 +123,9 @@ public class GroupMember
toAction = members
.Where(m => existingMembersInGroup.Contains(m.Value))
.ToList();
if (ctx.MatchFlag("all", "a") && !await ctx.PromptYesNo($"Are you sure you want to remove all members from group {target.Reference(ctx)}?", "Empty Group")) throw Errors.GenericCancelled();
await ctx.Repository.RemoveMembersFromGroup(target.Id, toAction);
}
else
@ -127,26 +144,26 @@ public class GroupMember
var targetSystem = await GetGroupSystem(ctx, target);
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;
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.Hid}`) in ");
var title = new StringBuilder($"Members of {target.DisplayName ?? target.Name} (`{target.DisplayHid(ctx.Config)}`) in ");
if (ctx.Guild != null)
{
var guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, targetSystem.Id);
if (guildSettings.DisplayName != null)
title.Append($"{guildSettings.DisplayName} (`{targetSystem.Hid}`)");
title.Append($"{guildSettings.DisplayName} (`{targetSystem.DisplayHid(ctx.Config)}`)");
else if (targetSystem.NameFor(ctx) != null)
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.Hid}`)");
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.DisplayHid(ctx.Config)}`)");
else
title.Append($"`{targetSystem.Hid}`");
title.Append($"`{targetSystem.DisplayHid(ctx.Config)}`");
}
else
{
if (targetSystem.NameFor(ctx) != null)
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.Hid}`)");
title.Append($"{targetSystem.NameFor(ctx)} (`{targetSystem.DisplayHid(ctx.Config)}`)");
else
title.Append($"`{targetSystem.Hid}`");
title.Append($"`{targetSystem.DisplayHid(ctx.Config)}`");
}
if (opts.Search != null)
title.Append($" matching **{opts.Search.Truncate(100)}**");

View file

@ -21,13 +21,15 @@ public class Groups
private readonly HttpClient _client;
private readonly DispatchService _dispatch;
private readonly EmbedService _embeds;
private readonly AvatarHostingService _avatarHosting;
public Groups(EmbedService embeds, HttpClient client,
DispatchService dispatch)
DispatchService dispatch, AvatarHostingService avatarHosting)
{
_embeds = embeds;
_client = client;
_dispatch = dispatch;
_avatarHosting = avatarHosting;
}
public async Task CreateGroup(Context ctx)
@ -44,14 +46,14 @@ public class Groups
var groupLimit = ctx.Config.GroupLimitOverride ?? Limits.MaxGroupCount;
if (existingGroupCount >= groupLimit)
throw new PKError(
$"System has reached the maximum number of groups ({groupLimit}). Please delete unused groups first in order to create new ones.");
$"System has reached the maximum number of groups ({groupLimit}). If you need to add more groups, you can either delete existing groups, or ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>");
// Warn if there's already a group by this name
var existingGroup = await ctx.Repository.GetGroupByName(ctx.System.Id, groupName);
if (existingGroup != null)
{
var msg =
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to create another group with the same name?";
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to create another group with the same name?";
if (!await ctx.PromptYesNo(msg, "Create"))
throw new PKError("Group creation cancelled.");
}
@ -81,7 +83,7 @@ public class Groups
var eb = new EmbedBuilder()
.Description(
$"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:")
$"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.DisplayHid(ctx.Config)}`**.\nBelow are a couple of useful commands:")
.Field(new Embed.Field("View the group card", $"> pk;group **{reference}**"))
.Field(new Embed.Field("Add members to the group",
$"> pk;group **{reference}** add **MemberName**\n> pk;group **{reference}** add **Member1** **Member2** **Member3** (and so on...)"))
@ -93,7 +95,7 @@ public class Groups
if (existingGroupCount >= Limits.WarnThreshold(groupLimit))
await ctx.Reply(
$"{Emojis.Warn} You are approaching the per-system group limit ({existingGroupCount} / {groupLimit} groups). Please review your group list for unused or duplicate groups.");
$"{Emojis.Warn} You are approaching the per-system group limit ({existingGroupCount} / {groupLimit} groups). Once you reach this limit, you will be unable to create new groups until existing groups are deleted, or you can ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>");
}
public async Task RenameGroup(Context ctx, PKGroup target)
@ -111,7 +113,7 @@ public class Groups
if (existingGroup != null && existingGroup.Id != target.Id)
{
var msg =
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.Hid}`). Do you want to rename this group to that name too?";
$"{Emojis.Warn} You already have a group in your system with the name \"{existingGroup.Name}\" (with ID `{existingGroup.DisplayHid(ctx.Config)}`). Do you want to rename this group to that name too?";
if (!await ctx.PromptYesNo(msg, "Rename"))
throw new PKError("Group rename cancelled.");
}
@ -130,40 +132,47 @@ public class Groups
// 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)
{
await ctx.Reply(noDisplayNameSetMessage);
else
await ctx.Reply($"```\n{target.DisplayName}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(target.DisplayName, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.DisplayName == null)
{
await ctx.Reply(noDisplayNameSetMessage);
}
else
{
var eb = new EmbedBuilder()
.Field(new Embed.Field("Name", target.Name))
.Field(new Embed.Field("Display Name", target.DisplayName));
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)
eb.Description(
$"To change display name, type `pk;group {reference} displayname <display name>`.\n"
+ $"To clear it, type `pk;group {reference} displayname -clear`.\n"
+ $"To print the raw display name, type `pk;group {reference} displayname -raw`.");
if (ctx.System?.Id == target.System)
eb.Description(
$"To change display name, type `pk;group {reference} displayname <display name>`.\n"
+ $"To clear it, type `pk;group {reference} displayname -clear`.\n"
+ $"To print the raw display name, type `pk;group {reference} displayname -raw`.");
if (ctx.System?.Id == target.System)
eb.Footer(new Embed.EmbedFooter($"Using {target.DisplayName.Length}/{Limits.MaxGroupNameLength} characters."));
if (ctx.System?.Id == target.System)
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;
}
@ -182,6 +191,8 @@ public class Groups
else
{
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) };
await ctx.Repository.UpdateGroup(target.Id, patch);
@ -199,30 +210,41 @@ public class Groups
noDescriptionSetMessage +=
$" 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)
{
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply($"```\n{target.Description}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(target.Description, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Description == null)
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group description")
.Description(target.Description)
.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `pk;group {target.Reference(ctx)} description -raw`."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;group {target.Reference(ctx)} description -clear`."
: "")
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
.Build());
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group description")
.Description(target.Description)
.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `pk;group {target.Reference(ctx)} description -raw`."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;group {target.Reference(ctx)} description -clear`."
: "")
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
.Build());
return;
}
@ -251,6 +273,7 @@ public class Groups
{
async Task ClearIcon()
{
await ctx.ConfirmClear("this group's icon");
ctx.CheckOwnGroup(target);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = null });
@ -261,22 +284,24 @@ public class Groups
{
ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.Url });
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { Icon = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.User =>
$"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set.",
AvatarSource.Url => $"{Emojis.Success} Group icon changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group icon changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -300,11 +325,11 @@ public class Groups
else
{
throw new PKSyntaxError(
"This group does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
"This group does not have an avatar set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
}
}
if (ctx.MatchClear() && await ctx.ConfirmClear("this group's icon"))
if (ctx.MatchClear())
await ClearIcon();
else if (await ctx.MatchImage() is { } img)
await SetIcon(img);
@ -316,6 +341,7 @@ public class Groups
{
async Task ClearBannerImage()
{
await ctx.ConfirmClear("this group's banner image");
ctx.CheckOwnGroup(target);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = null });
@ -326,13 +352,15 @@ public class Groups
{
ctx.CheckOwnGroup(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true);
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.Url });
await ctx.Repository.UpdateGroup(target.Id, new GroupPatch { BannerImage = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.Url => $"{Emojis.Success} Group banner image changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Group banner image changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Group banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.",
AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."),
@ -340,7 +368,7 @@ public class Groups
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -348,7 +376,7 @@ public class Groups
async Task ShowBannerImage()
{
ctx.CheckSystemPrivacy(target.System, target.DescriptionPrivacy);
ctx.CheckSystemPrivacy(target.System, target.BannerPrivacy);
if ((target.BannerImage?.Trim() ?? "").Length > 0)
{
@ -364,11 +392,11 @@ public class Groups
else
{
throw new PKSyntaxError(
"This group does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
"This group does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL.");
}
}
if (ctx.MatchClear() && await ctx.ConfirmClear("this group's banner image"))
if (ctx.MatchClear())
await ClearBannerImage();
else if (await ctx.MatchImage() is { } img)
await SetBannerImage(img);
@ -379,7 +407,7 @@ public class Groups
public async Task GroupColor(Context ctx, PKGroup target)
{
var isOwnSystem = ctx.System?.Id == target.System;
var matchedRaw = ctx.MatchRaw();
var matchedFormat = ctx.MatchFormat();
var matchedClear = ctx.MatchClear();
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
@ -387,8 +415,10 @@ public class Groups
if (target.Color == null)
await ctx.Reply(
"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```");
else if (matchedFormat == ReplyFormat.Plaintext)
await ctx.Reply(target.Color);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Group color")
@ -440,24 +470,24 @@ public class Groups
// - 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)
// 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(
ctx.LookupContextFor(system.Id),
system.Id,
GetEmbedTitle(system, opts),
GetEmbedTitle(ctx, system, opts),
system.Color,
opts
);
}
private string GetEmbedTitle(PKSystem target, ListOptions opts)
private string GetEmbedTitle(Context ctx, PKSystem target, ListOptions opts)
{
var title = new StringBuilder("Groups of ");
if (target.Name != null)
title.Append($"{target.Name} (`{target.Hid}`)");
if (target.NameFor(ctx) != null)
title.Append($"{target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
else
title.Append($"`{target.Hid}`");
title.Append($"`{target.DisplayHid(ctx.Config)}`");
if (opts.Search != null)
title.Append($" matching **{opts.Search}**");
@ -481,12 +511,13 @@ public class Groups
.Title($"Current privacy settings for {target.Name}")
.Field(new Embed.Field("Name", target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation()))
.Field(new Embed.Field("Icon", target.IconPrivacy.Explanation()))
.Field(new Embed.Field("Member list", target.ListPrivacy.Explanation()))
.Field(new Embed.Field("Metadata (creation date)", target.MetadataPrivacy.Explanation()))
.Field(new Embed.Field("Visibility", target.Visibility.Explanation()))
.Description(
$"To edit privacy settings, use the command:\n> pk;group **{target.Reference(ctx)}** privacy **<subject>** **<level>**\n\n- `subject` is one of `name`, `description`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
$"To edit privacy settings, use the command:\n> pk;group **{target.Reference(ctx)}** privacy **<subject>** **<level>**\n\n- `subject` is one of `name`, `description`, `banner`, `icon`, `members`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
.Build());
return;
}
@ -511,6 +542,7 @@ public class Groups
{
GroupPrivacySubject.Name => "name privacy",
GroupPrivacySubject.Description => "description privacy",
GroupPrivacySubject.Banner => "banner privacy",
GroupPrivacySubject.Icon => "icon privacy",
GroupPrivacySubject.List => "member list",
GroupPrivacySubject.Metadata => "metadata",
@ -524,6 +556,8 @@ public class Groups
"This group's name is now hidden from other systems, and will be replaced by the group's display name.",
(GroupPrivacySubject.Description, PrivacyLevel.Private) =>
"This group's description is now hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Private) =>
"This group's banner is now hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Private) =>
"This group's icon is now hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Private) =>
@ -537,6 +571,8 @@ public class Groups
"This group's name is no longer hidden from other systems.",
(GroupPrivacySubject.Description, PrivacyLevel.Public) =>
"This group's description is no longer hidden from other systems.",
(GroupPrivacySubject.Banner, PrivacyLevel.Public) =>
"This group's banner is no longer hidden from other systems.",
(GroupPrivacySubject.Icon, PrivacyLevel.Public) =>
"This group's icon is no longer hidden from other systems.",
(GroupPrivacySubject.Visibility, PrivacyLevel.Public) =>
@ -568,10 +604,10 @@ public class Groups
ctx.CheckOwnGroup(target);
await ctx.Reply(
$"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.Hid}`).\n**Note: this action is permanent.**");
if (!await ctx.ConfirmWithReply(target.Hid))
$"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.DisplayHid(ctx.Config)}`).\n**Note: this action is permanent.**");
if (!await ctx.ConfirmWithReply(target.Hid, treatAsHid: true))
throw new PKError(
$"Group deletion cancelled. Note that you must reply with your group ID (`{target.Hid}`) *verbatim*.");
$"Group deletion cancelled. Note that you must reply with your group ID (`{target.DisplayHid(ctx.Config)}`) *verbatim*.");
await ctx.Repository.DeleteGroup(target.Id);
@ -580,7 +616,7 @@ public class Groups
public async Task DisplayId(Context ctx, PKGroup target)
{
await ctx.Reply(target.Hid);
await ctx.Reply(target.DisplayHid(ctx.Config));
}
private async Task<PKSystem> GetGroupSystem(Context ctx, PKGroup target)

View file

@ -11,12 +11,32 @@ public class Help
{
Title = "PluralKit",
Description = "PluralKit is a bot designed for plural communities on Discord, and is open for anyone to use. It allows you to register systems, maintain system information, set up message proxying, log switches, and more.",
Footer = new("By @Ske#6201 | Myriad design by @Layl#8888, art by https://twitter.com/sillyvizion | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"),
Footer = new("By @ske | Myriad design by @layl, icon by @tedkalashnikov, banner by @fulmine | GitHub: https://github.com/PluralKit/PluralKit/ | Website: https://pluralkit.me/"),
Color = DiscordUtils.Blue,
};
private static Dictionary<string, Embed.Field[]> helpEmbedPages = new Dictionary<string, Embed.Field[]>
{
{
"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",
new Embed.Field[]
@ -29,9 +49,9 @@ public class Help
),
new
(
"Why are people's names saying [BOT] next to them?",
"These people are not actually bots, this is just a Discord limitation. See [the documentation](https://pluralkit.me/guide#proxying) for an in-depth explanation."
),
"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."
)
}
},
{
@ -137,7 +157,9 @@ public class Help
public Task HelpRoot(Context ctx)
=> ctx.Rest.CreateMessage(ctx.Channel.Id, new MessageRequest
{
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description + "\n\n**Use the buttons below to see more info!**" } },
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) },
});
@ -151,7 +173,7 @@ public class Help
if (ctx.Event.Message.Components.First().Components.Where(x => x.CustomId == ctx.CustomId).First().Style == ButtonStyle.Primary)
return ctx.Respond(InteractionResponse.ResponseType.UpdateMessage, new()
{
Embeds = new[] { helpEmbed with { Description = helpEmbed.Description + "\n\n**Use the buttons below to see more info!**" } },
Embeds = new[] { helpEmbed with { Fields = helpEmbedPages.GetValueOrDefault("default") } },
Components = new[] { buttons }
});
@ -168,8 +190,10 @@ public class Help
{
"> **About PluralKit**\nPluralKit detects messages enclosed in specific tags associated with a profile, then replaces that message under a \"pseudo-account\" of that profile using Discord webhooks.",
"This is useful for multiple people sharing one body (aka. *systems*), people who wish to role-play as different characters without having multiple Discord accounts, or anyone else who may want to post messages under a different identity from the same Discord account.",
"Due to Discord limitations, these messages will show up with the `[BOT]` tag - however, they are not bots."
"Due to Discord limitations, these messages will show up with the `[APP]` or `[BOT]` tag - however, they are not apps or bots."
});
public Task Explain(Context ctx) => ctx.Reply(explanation);
public Task Dashboard(Context ctx) => ctx.Reply("The PluralKit dashboard is at <https://dash.pluralkit.me>");
}

View file

@ -45,13 +45,20 @@ public class ImportExport
try
{
var response = await _client.GetAsync(url);
if (!response.IsSuccessStatusCode)
throw Errors.InvalidImportFile;
// hacky fix for discord api returning nonsense charsets sometimes
response.Content.Headers.Remove("content-type");
response.Content.Headers.Add("content-type", "application/json; charset=UTF-8");
var content = await response.Content.ReadAsStringAsync();
if (content == "This content is no longer available.")
{
var refreshed = await ctx.Rest.RefreshUrls(new[] { url.ToString() });
response = await _client.GetAsync(new Uri(refreshed.RefreshedUrls[0].Refreshed));
content = await response.Content.ReadAsStringAsync();
}
if (!response.IsSuccessStatusCode)
throw Errors.InvalidImportFile;
data = JsonConvert.DeserializeObject<JObject>(
await response.Content.ReadAsStringAsync(),
content,
_settings
);
if (data == null)

View file

@ -11,7 +11,7 @@ namespace PluralKit.Bot;
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();
@ -44,7 +44,7 @@ public static class ContextListExt
p.SortProperty = SortProperty.LastSwitch;
if (ctx.MatchFlag("by-last-message", "blm", "blp")) p.SortProperty = SortProperty.LastMessage;
if (ctx.MatchFlag("by-birthday", "by-birthdate", "bbd")) p.SortProperty = SortProperty.Birthdate;
if (ctx.MatchFlag("random")) p.SortProperty = SortProperty.Random;
if (ctx.MatchFlag("random", "rand")) p.SortProperty = SortProperty.Random;
// Sort reverse?
if (ctx.MatchFlag("r", "rev", "reverse"))
@ -55,10 +55,13 @@ public static class ContextListExt
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
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? >.>
throw Errors.NotOwnInfo;
//this is for searching
p.Context = lookupContext;
// Additional fields to include in the search results
if (ctx.MatchFlag("with-last-switch", "with-last-fronted", "with-last-front", "wls", "wlf"))
p.IncludeLastSwitch = true;
@ -124,11 +127,14 @@ public static class ContextListExt
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
// so run it through a helper that "makes it work" :)
eb.WithSimpleLineContent(page.Select(m =>
{
var ret = $"[`{m.Hid}`] **{m.NameFor(ctx)}** ";
var ret = $"[`{m.DisplayHid(ctx.Config, isList: true, shouldPad: shouldPad)}`] **{m.NameFor(ctx)}** ";
if (opts.IncludeMessageCount && m.MessageCountFor(lookupCtx) is { } count)
ret += $"({count} messages)";
@ -162,7 +168,7 @@ public static class ContextListExt
{
foreach (var m in page)
{
var profile = new StringBuilder($"**ID**: {m.Hid}");
var profile = new StringBuilder($"**ID**: {m.DisplayHid(ctx.Config)}");
if (m.DisplayName != null && m.NamePrivacy.CanAccess(lookupCtx))
profile.Append($"\n**Display name**: {m.DisplayName}");
@ -234,11 +240,14 @@ public static class ContextListExt
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
// so run it through a helper that "makes it work" :)
eb.WithSimpleLineContent(page.Select(g =>
{
var ret = $"[`{g.Hid}`] **{g.NameFor(ctx)}** ";
var ret = $"[`{g.DisplayHid(ctx.Config, isList: true, shouldPad: shouldPad)}`] **{g.NameFor(ctx)}** ";
switch (opts.SortProperty)
{
@ -308,7 +317,7 @@ public static class ContextListExt
{
foreach (var g in page)
{
var profile = new StringBuilder($"**ID**: {g.Hid}");
var profile = new StringBuilder($"**ID**: {g.DisplayHid(ctx.Config)}");
if (g.DisplayName != null && g.NamePrivacy.CanAccess(lookupCtx))
profile.Append($"\n**Display name**: {g.DisplayName}");

View file

@ -28,7 +28,9 @@ public class ListOptions
public bool Reverse { get; set; }
public PrivacyLevel? PrivacyFilter { get; set; } = PrivacyLevel.Public;
public LookupContext Context { get; set; } = LookupContext.ByNonOwner;
public GroupId? GroupFilter { get; set; }
public MemberId? MemberFilter { get; set; }
public string? Search { get; set; }
public bool SearchDescription { get; set; }
@ -96,8 +98,10 @@ public class ListOptions
{
PrivacyFilter = PrivacyFilter,
GroupFilter = GroupFilter,
MemberFilter = MemberFilter,
Search = Search,
SearchDescription = SearchDescription
SearchDescription = SearchDescription,
Context = Context
};
}

View file

@ -16,13 +16,15 @@ public class Member
private readonly HttpClient _client;
private readonly DispatchService _dispatch;
private readonly EmbedService _embeds;
private readonly AvatarHostingService _avatarHosting;
public Member(EmbedService embeds, HttpClient client,
DispatchService dispatch)
DispatchService dispatch, AvatarHostingService avatarHosting)
{
_embeds = embeds;
_client = client;
_dispatch = dispatch;
_avatarHosting = avatarHosting;
}
public async Task NewMember(Context ctx)
@ -38,7 +40,7 @@ public class Member
var existingMember = await ctx.Repository.GetMemberByName(ctx.System.Id, memberName);
if (existingMember != null)
{
var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.Hid}`). Do you want to create another member with the same name?";
var msg = $"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (with ID `{existingMember.DisplayHid(ctx.Config)}`). Do you want to create another member with the same name?";
if (!await ctx.PromptYesNo(msg, "Create")) throw new PKError("Member creation cancelled.");
}
@ -67,13 +69,24 @@ public class Member
// Try to match an image attached to the message
var avatarArg = ctx.Message.Attachments.FirstOrDefault();
Exception imageMatchError = null;
ParsedImage img = new();
if (avatarArg != null)
try
{
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url);
await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn);
// XXX: discord attachment URLs are unable to be validated without their query params
// keep both the URL with query (for validation) and the clean URL (for storage) around
var uriBuilder = new UriBuilder(avatarArg.ProxyUrl);
img = new ParsedImage { Url = uriBuilder.Uri.AbsoluteUri, Source = AvatarSource.Attachment };
dispatchData.Add("avatar_url", avatarArg.Url);
uriBuilder.Query = "";
img.CleanUrl = uriBuilder.Uri.AbsoluteUri;
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url);
await ctx.Repository.UpdateMember(member.Id, new MemberPatch { AvatarUrl = img.CleanUrl ?? img.Url }, conn);
dispatchData.Add("avatar_url", img.CleanUrl);
}
catch (Exception e)
{
@ -88,34 +101,34 @@ public class Member
// Send confirmation and space hint
await ctx.Reply(
$"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member");
$"{Emojis.Success} Member \"{memberName}\" (`{member.DisplayHid(ctx.Config)}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member");
// todo: move this to ModelRepository
if (await ctx.Database.Execute(conn => conn.QuerySingleAsync<bool>("select has_private_members(@System)",
new { System = ctx.System.Id })) && !ctx.Config.MemberDefaultPrivate) //if has private members
await ctx.Reply(
$"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.Hid} private`.");
$"{Emojis.Warn} This member is currently **public**. To change this, use `pk;member {member.DisplayHid(ctx.Config)} private`.");
if (avatarArg != null)
if (imageMatchError == null)
await ctx.Reply(
$"{Emojis.Success} Member avatar set to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.");
$"{Emojis.Success} Member avatar set to attached image." + (img.Source == AvatarSource.Attachment ? $"\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working." : ""));
else
await ctx.Reply($"{Emojis.Error} Couldn't set avatar: {imageMatchError.Message}");
if (memberName.Contains(" "))
await ctx.Reply(
$"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's 5-character ID (which is `{member.Hid}`).");
$"{Emojis.Note} Note that this member's name contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's short ID (which is `{member.DisplayHid(ctx.Config)}`).");
if (memberCount >= memberLimit)
await ctx.Reply(
$"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). You will be unable to create additional members until existing members are deleted.");
$"{Emojis.Warn} You have reached the per-system member limit ({memberLimit}). If you need to add more members, you can either delete existing members, or ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>");
else if (memberCount >= Limits.WarnThreshold(memberLimit))
await ctx.Reply(
$"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Please review your member list for unused or duplicate members.");
$"{Emojis.Warn} You are approaching the per-system member limit ({memberCount} / {memberLimit} members). Once you reach this limit, you will be unable to create new members until existing members are deleted, or you can ask for your limit to be raised in the PluralKit support server: <https://discord.gg/PczBt78>");
}
public async Task ViewMember(Context ctx, PKMember target)
{
var system = await ctx.Repository.GetSystem(target.System);
await ctx.Reply(
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.LookupContextFor(system.Id), ctx.Zone));
embed: await _embeds.CreateMemberEmbed(system, target, ctx.Guild, ctx.Config, ctx.LookupContextFor(system.Id), ctx.Zone));
}
public async Task Soulscream(Context ctx, PKMember target)
@ -143,6 +156,6 @@ public class Member
public async Task DisplayId(Context ctx, PKMember target)
{
await ctx.Reply(target.Hid);
await ctx.Reply(target.DisplayHid(ctx.Config));
}
}

View file

@ -9,10 +9,12 @@ namespace PluralKit.Bot;
public class MemberAvatar
{
private readonly HttpClient _client;
private readonly AvatarHostingService _avatarHosting;
public MemberAvatar(HttpClient client)
public MemberAvatar(HttpClient client, AvatarHostingService avatarHosting)
{
_client = client;
_avatarHosting = avatarHosting;
}
private async Task AvatarClear(MemberAvatarLocation location, Context ctx, PKMember target, MemberGuildSettings? mgs)
@ -121,9 +123,10 @@ public class MemberAvatar
MemberGuildSettings? guildData)
{
// First, see if we need to *clear*
if (ctx.MatchClear() && await ctx.ConfirmClear("this member's " + location.Name()))
if (ctx.MatchClear())
{
ctx.CheckSystem().CheckOwnMember(target);
await ctx.ConfirmClear("this member's " + location.Name());
await AvatarClear(location, ctx, target, guildData);
return;
}
@ -138,8 +141,10 @@ public class MemberAvatar
}
ctx.CheckSystem().CheckOwnMember(target);
avatarArg = await _avatarHosting.TryRehostImage(avatarArg.Value, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Value.Url);
await UpdateAvatar(location, ctx, target, avatarArg.Value.Url);
await UpdateAvatar(location, ctx, target, avatarArg.Value.CleanUrl ?? avatarArg.Value.Url);
await PrintResponse(location, ctx, target, avatarArg.Value, guildData);
}
@ -170,13 +175,15 @@ public class MemberAvatar
$"{Emojis.Success} Member {location.Name()} changed to {avatar.SourceUser?.Username}'s avatar!{serverFrag}\n{Emojis.Warn} If {avatar.SourceUser?.Username} changes their avatar, the member's avatar will need to be re-set.",
AvatarSource.Url =>
$"{Emojis.Success} Member {location.Name()} changed to the image at the given URL.{serverFrag}",
AvatarSource.HostedCdn =>
$"{Emojis.Success} Member {location.Name()} changed to attached image.{serverFrag}",
AvatarSource.Attachment =>
$"{Emojis.Success} Member {location.Name()} changed to attached image.{serverFrag}\n{Emojis.Warn} If you delete the message containing the attachment, the avatar will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = avatar.Source != AvatarSource.Attachment;
var hasEmbed = avatar.Source != AvatarSource.Attachment && avatar.Source != AvatarSource.HostedCdn;
return hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(avatar.Url)).Build())
: ctx.Reply(msg);

View file

@ -13,10 +13,12 @@ namespace PluralKit.Bot;
public class MemberEdit
{
private readonly HttpClient _client;
private readonly AvatarHostingService _avatarHosting;
public MemberEdit(HttpClient client)
public MemberEdit(HttpClient client, AvatarHostingService avatarHosting)
{
_client = client;
_avatarHosting = avatarHosting;
}
public async Task Name(Context ctx, PKMember target)
@ -34,7 +36,7 @@ public class MemberEdit
if (existingMember != null && existingMember.Id != target.Id)
{
var msg =
$"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (`{existingMember.Hid}`). Do you want to rename this member to that name too?";
$"{Emojis.Warn} You already have a member in your system with the name \"{existingMember.NameFor(ctx)}\" (`{existingMember.DisplayHid(ctx.Config)}`). Do you want to rename this member to that name too?";
if (!await ctx.PromptYesNo(msg, "Rename")) throw new PKError("Member renaming cancelled.");
}
@ -45,7 +47,7 @@ public class MemberEdit
await ctx.Reply($"{Emojis.Success} Member renamed (using {newName.Length}/{Limits.MaxMemberNameLength} characters).");
if (newName.Contains(" "))
await ctx.Reply(
$"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it.");
$"{Emojis.Note} Note that this member's name now contains spaces. You will need to surround it with \"double quotes\" when using commands referring to it, or just use the member's short ID (which is `{target.DisplayHid(ctx.Config)}`).");
if (target.DisplayName != null)
await ctx.Reply(
$"{Emojis.Note} Note that this member has a display name set ({target.DisplayName}), and will be proxied using that name instead.");
@ -68,30 +70,41 @@ public class MemberEdit
noDescriptionSetMessage +=
$" 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)
{
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply($"```\n{target.Description}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(target.Description, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Description == null)
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Member description")
.Description(target.Description)
.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `pk;member {target.Reference(ctx)} description -raw`."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;member {target.Reference(ctx)} description -clear`."
: "")
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
.Build());
await ctx.Reply(embed: new EmbedBuilder()
.Title("Member description")
.Description(target.Description)
.Field(new Embed.Field("\u200B",
$"To print the description with formatting, type `pk;member {target.Reference(ctx)} description -raw`."
+ (ctx.System?.Id == target.System
? $" To clear it, type `pk;member {target.Reference(ctx)} description -clear`."
: "")
+ $" Using {target.Description.Length}/{Limits.MaxDescriptionLength} characters."))
.Build());
return;
}
@ -120,30 +133,41 @@ public class MemberEdit
{
var noPronounsSetMessage = "This member does not have pronouns set.";
if (ctx.System?.Id == target.System)
noPronounsSetMessage += $"To set some, type `pk;member {target.Reference(ctx)} pronouns <pronouns>`.";
noPronounsSetMessage += $" To set some, type `pk;member {target.Reference(ctx)} pronouns <pronouns>`.";
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)
{
await ctx.Reply(noPronounsSetMessage);
else
await ctx.Reply($"```\n{target.Pronouns}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(target.Pronouns, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Pronouns == null)
await ctx.Reply(noPronounsSetMessage);
else
await ctx.Reply(
$"**{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
? $" To clear them, type `pk;member {target.Reference(ctx)} pronouns -clear`."
: "")
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
await ctx.Reply(
$"**{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
? $" To clear them, type `pk;member {target.Reference(ctx)} pronouns -clear`."
: "")
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
return;
}
@ -180,13 +204,15 @@ public class MemberEdit
async Task SetBannerImage(ParsedImage img)
{
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true);
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.Url });
await ctx.Repository.UpdateMember(target.Id, new MemberPatch { BannerImage = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.Url => $"{Emojis.Success} Member banner image changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} Member banner image changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} Member banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.",
AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."),
@ -194,7 +220,7 @@ public class MemberEdit
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -207,13 +233,13 @@ public class MemberEdit
var eb = new EmbedBuilder()
.Title($"{target.NameFor(ctx)}'s banner image")
.Image(new Embed.EmbedImage(target.BannerImage))
.Description($"To clear, use `pk;member {target.Hid} banner clear`.");
.Description($"To clear, use `pk;member {target.Reference(ctx)} banner clear`.");
await ctx.Reply(embed: eb.Build());
}
else
{
throw new PKSyntaxError(
"This member does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL or @mention.");
"This member does not have a banner image set. Set one by attaching an image to this command, or by passing an image URL.");
}
}
@ -228,7 +254,7 @@ public class MemberEdit
public async Task Color(Context ctx, PKMember target)
{
var isOwnSystem = ctx.System?.Id == target.System;
var matchedRaw = ctx.MatchRaw();
var matchedFormat = ctx.MatchFormat();
var matchedClear = ctx.MatchClear();
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
@ -236,8 +262,10 @@ public class MemberEdit
if (target.Color == null)
await ctx.Reply(
"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```");
else if (matchedFormat == ReplyFormat.Plaintext)
await ctx.Reply(target.Color);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("Member color")
@ -335,7 +363,7 @@ public class MemberEdit
var eb = new EmbedBuilder()
.Title("Member names")
.Footer(new Embed.EmbedFooter(
$"Member ID: {target.Hid} | Active name in bold. Server name overrides display name, which overrides base name."
$"Member ID: {target.DisplayHid(ctx.Config)} | Active name in bold. Server name overrides display name, which overrides base name."
+ (target.DisplayName != null && ctx.System?.Id == target.System ? $" Using {target.DisplayName.Length}/{Limits.MaxMemberNameLength} characters for the display name." : "")
+ (memberGuildConfig?.DisplayName != null ? $" Using {memberGuildConfig?.DisplayName.Length}/{Limits.MaxMemberNameLength} characters for the server name." : "")));
@ -384,12 +412,26 @@ public class MemberEdit
// 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)
{
await ctx.Reply(noDisplayNameSetMessage);
else
await ctx.Reply($"```\n{target.DisplayName}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(target.DisplayName, embed: eb.Build());
return;
}
@ -446,12 +488,26 @@ public class MemberEdit
// No perms check, display name isn't covered by member privacy
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)
{
await ctx.Reply(noServerNameSetMessage);
else
await ctx.Reply($"```\n{memberGuildConfig.DisplayName}\n```");
return;
}
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.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
await ctx.Reply(memberGuildConfig.DisplayName, embed: eb.Build());
return;
}
@ -700,6 +756,7 @@ public class MemberEdit
.Field(new Embed.Field("Name (replaces name with display name if member has one)",
target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation()))
.Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation()))
.Field(new Embed.Field("Birthday", target.BirthdayPrivacy.Explanation()))
.Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation()))
@ -708,7 +765,7 @@ public class MemberEdit
target.MetadataPrivacy.Explanation()))
.Field(new Embed.Field("Visibility", target.MemberVisibility.Explanation()))
.Description(
"To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `avatar`, `birthday`, `pronouns`, `proxies`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
"To edit privacy settings, use the command:\n`pk;member <member> privacy <subject> <level>`\n\n- `subject` is one of `name`, `description`, `banner`, `avatar`, `birthday`, `pronouns`, `proxies`, `metadata`, `visibility`, or `all`\n- `level` is either `public` or `private`.")
.Build());
return;
}
@ -738,6 +795,7 @@ public class MemberEdit
{
MemberPrivacySubject.Name => "name privacy",
MemberPrivacySubject.Description => "description privacy",
MemberPrivacySubject.Banner => "banner privacy",
MemberPrivacySubject.Avatar => "avatar privacy",
MemberPrivacySubject.Pronouns => "pronoun privacy",
MemberPrivacySubject.Birthday => "birthday privacy",
@ -753,6 +811,8 @@ public class MemberEdit
"This member's name is now hidden from other systems, and will be replaced by the member's display name.",
(MemberPrivacySubject.Description, PrivacyLevel.Private) =>
"This member's description is now hidden from other systems.",
(MemberPrivacySubject.Banner, PrivacyLevel.Private) =>
"This member's banner is now hidden from other systems.",
(MemberPrivacySubject.Avatar, PrivacyLevel.Private) =>
"This member's avatar is now hidden from other systems.",
(MemberPrivacySubject.Birthday, PrivacyLevel.Private) =>
@ -770,6 +830,8 @@ public class MemberEdit
"This member's name is no longer hidden from other systems.",
(MemberPrivacySubject.Description, PrivacyLevel.Public) =>
"This member's description is no longer hidden from other systems.",
(MemberPrivacySubject.Banner, PrivacyLevel.Public) =>
"This member's banner is no longer hidden from other systems.",
(MemberPrivacySubject.Avatar, PrivacyLevel.Public) =>
"This member's avatar is no longer hidden from other systems.",
(MemberPrivacySubject.Birthday, PrivacyLevel.Public) =>
@ -812,8 +874,8 @@ public class MemberEdit
ctx.CheckSystem().CheckOwnMember(target);
await ctx.Reply(
$"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.Hid}`). __***This cannot be undone!***__");
if (!await ctx.ConfirmWithReply(target.Hid)) throw Errors.MemberDeleteCancelled;
$"{Emojis.Warn} Are you sure you want to delete \"{target.NameFor(ctx)}\"? If so, reply to this message with the member's ID (`{target.DisplayHid(ctx.Config)}`). __***This cannot be undone!***__");
if (!await ctx.ConfirmWithReply(target.Hid, treatAsHid: true)) throw Errors.MemberDeleteCancelled;
await ctx.Repository.DeleteMember(target.Id);

View file

@ -63,7 +63,7 @@ public class MemberProxy
if (!ctx.HasNext(false))
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 (target.ProxyTags.Contains(tagToAdd))
throw Errors.ProxyTagAlreadyExists(tagToAdd, target);
@ -87,10 +87,17 @@ public class MemberProxy
if (!ctx.HasNext(false))
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 (!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();
newTags.Remove(tagToRemove);
@ -102,7 +109,7 @@ public class MemberProxy
// Subcommand: bare proxy tag given
else
{
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false));
var requestedTag = ParseProxyTags(ctx.RemainderOrNull(false).NormalizeLineEndSpacing());
if (requestedTag.IsEmpty) throw Errors.EmptyProxyTags(target, ctx);
// This is mostly a legacy command, so it's gonna warn if there's

View file

@ -39,11 +39,12 @@ public class ProxiedMessage
private readonly WebhookExecutorService _webhookExecutor;
private readonly ProxyService _proxy;
private readonly LastMessageCacheService _lastMessageCache;
private readonly RedisService _redisService;
public ProxiedMessage(EmbedService embeds,
DiscordApiClient rest, IMetrics metrics, ModelRepository repo, ProxyService proxy,
WebhookExecutorService webhookExecutor, LogChannelService logChannel, IDiscordCache cache,
LastMessageCacheService lastMessageCache)
LastMessageCacheService lastMessageCache, RedisService redisService)
{
_embeds = embeds;
_rest = rest;
@ -54,6 +55,7 @@ public class ProxiedMessage
_metrics = metrics;
_proxy = proxy;
_lastMessageCache = lastMessageCache;
_redisService = redisService;
}
public async Task ReproxyMessage(Context ctx)
@ -91,7 +93,7 @@ public class ProxiedMessage
}
}
public async Task EditMessage(Context ctx)
public async Task EditMessage(Context ctx, bool useRegex)
{
var (msg, systemId) = await GetMessageToEdit(ctx, EditTimeout, false);
@ -103,7 +105,7 @@ public class ProxiedMessage
throw new PKError("Could not edit message.");
// Regex flag
var useRegex = ctx.MatchFlag("regex", "x");
useRegex = useRegex || ctx.MatchFlag("regex", "x");
// Check if we should append or prepend
var mutateSpace = ctx.MatchFlag("nospace", "ns") ? "" : " ";
@ -116,7 +118,8 @@ public class ProxiedMessage
// Should we clear embeds?
var clearEmbeds = ctx.MatchFlag("clear-embed", "ce");
if (clearEmbeds && newContent == null)
var clearAttachments = ctx.MatchFlag("clear-attachments", "ca");
if ((clearEmbeds || clearAttachments) && newContent == null)
newContent = originalMsg.Content!;
if (newContent == null)
@ -216,11 +219,13 @@ public class ProxiedMessage
try
{
var editedMsg =
await _webhookExecutor.EditWebhookMessage(msg.Channel, msg.Mid, newContent, clearEmbeds);
await _webhookExecutor.EditWebhookMessage(msg.Guild ?? 0, msg.Channel, msg.Mid, newContent, clearEmbeds, clearAttachments);
if (ctx.Guild == null)
await _rest.CreateReaction(ctx.Channel.Id, ctx.Message.Id, new Emoji { Name = Emojis.Success });
await _redisService.SetOriginalMid(ctx.Message.Id, editedMsg.Id);
if ((await ctx.BotPermissions).HasFlag(PermissionSet.ManageMessages))
await _rest.DeleteMessage(ctx.Channel.Id, ctx.Message.Id);
@ -348,7 +353,9 @@ public class ProxiedMessage
else if (!await ctx.CheckPermissionsInGuildChannel(channel, PermissionSet.ViewChannel))
showContent = false;
if (ctx.MatchRaw())
var format = ctx.MatchFormat();
if (format != ReplyFormat.Standard)
{
var discordMessage = await _rest.GetMessageOrNull(message.Message.Channel, message.Message.Mid);
if (discordMessage == null || !showContent)
@ -361,21 +368,32 @@ public class ProxiedMessage
return;
}
await ctx.Reply($"```{content}```");
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
if (format == ReplyFormat.Raw)
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
await ctx.Rest.CreateMessage(
ctx.Channel.Id,
new MessageRequest
{
Content = $"{Emojis.Warn} Message contains codeblocks, raw source sent as an attachment."
},
new[] { new MultipartFile("message.txt", stream, null, null, null) });
await ctx.Reply($"```{content}```");
if (Regex.IsMatch(content, "```.*```", RegexOptions.Singleline))
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
await ctx.Rest.CreateMessage(
ctx.Channel.Id,
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)
@ -383,7 +401,8 @@ public class ProxiedMessage
if (!showContent)
throw new PKError(noShowContentError);
if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.Author.Id)
// if user has has a system and their system sent the message, or if user sent the message, do not error
if (!((ctx.System != null && message.System?.Id == ctx.System.Id) || message.Message.Sender == ctx.Author.Id))
throw new PKError("You can only delete your own messages.");
await ctx.Rest.DeleteMessage(message.Message.Channel, message.Message.Mid);
@ -414,19 +433,19 @@ public class ProxiedMessage
return;
}
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent));
await ctx.Reply(embed: await _embeds.CreateMessageInfoEmbed(message, showContent, ctx.Config));
}
private async Task DeleteCommandMessage(Context ctx, ulong messageId)
{
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
if (authorId == null)
var cmessage = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
if (cmessage == null)
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.");
await ctx.Rest.DeleteMessage(channelId!.Value, messageId);
await ctx.Rest.DeleteMessage(cmessage.ChannelId, messageId);
if (ctx.Guild != null)
await ctx.Rest.DeleteMessage(ctx.Message);

View file

@ -37,7 +37,7 @@ public class Random
var randInt = randGen.Next(members.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(target, members[randInt], ctx.Guild,
ctx.LookupContextFor(target.Id), ctx.Zone));
ctx.Config, ctx.LookupContextFor(target.Id), ctx.Zone));
}
public async Task Group(Context ctx, PKSystem target)
@ -67,7 +67,7 @@ public class Random
{
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;
var members = await ctx.Database.Execute(conn => conn.QueryMemberList(group.System, opts.ToQueryOptions()));
@ -93,6 +93,6 @@ public class Random
var randInt = randGen.Next(ms.Count);
await ctx.Reply(embed: await _embeds.CreateMemberEmbed(system, ms[randInt], ctx.Guild,
ctx.LookupContextFor(group.System), ctx.Zone));
ctx.Config, ctx.LookupContextFor(group.System), ctx.Zone));
}
}

View file

@ -18,6 +18,93 @@ public class ServerConfig
_cache = cache;
}
private record PaginatedConfigItem(string Key, string Description, string? CurrentValue, string DefaultValue);
private string EnabledDisabled(bool value) => value ? "enabled" : "disabled";
public async Task ShowConfig(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var items = new List<PaginatedConfigItem>();
// TODO: move log channel / blacklist into here
items.Add(new(
"log cleanup",
"Whether to clean up other bots' log channels",
EnabledDisabled(ctx.GuildConfig!.LogCleanupEnabled),
"disabled"
));
items.Add(new(
"invalid command error",
"Whether to show an error message when an unknown command is sent",
EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled),
"enabled"
));
items.Add(new(
"require tag",
"Whether server users are required to have a system tag on proxied messages",
EnabledDisabled(ctx.GuildConfig!.RequireSystemTag),
"disabled"
));
items.Add(new(
"log channel",
"Channel to log proxied messages to",
ctx.GuildConfig!.LogChannel != null ? $"<#{ctx.GuildConfig.LogChannel}>" : "none",
"none"
));
string ChannelListMessage(int count, string cmd) => $"{count} channels, use `pk;scfg {cmd}` to view/update";
items.Add(new(
"log blacklist",
"Channels whose proxied messages will not be logged",
ChannelListMessage(ctx.GuildConfig!.LogBlacklist.Length, "log blacklist"),
ChannelListMessage(0, "log blacklist")
));
items.Add(new(
"proxy blacklist",
"Channels where message proxying is disabled",
ChannelListMessage(ctx.GuildConfig!.Blacklist.Length, "proxy blacklist"),
ChannelListMessage(0, "proxy blacklist")
));
await ctx.Paginate<PaginatedConfigItem>(
items.ToAsyncEnumerable(),
items.Count,
10,
"Current settings for this server",
null,
(eb, l) =>
{
var description = new StringBuilder();
foreach (var item in l)
{
description.Append(item.Key.AsCode());
description.Append($" **({item.CurrentValue ?? item.DefaultValue})**");
if (item.CurrentValue != null && item.CurrentValue != item.DefaultValue)
description.Append("\ud83d\udd39");
description.AppendLine();
description.Append(item.Description);
description.AppendLine();
description.AppendLine();
}
eb.Description(description.ToString());
// using *large* blue diamond here since it's easier to see in the small footer
eb.Footer(new("\U0001f537 means this setting was changed. Type `pk;serverconfig <setting name> clear` to reset it to the default."));
return Task.CompletedTask;
}
);
}
public async Task SetLogChannel(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
@ -49,7 +136,7 @@ public class ServerConfig
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.");
var perms = await _cache.PermissionsIn(channel.Id);
var perms = await _cache.BotPermissionsIn(ctx.Guild.Id, channel.Id);
if (!perms.HasFlag(PermissionSet.SendMessages))
throw new PKError("PluralKit is missing **Send Messages** permissions in the new log channel.");
if (!perms.HasFlag(PermissionSet.EmbedLinks))
@ -59,6 +146,8 @@ public class ServerConfig
await ctx.Reply($"{Emojis.Success} Proxy logging channel set to <#{channel.Id}>.");
}
// legacy behaviour: enable/disable logging for commands
// new behaviour is add/remove from log blacklist (see #LogBlacklistNew)
public async Task SetLogEnabled(Context ctx, bool enable)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
@ -92,11 +181,11 @@ public class ServerConfig
await ctx.Reply(
$"{Emojis.Success} Message logging for the given channels {(enable ? "enabled" : "disabled")}." +
(logChannel == null
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;log channel #your-log-channel`."
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;serverconfig log channel #your-log-channel`."
: ""));
}
public async Task ShowBlacklisted(Context ctx)
public async Task ShowProxyBlacklisted(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
@ -104,14 +193,14 @@ public class ServerConfig
// Resolve all channels from the cache and order by position
var channels = (await Task.WhenAll(blacklist.Blacklist
.Select(id => _cache.TryGetChannel(id))))
.Select(id => _cache.TryGetChannel(ctx.Guild.Id, id))))
.Where(c => c != null)
.OrderBy(c => c.Position)
.ToList();
if (channels.Count == 0)
{
await ctx.Reply("This server has no blacklisted channels.");
await ctx.Reply("This server has no channels where proxying is disabled.");
return;
}
@ -121,7 +210,7 @@ public class ServerConfig
async (eb, l) =>
{
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;
@ -153,8 +242,9 @@ public class ServerConfig
var config = await ctx.Repository.GetGuild(ctx.Guild.Id);
// Resolve all channels from the cache and order by position
// todo: GetAllChannels?
var channels = (await Task.WhenAll(config.LogBlacklist
.Select(id => _cache.TryGetChannel(id))))
.Select(id => _cache.TryGetChannel(ctx.Guild.Id, id))))
.Where(c => c != null)
.OrderBy(c => c.Position)
.ToList();
@ -171,7 +261,7 @@ public class ServerConfig
async (eb, l) =>
{
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;
@ -197,14 +287,16 @@ public class ServerConfig
}
public async Task SetBlacklisted(Context ctx, bool shouldAdd)
public async Task SetProxyBlacklisted(Context ctx, bool shouldAdd)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (ctx.Match("all"))
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
while (ctx.HasNext())
@ -229,6 +321,42 @@ public class ServerConfig
$"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the proxy blacklist.");
}
public async Task SetLogBlacklisted(Context ctx, bool shouldAdd)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var affectedChannels = new List<Channel>();
if (ctx.Match("all"))
affectedChannels = (await _cache.GetGuildChannels(ctx.Guild.Id))
// 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
while (ctx.HasNext())
{
var channelString = ctx.PeekArgument();
var channel = await ctx.MatchChannel();
if (channel == null || channel.GuildId != ctx.Guild.Id) throw Errors.ChannelNotFound(channelString);
affectedChannels.Add(channel);
}
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
var blacklist = guild.LogBlacklist.ToHashSet();
if (shouldAdd)
blacklist.UnionWith(affectedChannels.Select(c => c.Id));
else
blacklist.ExceptWith(affectedChannels.Select(c => c.Id));
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new GuildPatch { LogBlacklist = blacklist.ToArray() });
await ctx.Reply(
$"{Emojis.Success} Channels {(shouldAdd ? "added to" : "removed from")} the logging blacklist." +
(guild.LogChannel == null
? $"\n{Emojis.Warn} Please note that no logging channel is set, so there is nowhere to log messages to. You can set a logging channel using `pk;serverconfig log channel #your-log-channel`."
: ""));
}
public async Task SetLogCleanup(Context ctx)
{
var botList = string.Join(", ", LoggerCleanService.Bots.Select(b => b.Name).OrderBy(x => x.ToLowerInvariant()));
@ -244,20 +372,16 @@ public class ServerConfig
}
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
var guild = await ctx.Repository.GetGuild(ctx.Guild.Id);
bool? newValue = ctx.MatchToggleOrNull();
if (newValue == null)
{
var guildCfg = await ctx.Repository.GetGuild(ctx.Guild.Id);
if (guildCfg.LogCleanupEnabled)
if (ctx.GuildConfig!.LogCleanupEnabled)
eb.Description(
"Log cleanup is currently **on** for this server. To disable it, type `pk;logclean off`.");
"Log cleanup is currently **on** for this server. To disable it, type `pk;serverconfig logclean off`.");
else
eb.Description(
"Log cleanup is currently **off** for this server. To enable it, type `pk;logclean on`.");
"Log cleanup is currently **off** for this server. To enable it, type `pk;serverconfig logclean on`.");
await ctx.Reply(embed: eb.Build());
return;
}
@ -270,4 +394,36 @@ public class ServerConfig
else
await ctx.Reply($"{Emojis.Success} Log cleanup has been **disabled** for this server.");
}
public async Task InvalidCommandResponse(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (!ctx.HasNext())
{
var msg = $"Error responses for unknown/invalid commands are currently **{EnabledDisabled(ctx.GuildConfig!.InvalidCommandResponseEnabled)}**.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { InvalidCommandResponseEnabled = newVal });
await ctx.Reply($"Error responses for unknown/invalid commands are now {EnabledDisabled(newVal)}.");
}
public async Task RequireSystemTag(Context ctx)
{
await ctx.CheckGuildContext().CheckAuthorPermission(PermissionSet.ManageGuild, "Manage Server");
if (!ctx.HasNext())
{
var msg = $"System tags are currently **{(ctx.GuildConfig!.RequireSystemTag ? "required" : "not required")}** for PluralKit users in this server.";
await ctx.Reply(msg);
return;
}
var newVal = ctx.MatchToggle(false);
await ctx.Repository.UpdateGuild(ctx.Guild.Id, new() { RequireSystemTag = newVal });
await ctx.Reply($"System tags are now **{(newVal ? "required" : "not required")}** for PluralKit users in this server.");
}
}

View file

@ -7,6 +7,7 @@ namespace PluralKit.Bot;
public class Switch
{
public async Task SwitchDo(Context ctx)
{
ctx.CheckSystem();
@ -103,12 +104,69 @@ public class Switch
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();
var members = await ctx.ParseMemberList(ctx.System.Id);
await DoEditCommand(ctx, members);
var newMembers = await ctx.ParseMemberList(ctx.System.Id);
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)

View file

@ -2,6 +2,9 @@ using PluralKit.Core;
namespace PluralKit.Bot;
using Myriad.Builders;
using Myriad.Types;
public class System
{
private readonly EmbedService _embeds;
@ -29,9 +32,25 @@ public class System
var system = await ctx.Repository.CreateSystem(systemName);
await ctx.Repository.AddAccount(system.Id, ctx.Author.Id);
// TODO: better message, perhaps embed like in groups?
await ctx.Reply(
$"{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>");
var eb = new EmbedBuilder()
.Title(
$"{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)
@ -39,6 +58,6 @@ public class System
if (target == null)
throw Errors.NoSystemError;
await ctx.Reply(target.Hid);
await ctx.Reply(target.DisplayHid(ctx.Config));
}
}

View file

@ -18,12 +18,14 @@ public class SystemEdit
private readonly HttpClient _client;
private readonly DataFileService _dataFiles;
private readonly PrivateChannelService _dmCache;
private readonly AvatarHostingService _avatarHosting;
public SystemEdit(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache)
public SystemEdit(DataFileService dataFiles, HttpClient client, PrivateChannelService dmCache, AvatarHostingService avatarHosting)
{
_dataFiles = dataFiles;
_client = client;
_dmCache = dmCache;
_avatarHosting = avatarHosting;
}
public async Task Name(Context ctx, PKSystem target)
@ -35,24 +37,35 @@ public class SystemEdit
if (isOwnSystem)
noNameSetMessage += " Type `pk;system name <name>` to set one.";
if (ctx.MatchRaw())
{
if (target.Name != null)
await ctx.Reply($"```\n{target.Name}\n```");
else
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.Name == null)
{
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(ctx.Config)}`");
await ctx.Reply(target.Name, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Name != null)
await ctx.Reply(
$"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**."
+ (isOwnSystem ? " Type `pk;system name -clear` to clear it." : "")
+ $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters.");
else
await ctx.Reply(noNameSetMessage);
await ctx.Reply(
$"{(isOwnSystem ? "Your" : "This")} system's name is currently **{target.Name}**."
+ (isOwnSystem ? " Type `pk;system name -clear` to clear it." : "")
+ $" Using {target.Name.Length}/{Limits.MaxSystemNameLength} characters.");
return;
}
@ -89,24 +102,35 @@ public class SystemEdit
var settings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id);
if (ctx.MatchRaw())
{
if (settings.DisplayName != null)
await ctx.Reply($"```\n{settings.DisplayName}\n```");
else
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 (settings.DisplayName == null)
{
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(ctx.Config)}`");
await ctx.Reply(settings.DisplayName, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (settings.DisplayName != null)
await ctx.Reply(
$"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**."
+ (isOwnSystem ? " Type `pk;system servername -clear` to clear it." : "")
+ $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters.");
else
await ctx.Reply(noNameSetMessage);
await ctx.Reply(
$"{(isOwnSystem ? "Your" : "This")} system's name for this server is currently **{settings.DisplayName}**."
+ (isOwnSystem ? " Type `pk;system servername -clear` to clear it." : "")
+ $" Using {settings.DisplayName.Length}/{Limits.MaxSystemNameLength} characters.");
return;
}
@ -141,28 +165,39 @@ public class SystemEdit
if (isOwnSystem)
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)
{
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply($"```\n{target.Description}\n```");
return;
}
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(ctx.Config)}`");
await ctx.Reply(target.Description, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Description == null)
await ctx.Reply(noDescriptionSetMessage);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("System description")
.Description(target.Description)
.Footer(new Embed.EmbedFooter(
"To print the description with formatting, type `pk;s description -raw`."
+ (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());
await ctx.Reply(embed: new EmbedBuilder()
.Title("System description")
.Description(target.Description)
.Footer(new Embed.EmbedFooter(
"To print the description with formatting, type `pk;s description -raw`."
+ (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;
}
@ -189,7 +224,7 @@ public class SystemEdit
public async Task Color(Context ctx, PKSystem target)
{
var isOwnSystem = ctx.System?.Id == target.Id;
var matchedRaw = ctx.MatchRaw();
var matchedFormat = ctx.MatchFormat();
var matchedClear = ctx.MatchClear();
if (!isOwnSystem || !(ctx.HasNext() || matchedClear))
@ -197,8 +232,10 @@ public class SystemEdit
if (target.Color == null)
await ctx.Reply(
"This system does not have a color set." + (isOwnSystem ? " To set one, type `pk;system color <color>`." : ""));
else if (matchedRaw)
else if (matchedFormat == ReplyFormat.Raw)
await ctx.Reply("```\n#" + target.Color + "\n```");
else if (matchedFormat == ReplyFormat.Plaintext)
await ctx.Reply(target.Color);
else
await ctx.Reply(embed: new EmbedBuilder()
.Title("System color")
@ -244,22 +281,33 @@ public class SystemEdit
? "You currently have no system tag set. To set one, type `pk;s tag <tag>`."
: "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)
{
await ctx.Reply(noTagSetMessage);
else
await ctx.Reply($"```\n{target.Tag}\n```");
return;
}
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(ctx.Config)}`");
await ctx.Reply(target.Tag, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Tag == null)
await ctx.Reply(noTagSetMessage);
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`." : ""));
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;
}
@ -294,15 +342,22 @@ public class SystemEdit
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 (raw)
if (format == ReplyFormat.Raw)
{
await ctx.Reply($"```{settings.Tag}```");
return;
}
if (format == ReplyFormat.Plaintext)
{
var eb = new EmbedBuilder()
.Description($"Showing servertag for system `{target.DisplayHid(ctx.Config)}`");
await ctx.Reply(settings.Tag, embed: eb.Build());
return;
}
var msg = $"Your current system tag in '{ctx.Guild.Name}' is {settings.Tag.AsCode()}";
if (!settings.TagEnabled)
@ -398,8 +453,8 @@ public class SystemEdit
await EnableDisable(false);
else if (ctx.Match("enable") || ctx.MatchFlag("enable"))
await EnableDisable(true);
else if (ctx.MatchRaw())
await Show(true);
else if (ctx.MatchFormat() != ReplyFormat.Standard)
await Show(ctx.MatchFormat());
else if (!ctx.HasNext(false))
await Show();
else
@ -416,24 +471,35 @@ public class SystemEdit
if (isOwnSystem)
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)
{
await ctx.Reply(noPronounsSetMessage);
else
await ctx.Reply($"```\n{target.Pronouns}\n```");
return;
}
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(ctx.Config)}`");
await ctx.Reply(target.Pronouns, embed: eb.Build());
return;
}
if (!ctx.HasNext(false))
{
if (target.Pronouns == null)
await ctx.Reply(noPronounsSetMessage);
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`."
+ (isOwnSystem ? " To clear them, type `pk;system pronouns -clear`."
: "")
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
await ctx.Reply($"{(isOwnSystem ? "Your" : "This system's")} current pronouns are **{target.Pronouns}**.\nTo print the pronouns with formatting, type `pk;system pronouns -raw`."
+ (isOwnSystem ? " To clear them, type `pk;system pronouns -clear`."
: "")
+ $" Using {target.Pronouns.Length}/{Limits.MaxPronounsLength} characters.");
return;
}
@ -473,22 +539,24 @@ public class SystemEdit
{
ctx.CheckOwnSystem(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url);
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.Url });
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { AvatarUrl = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.User =>
$"{Emojis.Success} System icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon will need to be re-set.",
AvatarSource.Url => $"{Emojis.Success} System icon changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} System icon changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} System icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -541,9 +609,10 @@ public class SystemEdit
{
ctx.CheckOwnSystem(target);
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Avatar, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url);
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.Url });
await ctx.Repository.UpdateSystemGuild(target.Id, ctx.Guild.Id, new SystemGuildPatch { AvatarUrl = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
@ -551,13 +620,14 @@ public class SystemEdit
$"{Emojis.Success} System icon for this server changed to {img.SourceUser?.Username}'s avatar! It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the system icon for this server will need to be re-set.",
AvatarSource.Url =>
$"{Emojis.Success} System icon for this server changed to the image at the given URL. It will now be used for anything that uses system avatar in this server.",
AvatarSource.HostedCdn => $"{Emojis.Success} System icon for this server changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} System icon for this server changed to attached image. It will now be used for anything that uses system avatar in this server.\n{Emojis.Warn} If you delete the message containing the attachment, the system icon for this server will stop working.",
_ => throw new ArgumentOutOfRangeException()
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -602,7 +672,7 @@ public class SystemEdit
public async Task BannerImage(Context ctx, PKSystem target)
{
ctx.CheckSystemPrivacy(target.Id, target.DescriptionPrivacy);
ctx.CheckSystemPrivacy(target.Id, target.BannerPrivacy);
var isOwnSystem = target.Id == ctx.System?.Id;
@ -638,13 +708,15 @@ public class SystemEdit
else if (await ctx.MatchImage() is { } img)
{
img = await _avatarHosting.TryRehostImage(img, AvatarHostingService.RehostedImageType.Banner, ctx.Author.Id, ctx.System);
await AvatarUtils.VerifyAvatarOrThrow(_client, img.Url, true);
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.Url });
await ctx.Repository.UpdateSystem(target.Id, new SystemPatch { BannerImage = img.CleanUrl ?? img.Url });
var msg = img.Source switch
{
AvatarSource.Url => $"{Emojis.Success} System banner image changed to the image at the given URL.",
AvatarSource.HostedCdn => $"{Emojis.Success} System banner image changed to attached image.",
AvatarSource.Attachment =>
$"{Emojis.Success} System banner image changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the banner image will stop working.",
AvatarSource.User => throw new PKError("Cannot set a banner image to an user's avatar."),
@ -652,7 +724,7 @@ public class SystemEdit
};
// The attachment's already right there, no need to preview it.
var hasEmbed = img.Source != AvatarSource.Attachment;
var hasEmbed = img.Source != AvatarSource.Attachment && img.Source != AvatarSource.HostedCdn;
await (hasEmbed
? ctx.Reply(msg, new EmbedBuilder().Image(new Embed.EmbedImage(img.Url)).Build())
: ctx.Reply(msg));
@ -662,19 +734,20 @@ public class SystemEdit
public async Task Delete(Context ctx, PKSystem target)
{
ctx.CheckSystem().CheckOwnSystem(target);
var noExport = ctx.MatchFlag("ne", "no-export");
await ctx.Reply(
$"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{target.Hid}`).\n"
+ $"**Note: this action is permanent,** but you will get a copy of your system's data that can be re-imported into PluralKit at a later date sent to you in DMs."
+ " If you don't want this to happen, use `pk;s delete -no-export` instead.");
if (!await ctx.ConfirmWithReply(target.Hid))
var warnMsg = $"{Emojis.Warn} Are you sure you want to delete your system? If so, reply to this message with your system's ID (`{target.DisplayHid(ctx.Config)}`).\n";
if (!noExport)
warnMsg += "**Note: this action is permanent,** but you will get a copy of your system's data that can be re-imported into PluralKit at a later date sent to you in DMs."
+ " If you don't want this to happen, use `pk;s delete -no-export` instead.";
await ctx.Reply(warnMsg);
if (!await ctx.ConfirmWithReply(target.Hid, treatAsHid: true))
throw new PKError(
$"System deletion cancelled. Note that you must reply with your system ID (`{target.Hid}`) *verbatim*.");
$"System deletion cancelled. Note that you must reply with your system ID (`{target.DisplayHid(ctx.Config)}`) *verbatim*.");
// If the user confirms the deletion, export their system and send them the export file before actually
// deleting their system, unless they specifically tell us not to do an export.
var noExport = ctx.MatchFlag("ne", "no-export");
if (!noExport)
{
var json = await ctx.BusyIndicator(async () =>
@ -762,13 +835,14 @@ public class SystemEdit
.Field(new Embed.Field("Name", target.NamePrivacy.Explanation()))
.Field(new Embed.Field("Avatar", target.AvatarPrivacy.Explanation()))
.Field(new Embed.Field("Description", target.DescriptionPrivacy.Explanation()))
.Field(new Embed.Field("Banner", target.BannerPrivacy.Explanation()))
.Field(new Embed.Field("Pronouns", target.PronounPrivacy.Explanation()))
.Field(new Embed.Field("Member list", target.MemberListPrivacy.Explanation()))
.Field(new Embed.Field("Group list", target.GroupListPrivacy.Explanation()))
.Field(new Embed.Field("Current fronter(s)", target.FrontPrivacy.Explanation()))
.Field(new Embed.Field("Front/switch history", target.FrontHistoryPrivacy.Explanation()))
.Description(
"To edit privacy settings, use the command:\n`pk;system privacy <subject> <level>`\n\n- `subject` is one of `name`, `avatar`, `description`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`.");
"To edit privacy settings, use the command:\n`pk;system privacy <subject> <level>`\n\n- `subject` is one of `name`, `avatar`, `description`, `banner`, `list`, `front`, `fronthistory`, `groups`, or `all` \n- `level` is either `public` or `private`.");
return ctx.Reply(embed: eb.Build());
}
@ -788,6 +862,7 @@ public class SystemEdit
SystemPrivacySubject.Name => "name",
SystemPrivacySubject.Avatar => "avatar",
SystemPrivacySubject.Description => "description",
SystemPrivacySubject.Banner => "banner",
SystemPrivacySubject.Pronouns => "pronouns",
SystemPrivacySubject.Front => "front",
SystemPrivacySubject.FrontHistory => "front history",

View file

@ -76,7 +76,7 @@ public class SystemFront
var members = await ctx.Database.Execute(c => ctx.Repository.GetSwitchMembers(c, sw.Id)).ToListAsync();
var membersStr = members.Any()
? string.Join(", ", members.Select(m => $"**{m.NameFor(ctx)}**{(showMemberId ? $" (`{m.Hid}`)" : "")}"))
? string.Join(", ", members.Select(m => $"**{m.NameFor(ctx)}**{(showMemberId ? $" (`{m.DisplayHid(ctx.Config)}`)" : "")}"))
: "**no fronter**";
var switchSince = SystemClock.Instance.GetCurrentInstant() - sw.Timestamp;
@ -138,13 +138,13 @@ public class SystemFront
if (ctx.Guild != null)
guildSettings = await ctx.Repository.GetSystemGuild(ctx.Guild.Id, system.Id);
if (group != null)
title.Append($"{group.NameFor(ctx)} (`{group.Hid}`)");
title.Append($"{group.NameFor(ctx)} (`{group.DisplayHid(ctx.Config)}`)");
else if (ctx.Guild != null && guildSettings.DisplayName != null)
title.Append($"{guildSettings.DisplayName} (`{system.Hid}`)");
title.Append($"{guildSettings.DisplayName} (`{system.DisplayHid(ctx.Config)}`)");
else if (system.NameFor(ctx) != null)
title.Append($"{system.NameFor(ctx)} (`{system.Hid}`)");
title.Append($"{system.NameFor(ctx)} (`{system.DisplayHid(ctx.Config)}`)");
else
title.Append($"`{system.Hid}`");
title.Append($"`{system.DisplayHid(ctx.Config)}`");
var frontpercent = await ctx.Database.Execute(c => ctx.Repository.GetFrontBreakdown(c, system.Id, group?.Id, rangeStart.Value.ToInstant(), now));
await ctx.Reply(embed: await _embeds.CreateFrontPercentEmbed(frontpercent, system, group, ctx.Zone,

View file

@ -18,7 +18,7 @@ public class SystemLink
var existingAccount = await ctx.Repository.GetSystemByAccount(account.Id);
if (existingAccount != null)
throw Errors.AccountInOtherSystem(existingAccount);
throw Errors.AccountInOtherSystem(existingAccount, ctx.Config);
var msg = $"{account.Mention()}, please confirm the link.";
if (!await ctx.PromptYesNo(msg, "Confirm", account, false)) throw Errors.MemberLinkCancelled;

View file

@ -17,7 +17,7 @@ public class SystemList
// - 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)
// 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(
ctx.LookupContextFor(target.Id),
target.Id,
@ -33,11 +33,11 @@ public class SystemList
var systemGuildSettings = ctx.Guild != null ? await ctx.Repository.GetSystemGuild(ctx.Guild.Id, target.Id) : null;
if (systemGuildSettings != null && systemGuildSettings.DisplayName != null)
title.Append($"{systemGuildSettings.DisplayName} (`{target.Hid}`)");
title.Append($"{systemGuildSettings.DisplayName} (`{target.DisplayHid(ctx.Config)}`)");
else if (target.NameFor(ctx) != null)
title.Append($"{target.NameFor(ctx)} (`{target.Hid}`)");
title.Append($"{target.NameFor(ctx)} (`{target.DisplayHid(ctx.Config)}`)");
else
title.Append($"`{target.Hid}`");
title.Append($"`{target.DisplayHid(ctx.Config)}`");
if (opts.Search != null)
title.Append($" matching **{opts.Search.Truncate(100)}**");

View file

@ -120,8 +120,8 @@ public static class Errors
public static PKError UrlTooLong(string url) =>
new($"The given URL is too long ({url.Length}/{Limits.MaxUriLength} characters).");
public static PKError AccountInOtherSystem(PKSystem system) =>
new($"The mentioned account is already linked to another system (see `pk;system {system.Hid}`).");
public static PKError AccountInOtherSystem(PKSystem system, SystemConfig config) =>
new($"The mentioned account is already linked to another system (see `pk;system {system.DisplayHid(config)}`).");
public static PKError SameSwitch(ICollection<PKMember> members, LookupContext ctx)
{
@ -162,7 +162,7 @@ public static class Errors
$"The webhook's name, {name.AsCode()}, is shorter than two characters, and thus cannot be proxied. Please change the member name or use a longer system tag.");
public static PKError ProxyNameTooLong(string name) => new(
$"The webhook's name, {name.AsCode()}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name, display name or server display name, or use a shorter system tag.");
$"The webhook's name, {name.AsCode()}, is too long ({name.Length} > {Limits.MaxProxyNameLength} characters), and thus cannot be proxied. Please change the member name, display name, server display name, system tag, or use a shorter name format");
public static PKError ProxyTagAlreadyExists(ProxyTag tagToAdd, PKMember member) => new(
$"That member already has the proxy tag {tagToAdd.ProxyString.AsCode()}. The member currently has these tags: {member.ProxyTagsString()}");

View file

@ -6,5 +6,5 @@ public interface IEventHandler<in T> where T : IGatewayEvent
{
Task Handle(int shardId, T evt);
ulong? ErrorChannelFor(T evt, ulong userId) => null;
(ulong?, ulong?) ErrorChannelFor(T evt, ulong userId) => (null, null);
}

View file

@ -16,20 +16,23 @@ public class InteractionCreated: IEventHandler<InteractionCreateEvent>
private readonly ApplicationCommandTree _commandTree;
private readonly ILifetimeScope _services;
private readonly ILogger _logger;
private readonly ModelRepository _repo;
public InteractionCreated(InteractionDispatchService interactionDispatch, ApplicationCommandTree commandTree,
ILifetimeScope services, ILogger logger)
ILifetimeScope services, ILogger logger, ModelRepository repo)
{
_interactionDispatch = interactionDispatch;
_commandTree = commandTree;
_services = services;
_logger = logger;
_repo = repo;
}
public async Task Handle(int shardId, InteractionCreateEvent evt)
{
var system = await _services.Resolve<ModelRepository>().GetSystemByAccount(evt.Member?.User.Id ?? evt.User!.Id);
var ctx = new InteractionContext(_services, evt, system);
var system = await _repo.GetSystemByAccount(evt.Member?.User.Id ?? evt.User!.Id);
var config = system != null ? await _repo.GetSystemConfig(system!.Id) : null;
var ctx = new InteractionContext(_services, evt, system, config);
switch (evt.Type)
{

View file

@ -52,7 +52,7 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
_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) =>
// 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;
@ -63,7 +63,7 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (evt.Type != Message.MessageType.Default && evt.Type != Message.MessageType.Reply) return;
if (IsDuplicateMessage(evt)) return;
var botPermissions = await _cache.PermissionsIn(evt.ChannelId);
var botPermissions = await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId);
if (!botPermissions.HasFlag(PermissionSet.SendMessages)) return;
// spawn off saving the private channel into another thread
@ -71,8 +71,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
_ = _dmCache.TrySavePrivateChannel(evt);
var guild = evt.GuildId != null ? await _cache.GetGuild(evt.GuildId.Value) : null;
var channel = await _cache.GetChannel(evt.ChannelId);
var rootChannel = await _cache.GetRootChannel(evt.ChannelId);
var channel = await _cache.GetChannel(evt.GuildId ?? 0, evt.ChannelId);
var rootChannel = await _cache.GetRootChannel(evt.GuildId ?? 0, evt.ChannelId);
// Log metrics and message info
_metrics.Measure.Meter.Mark(BotMetrics.MessagesReceived);
@ -90,7 +90,8 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (await TryHandleCommand(shardId, evt, guild, channel))
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)
@ -113,6 +114,11 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
if (!HasCommandPrefix(content, _config.ClientId, out var cmdStart) || cmdStart == content.Length)
return false;
// if the command message was sent by a user account with bot usage disallowed, ignore it
var abuse_log = await _repo.GetAbuseLogByAccount(evt.Author.Id);
if (abuse_log != null && abuse_log.DenyBotUsage)
return false;
// Trim leading whitespace from command without actually modifying the string
// This just moves the argPos pointer by however much whitespace is at the start of the post-argPos string
var trimStartLengthDiff =
@ -123,7 +129,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
{
var system = await _repo.GetSystemByAccount(evt.Author.Id);
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config));
var guildConfig = guild != null ? await _repo.GetGuild(guild.Id) : null;
await _tree.ExecuteCommand(new Context(_services, shardId, guild, channel, evt, cmdStart, system, config, guildConfig));
}
catch (PKError)
{
@ -161,6 +169,9 @@ public class MessageCreated: IEventHandler<MessageCreateEvent>
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Id, evt.GuildId ?? default, rootChannel, channel.Id != rootChannel ? channel.Id : default);
if (ctx.DenyBotUsage)
return false;
try
{
return await _proxy.HandleIncomingMessage(evt, ctx, guild, channel, true, botPermissions);

View file

@ -52,11 +52,19 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
if (!evt.Content.HasValue || !evt.Author.HasValue || !evt.Member.HasValue)
return;
var channel = await _cache.GetChannel(evt.ChannelId);
// we only use message edit event for proxying, so ignore messages from DMs
if (!evt.GuildId.HasValue || evt.GuildId.Value == null) return;
ulong guildId = evt.GuildId!.Value!.Value;
var channel = await _cache.TryGetChannel(guildId, evt.ChannelId); // todo: is this correct for message update?
if (channel == null)
throw new Exception("could not find self channel in MessageEdited event");
if (!DiscordUtils.IsValidGuildChannel(channel))
return;
var rootChannel = await _cache.GetRootChannel(channel.Id);
var guild = await _cache.GetGuild(channel.GuildId!.Value);
var rootChannel = await _cache.GetRootChannel(guildId, channel.Id);
var guild = await _cache.TryGetGuild(channel.GuildId!.Value);
if (guild == null)
throw new Exception("could not find self guild in MessageEdited event");
var lastMessage = _lastMessageCache.GetLastMessage(evt.ChannelId)?.Current;
// Only react to the last message in the channel
@ -67,9 +75,11 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
MessageContext ctx;
using (_metrics.Measure.Timer.Time(BotMetrics.MessageContextQueryTime))
ctx = await _repo.GetMessageContext(evt.Author.Value!.Id, channel.GuildId!.Value, rootChannel.Id, evt.ChannelId);
if (ctx.DenyBotUsage)
return;
var equivalentEvt = await GetMessageCreateEvent(evt, lastMessage, channel);
var botPermissions = await _cache.PermissionsIn(channel.Id);
var botPermissions = await _cache.BotPermissionsIn(guildId, channel.Id);
try
{
@ -91,7 +101,7 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
private async Task<MessageCreateEvent> GetMessageCreateEvent(MessageUpdateEvent evt, CachedMessage lastMessage,
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
? new Message.Reference(channel.GuildId, evt.ChannelId, lastMessage.ReferencedMessage.Value)
@ -118,12 +128,12 @@ public class MessageEdited: IEventHandler<MessageUpdateEvent>
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)
return null;
var botPermissions = await _cache.PermissionsIn(channelId);
var botPermissions = await _cache.BotPermissionsIn(guildId, channelId);
if (!botPermissions.HasFlag(PermissionSet.ReadMessageHistory))
{
_logger.Warning(

View file

@ -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
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
// since this can happen in DMs as well
@ -75,16 +75,17 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
return;
}
var (authorId, _) = await _commandMessageService.GetCommandMessage(evt.MessageId);
if (authorId != null)
var cmessage = await _commandMessageService.GetCommandMessage(evt.MessageId);
if (cmessage != null)
{
await HandleCommandDeleteReaction(evt, authorId.Value, false);
await HandleCommandDeleteReaction(evt, cmessage.AuthorId, false);
return;
}
}
// Proxied messages only exist in guild text channels, so skip checking if we're elsewhere
if (!DiscordUtils.IsValidGuildChannel(channel)) return;
var abuse_log = await _repo.GetAbuseLogByAccount(evt.Member!.User!.Id);
switch (evt.Emoji.Name.Split("\U0000fe0f", 2)[0])
{
@ -113,6 +114,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
case "\u23F0": // Alarm clock
case "\u2757": // Exclamation mark
{
if (abuse_log != null && abuse_log.DenyBotUsage) break;
var msg = await _repo.GetFullMessage(evt.MessageId);
if (msg != null)
await HandlePingReaction(evt, msg);
@ -123,7 +125,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
private async ValueTask HandleProxyDeleteReaction(MessageReactionAddEvent evt, PKMessage msg)
{
if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
if (!(await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
return;
var isSameSystem = msg.Member != null && await _repo.IsMemberOwnedByAccount(msg.Member.Value, evt.UserId);
@ -150,7 +152,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
if (authorId != null && authorId != evt.UserId)
return;
if (!((await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
if (!((await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
return;
// todo: don't try to delete the user's own messages in DMs
@ -175,6 +177,8 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
private async ValueTask HandleQueryReaction(MessageReactionAddEvent evt, FullMessage msg)
{
var guild = await _cache.GetGuild(evt.GuildId!.Value);
var system = await _repo.GetSystemByAccount(evt.UserId);
var config = system != null ? await _repo.GetSystemConfig(system.Id) : null;
// Try to DM the user info about the message
try
@ -188,11 +192,12 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
msg.System,
msg.Member,
guild,
config,
LookupContext.ByNonOwner,
DateTimeZone.Utc
));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, true));
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, true, config));
await _rest.CreateMessage(dm, new MessageRequest { Embeds = embeds.ToArray() });
}
@ -203,14 +208,14 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
private async ValueTask HandlePingReaction(MessageReactionAddEvent evt, FullMessage msg)
{
if (!(await _cache.PermissionsIn(evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
if (!(await _cache.BotPermissionsIn(evt.GuildId ?? 0, evt.ChannelId)).HasFlag(PermissionSet.ManageMessages))
return;
// Check if the "pinger" has permission to send messages in this channel
// (if not, PK shouldn't send messages on their behalf)
var member = await _rest.GetGuildMember(evt.GuildId!.Value, evt.UserId);
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
if (member == null || !(await _cache.PermissionsFor(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;
@ -263,7 +268,7 @@ public class ReactionAdded: IEventHandler<MessageReactionAddEvent>
private async Task TryRemoveOriginalReaction(MessageReactionAddEvent evt)
{
if ((await _cache.PermissionsIn(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);
}
}

View file

@ -42,10 +42,10 @@ public class Init
using var _ = SentrySdk.Init(opts =>
{
opts.Dsn = services.Resolve<CoreConfig>().SentryUrl;
opts.Dsn = services.Resolve<CoreConfig>().SentryUrl ?? "";
opts.Release = BuildInfoService.FullVersion;
opts.AutoSessionTracking = true;
opts.DisableTaskUnobservedTaskExceptionCapture();
// opts.DisableTaskUnobservedTaskExceptionCapture();
});
var config = services.Resolve<BotConfig>();
@ -56,8 +56,6 @@ public class Init
await redis.InitAsync(coreConfig);
var cache = services.Resolve<IDiscordCache>();
if (cache is RedisDiscordCache)
await (cache as RedisDiscordCache).InitAsync(coreConfig.RedisAddr);
if (config.Cluster == null)
{

View file

@ -84,20 +84,32 @@ public class YesNoPrompt: BaseInteractive
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();
cts.Token.Register(() => _tcs.TrySetException(new TimeoutException("Action timed out")));
var messageDispatch = WaitForMessage();
cts.Token.Register(() => _tcs.TrySetException(new TimeoutException("YesNoPrompt timed out")));
try
{
var doneTask = await Task.WhenAny(_tcs.Task, messageDispatch);
if (doneTask == messageDispatch)
await Finish();
}
finally
{
await Finish();
Cleanup();
}
}

View file

@ -48,8 +48,25 @@ public class BotModule: Module
{
var botConfig = c.Resolve<BotConfig>();
if (botConfig.UseRedisCache)
return new RedisDiscordCache(c.Resolve<ILogger>(), botConfig.ClientId);
if (botConfig.HttpCacheUrl != null)
{
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);
}).AsSelf().SingleInstance();
builder.RegisterType<PrivateChannelService>().AsSelf().SingleInstance();
@ -136,6 +153,7 @@ public class BotModule: Module
builder.RegisterType<ErrorMessageService>().AsSelf().SingleInstance();
builder.RegisterType<CommandMessageService>().AsSelf().SingleInstance();
builder.RegisterType<InteractionDispatchService>().AsSelf().SingleInstance();
builder.RegisterType<AvatarHostingService>().AsSelf().SingleInstance();
// Sentry stuff
builder.Register(_ => new Scope(null)).AsSelf().InstancePerLifetimeScope();

View file

@ -26,8 +26,8 @@
<PackageReference Include="Grpc.Net.ClientFactory" Version="2.32.0" />
<PackageReference Include="Grpc.Tools" Version="2.47.0" PrivateAssets="all" />
<PackageReference Include="Humanizer.Core" Version="2.8.26" />
<PackageReference Include="Sentry" Version="3.11.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
<PackageReference Include="Sentry" Version="4.12.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
</ItemGroup>
<ItemGroup>

View file

@ -59,7 +59,7 @@ public class ProxyService
public async Task<bool> HandleIncomingMessage(MessageCreateEvent message, MessageContext ctx,
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))
return false;
@ -102,7 +102,7 @@ public class ProxyService
// Check if the sender account can mention everyone/here + embed links
// we need to "mirror" these permissions when proxying to prevent exploits
var senderPermissions = PermissionExtensions.PermissionsFor(guild, rootChannel, message, isThread: rootChannel.Id != channel.Id);
var senderPermissions = PermissionExtensions.PermissionsFor(guild, rootChannel, message.Author.Id, message.Member, isThread: rootChannel.Id != channel.Id);
var allowEveryone = senderPermissions.HasFlag(PermissionSet.MentionEveryone);
var allowEmbeds = senderPermissions.HasFlag(PermissionSet.EmbedLinks);
@ -111,31 +111,10 @@ public class ProxyService
return true;
}
#pragma warning disable CA1822 // Mark members as static
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,
};
}
// Proxy checks that give user errors
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.";
// Check if the message does not go over any Discord Nitro limits
@ -144,6 +123,21 @@ public class ProxyService
return "PluralKit cannot proxy messages over 2000 characters in length.";
}
if (ctx.RequireSystemTag)
{
if (!ctx.TagEnabled)
{
return "This server requires PluralKit users to have a system tag, but your system tag is disabled in this server. " +
"Use `pk;s servertag -enable` to enable it for this server.";
}
if (!ctx.HasProxyableTag())
{
return "This server requires PluralKit users to have a system tag, but you do not have one set. " +
"A system tag can be set for all servers with `pk;s tag`, or for just this server with `pk;s servertag`.";
}
}
var guild = await _cache.GetGuild(channel.GuildId.Value);
var fileSizeLimit = guild.FileSizeLimit();
var bytesThreshold = fileSizeLimit * 1024 * 1024;
@ -159,6 +153,7 @@ public class ProxyService
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)
{
// Make sure author has a system
@ -189,9 +184,9 @@ public class ProxyService
throw new ProxyChecksFailedException(
"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;
if (isMessageBlank && msg.Attachments.Length == 0)
if (isMessageBlank && msg.Attachments.Length == 0 && msg.Poll == null)
throw new ProxyChecksFailedException("Message cannot be blank.");
if (msg.Activity != null)
@ -227,8 +222,8 @@ public class ProxyService
var content = match.ProxyContent;
if (!allowEmbeds) content = content.BreakLinkEmbeds();
var messageChannel = await _cache.GetChannel(trigger.ChannelId);
var rootChannel = await _cache.GetRootChannel(trigger.ChannelId);
var messageChannel = await _cache.GetChannel(trigger.GuildId!.Value, trigger.ChannelId);
var rootChannel = await _cache.GetRootChannel(trigger.GuildId!.Value, trigger.ChannelId);
var threadId = messageChannel.IsThread() ? messageChannel.Id : (ulong?)null;
var guild = await _cache.GetGuild(trigger.GuildId.Value);
var guildMember = await _rest.GetGuildMember(trigger.GuildId!.Value, trigger.Author.Id);
@ -242,6 +237,7 @@ public class ProxyService
GuildId = trigger.GuildId!.Value,
ChannelId = rootChannel.Id,
ThreadId = threadId,
MessageId = trigger.Id,
Name = await FixSameName(messageChannel.Id, ctx, match.Member),
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
Content = content,
@ -252,6 +248,7 @@ public class ProxyService
AllowEveryone = allowEveryone,
Flags = trigger.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
Tts = tts,
Poll = trigger.Poll,
});
await HandleProxyExecutedActions(ctx, autoproxySettings, trigger, proxyMessage, match);
}
@ -310,6 +307,7 @@ public class ProxyService
GuildId = guild.Id,
ChannelId = rootChannel.Id,
ThreadId = threadId,
MessageId = originalMsg.Id,
Name = match.Member.ProxyName(ctx),
AvatarUrl = AvatarUtils.TryRewriteCdnUrl(match.Member.ProxyAvatar(ctx)),
Content = match.ProxyContent!,
@ -320,6 +318,7 @@ public class ProxyService
AllowEveryone = allowEveryone,
Flags = originalMsg.Flags.HasFlag(Message.MessageFlags.VoiceMessage) ? Message.MessageFlags.VoiceMessage : null,
Tts = tts,
Poll = originalMsg.Poll,
});
@ -497,10 +496,10 @@ public class ProxyService
async Task SaveMessageInRedis()
{
// logclean info
await _redis.SetLogCleanup(triggerMessage.Author.Id, triggerMessage.GuildId.Value);
await _redis.SetLogCleanup(triggerMessage.Author.Id, proxyMessage.GuildId!.Value);
// last message info (edit/reproxy)
await _redis.SetLastMessage(triggerMessage.Author.Id, triggerMessage.ChannelId, sentMessage.Mid);
await _redis.SetLastMessage(triggerMessage.Author.Id, proxyMessage.ChannelId, sentMessage.Mid);
// "by original mid" lookup
await _redis.SetOriginalMid(triggerMessage.Id, proxyMessage.Id);
@ -522,6 +521,10 @@ public class ProxyService
Task DispatchWebhook() => _dispatch.Dispatch(ctx.SystemId.Value, sentMessage);
Task MaybeLogSwitch() => (ctx.ProxySwitch && !Array.Exists(ctx.LastSwitchMembers, element => element == match.Member.Id))
? _db.Execute(conn => _repo.AddSwitch(conn, (SystemId)ctx.SystemId, new[] { match.Member.Id }))
: Task.CompletedTask;
async Task DeleteProxyTriggerMessage()
{
if (!deletePrevious)
@ -555,7 +558,8 @@ public class ProxyService
UpdateMemberForSentMessage(),
LogMessageToChannel(),
SaveLatchAutoproxy(),
DispatchWebhook()
DispatchWebhook(),
MaybeLogSwitch()
);
}

View file

@ -0,0 +1,79 @@
using PluralKit.Core;
using System.Net;
using System.Net.Http.Json;
namespace PluralKit.Bot;
public class AvatarHostingService
{
private readonly BotConfig _config;
private readonly HttpClient _client;
public AvatarHostingService(BotConfig config)
{
_config = config;
_client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10),
};
}
public async Task<ParsedImage> TryRehostImage(ParsedImage input, RehostedImageType type, ulong userId, PKSystem? system)
{
try
{
var uploaded = await TryUploadAvatar(input.Url, type, userId, system);
if (uploaded != null)
{
// todo: make new image type called Cdn?
return new ParsedImage { Url = uploaded, Source = AvatarSource.HostedCdn };
}
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)
{
if (!AvatarUtils.IsDiscordCdnUrl(avatarUrl))
return null;
if (_config.AvatarServiceUrl == null)
return null;
var kind = type switch
{
RehostedImageType.Avatar => "avatar",
RehostedImageType.Banner => "banner",
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
};
var response = await _client.PostAsJsonAsync(_config.AvatarServiceUrl + "/pull",
new { url = avatarUrl, kind, uploaded_by = userId, system_id = system?.Uuid.ToString() });
if (response.StatusCode != HttpStatusCode.OK)
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>();
throw new PKError($"Error uploading image to CDN: {error.Error}");
}
var success = await response.Content.ReadFromJsonAsync<SuccessResponse>();
return success.Url;
}
public record ErrorResponse(string Error);
public record SuccessResponse(string Url, bool New);
public enum RehostedImageType
{
Avatar,
Banner,
}
}

View file

@ -18,7 +18,7 @@ public class 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;
@ -27,17 +27,19 @@ public class CommandMessageService
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());
if (str.HasValue)
{
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);

View file

@ -56,29 +56,18 @@ public class EmbedService
var memberCount = await _repo.GetSystemMemberCount(system.Id, countctx == LookupContext.ByOwner ? null : PrivacyLevel.Public);
uint color;
try
{
color = system.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// There's no API for system colors yet, but defaulting to a blank color in advance can't be a bad idea
color = DiscordUtils.Gray;
}
var eb = new EmbedBuilder()
.Title(system.NameFor(ctx))
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(color)
$"System ID: {system.DisplayHid(cctx.Config)} | Created on {system.Created.FormatZoned(cctx.Zone)}"))
.Color(system.Color?.ToDiscordColor())
.Url($"https://dash.pluralkit.me/profile/s/{system.Hid}");
var avatar = system.AvatarFor(ctx);
if (avatar != null)
eb.Thumbnail(new Embed.EmbedThumbnail(avatar));
if (system.DescriptionPrivacy.CanAccess(ctx))
if (system.BannerPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(system.BannerImage));
var latestSwitch = await _repo.GetLatestSwitch(system.Id);
@ -90,7 +79,7 @@ public class EmbedService
{
var memberStr = string.Join(", ", switchMembers.Select(m => m.NameFor(ctx)));
if (memberStr.Length > 200)
memberStr = $"[too many to show, see `pk;system {system.Hid} fronters`]";
memberStr = $"[too many to show, see `pk;system {system.DisplayHid(cctx.Config)} fronters`]";
eb.Field(new Embed.Field("Fronter".ToQuantity(switchMembers.Count, ShowQuantityAs.None), memberStr));
}
}
@ -137,7 +126,7 @@ public class EmbedService
{
if (memberCount > 0)
eb.Field(new Embed.Field($"Members ({memberCount})",
$"(see `pk;system {system.Hid} list` or `pk;system {system.Hid} list full`)", true));
$"(see `pk;system {system.DisplayHid(cctx.Config)} list` or `pk;system {system.DisplayHid(cctx.Config)} list full`)", true));
else
eb.Field(new Embed.Field($"Members ({memberCount})", "Add one with `pk;member new`!", true));
}
@ -175,7 +164,7 @@ public class EmbedService
return embed.Build();
}
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, LookupContext ctx, DateTimeZone zone)
public async Task<Embed> CreateMemberEmbed(PKSystem system, PKMember member, Guild guild, SystemConfig? ccfg, LookupContext ctx, DateTimeZone zone)
{
// string FormatTimestamp(Instant timestamp) => DateTimeFormats.ZonedDateTimeFormat.Format(timestamp.InZone(system.Zone));
@ -188,19 +177,6 @@ public class EmbedService
else
name = $"{name}";
uint color;
try
{
color = member.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// Bad API use can cause an invalid color string
// this is now fixed in the API, but might still have some remnants in the database
// so we just default to a blank color, yolo
color = DiscordUtils.Gray;
}
var guildSettings = guild != null ? await _repo.GetMemberGuild(guild.Id, member.Id) : null;
var guildDisplayName = guildSettings?.DisplayName;
var webhook_avatar = guildSettings?.AvatarUrl ?? member.WebhookAvatarFor(ctx) ?? member.AvatarFor(ctx);
@ -213,12 +189,12 @@ public class EmbedService
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(name, IconUrl: webhook_avatar.TryGetCleanCdnUrl(), Url: $"https://dash.pluralkit.me/profile/m/{member.Hid}"))
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : DiscordUtils.Gray)
.Color(color)
// .WithColor(member.ColorPrivacy.CanAccess(ctx) ? color : null)
.Color(member.Color?.ToDiscordColor())
.Footer(new Embed.EmbedFooter(
$"System ID: {system.Hid} | Member ID: {member.Hid} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(zone)}" : "")}"));
$"System ID: {system.DisplayHid(ccfg)} | Member ID: {member.DisplayHid(ccfg)} {(member.MetadataPrivacy.CanAccess(ctx) ? $"| Created on {member.Created.FormatZoned(zone)}" : "")}"));
if (member.DescriptionPrivacy.CanAccess(ctx))
if (member.BannerPrivacy.CanAccess(ctx))
eb.Image(new Embed.EmbedImage(member.BannerImage));
var description = "";
@ -255,7 +231,7 @@ public class EmbedService
// More than 5 groups show in "compact" format without ID
var content = groups.Count > 5
? string.Join(", ", groups.Select(g => g.DisplayName ?? g.Name))
: string.Join("\n", groups.Select(g => $"[`{g.Hid}`] **{g.DisplayName ?? g.Name}**"));
: string.Join("\n", groups.Select(g => $"[`{g.DisplayHid(ccfg, isList: true)}`] **{g.DisplayName ?? g.Name}**"));
eb.Field(new Embed.Field($"Groups ({groups.Count})", content.Truncate(1000)));
}
@ -287,26 +263,15 @@ public class EmbedService
else if (system.NameFor(ctx) != null)
nameField = $"{nameField} ({system.NameFor(ctx)})";
else
nameField = $"{nameField} ({system.Name})";
uint color;
try
{
color = target.Color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
// There's no API for group colors yet, but defaulting to a blank color regardless
color = DiscordUtils.Gray;
}
nameField = $"{nameField}";
var eb = new EmbedBuilder()
.Author(new Embed.EmbedAuthor(nameField, IconUrl: target.IconFor(pctx), Url: $"https://dash.pluralkit.me/profile/g/{target.Hid}"))
.Color(color);
.Color(target.Color?.ToDiscordColor());
eb.Footer(new Embed.EmbedFooter($"System ID: {system.Hid} | Group ID: {target.Hid}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
eb.Footer(new Embed.EmbedFooter($"System ID: {system.DisplayHid(ctx.Config)} | Group ID: {target.DisplayHid(ctx.Config)}{(target.MetadataPrivacy.CanAccess(pctx) ? $" | Created on {target.Created.FormatZoned(ctx.Zone)}" : "")}"));
if (target.DescriptionPrivacy.CanAccess(pctx))
if (target.BannerPrivacy.CanAccess(pctx))
eb.Image(new Embed.EmbedImage(target.BannerImage));
if (target.NamePrivacy.CanAccess(pctx) && target.DisplayName != null)
@ -324,7 +289,7 @@ public class EmbedService
{
var name = pctx == LookupContext.ByOwner
? target.Reference(ctx)
: target.Hid;
: target.DisplayHid(ctx.Config);
eb.Field(new Embed.Field($"Members ({memberCount})", $"(see `pk;group {name} list`)"));
}
}
@ -362,16 +327,16 @@ public class EmbedService
}
return new EmbedBuilder()
.Color(members.FirstOrDefault()?.Color?.ToDiscordColor() ?? DiscordUtils.Gray)
.Color(members.FirstOrDefault()?.Color?.ToDiscordColor())
.Field(new Embed.Field($"Current {"fronter".ToQuantity(members.Count, ShowQuantityAs.None)}", memberStr))
.Field(new Embed.Field("Since",
$"{sw.Timestamp.FormatZoned(zone)} ({timeSinceSwitch.FormatDuration()} ago)"))
.Build();
}
public async Task<Embed> CreateMessageInfoEmbed(FullMessage msg, bool showContent)
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 serverMsg = await _rest.GetMessageOrNull(msg.Message.Channel, msg.Message.Mid);
@ -424,27 +389,29 @@ public class EmbedService
.Field(new Embed.Field("System",
msg.System == null
? "*(deleted or unknown system)*"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.Hid}`)" : $"`{msg.System.Hid}`"
: msg.System.NameFor(ctx) != null ? $"{msg.System.NameFor(ctx)} (`{msg.System.DisplayHid(ccfg)}`)" : $"`{msg.System.DisplayHid(ccfg)}`"
, true))
.Field(new Embed.Field("Member",
msg.Member == null
? "*(deleted member)*"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.Hid}`)"
: $"{msg.Member.NameFor(ctx)} (`{msg.Member.DisplayHid(ccfg)}`)"
, true))
.Field(new Embed.Field("Sent by", userStr, true))
.Timestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset().ToString("O"));
.Timestamp(DiscordUtils.SnowflakeToInstant(msg.Message.Mid).ToDateTimeOffset().ToString("O"))
.Footer(new Embed.EmbedFooter($"Original Message ID: {msg.Message.OriginalMid}"));
var roles = memberInfo?.Roles?.ToList();
if (roles != null && roles.Count > 0 && showContent)
{
var rolesString = string.Join(", ", (await Task.WhenAll(roles
.Select(async id =>
var guild = await _cache.GetGuild(channel.GuildId!.Value);
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)
return role;
return new Role { Name = "*(unknown role)*", Position = 0 };
})))
}))
.OrderByDescending(role => role.Position)
.Select(role => role.Name));
eb.Field(new Embed.Field($"Account roles ({roles.Count})", rolesString.Truncate(1024)));
@ -460,19 +427,9 @@ public class EmbedService
var color = system.Color;
if (group != null) color = group.Color;
uint embedColor;
try
{
embedColor = color?.ToDiscordColor() ?? DiscordUtils.Gray;
}
catch (ArgumentException)
{
embedColor = DiscordUtils.Gray;
}
var eb = new EmbedBuilder()
.Title(embedTitle)
.Color(embedColor);
.Color(color?.ToDiscordColor());
var footer =
$"Since {breakdown.RangeStart.FormatZoned(tz)} ({(breakdown.RangeEnd - breakdown.RangeStart).FormatDuration()} ago)";

View file

@ -42,7 +42,7 @@ public class LogChannelService
if (logChannelId == null)
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 system = await _repo.GetSystem(member.System);
@ -63,7 +63,7 @@ public class LogChannelService
return null;
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
var guild = await _repo.GetGuild(guildId);
@ -109,7 +109,7 @@ public class LogChannelService
private async Task<Channel?> FindLogChannel(ulong guildId, ulong channelId)
{
// 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;
if (await _rest.GetChannelOrNull(channelId) is Channel restChannel)

View file

@ -23,6 +23,8 @@ public class LoggerCleanService
private static readonly Regex _basicRegex = new("(\\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 _sapphireRegex = 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 _loggerARegex = new("Message = (\\d{17,19})");
private static readonly Regex _loggerBRegex = new("MessageID:(\\d{17,19})");
@ -62,6 +64,8 @@ public class LoggerCleanService
new LoggerBot("Dyno#8389", 470724017205149701, ExtractDyno), // webhook
new LoggerBot("Dyno#5714", 470723870270160917, ExtractDyno), // webhook
new LoggerBot("Dyno#1961", 347378323418251264, ExtractDyno), // webhook
new LoggerBot("Maki", 563434444321587202, ExtractMaki), // webhook
new LoggerBot("Sapphire", 678344927997853742, ExtractSapphire), // webhook
new LoggerBot("Auttaja", 242730576195354624, ExtractAuttaja), // webhook
new LoggerBot("GenericBot", 295329346590343168, ExtractGenericBot),
new LoggerBot("blargbot", 134133271750639616, ExtractBlargBot),
@ -101,10 +105,10 @@ public class LoggerCleanService
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 (!(await _cache.PermissionsIn(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 it's from a *bot*, check the bot ID to see if we know it.
@ -239,6 +243,26 @@ public class LoggerCleanService
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") ?? false)) return null;
var match = _makiRegex.Match(embed.Footer.Text ?? "");
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static ulong? ExtractSapphire(Message msg)
{
// Embed, Message title field: "Message deleted", description contains "**Message ID:** [[id]]"
// Example: "**Message ID:** [1297549791927996598]"
var embed = msg.Embeds?.FirstOrDefault();
if (embed == null) return null;
if (!(embed.Title?.StartsWith("Message deleted") ?? false)) return null;
var match = _sapphireRegex.Match(embed.Description);
return match.Success ? ulong.Parse(match.Groups[1].Value) : null;
}
private static FuzzyExtractResult? ExtractCircle(Message msg)
{
// Like Auttaja, Circle has both embed and compact modes, but the regex works for both.

View file

@ -54,33 +54,6 @@ public class PeriodicStatCollector
var stopwatch = new Stopwatch();
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
var process = Process.GetCurrentProcess();
_metrics.Measure.Gauge.SetValue(CoreMetrics.ProcessPhysicalMemory, process.WorkingSet64);

View file

@ -94,9 +94,9 @@ public class WebhookCacheService
// We don't have one, so we gotta create a new one
// but first, make sure we haven't hit the webhook cap yet...
if (webhooks.Length >= 10)
if (webhooks.Length >= 15)
throw new PKError(
"This channel has the maximum amount of possible webhooks (10) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying.");
"This channel has the maximum amount of possible webhooks (15) already created. A server admin must delete one or more webhooks so PluralKit can create one for proxying.");
return await DoCreateWebhook(channelId);
}

View file

@ -4,6 +4,8 @@ using App.Metrics;
using Humanizer;
using NodaTime.Text;
using Myriad.Cache;
using Myriad.Extensions;
using Myriad.Rest;
@ -17,7 +19,6 @@ using Newtonsoft.Json.Linq;
using Serilog;
using PluralKit.Core;
using Myriad.Utils;
namespace PluralKit.Bot;
@ -35,6 +36,7 @@ public record ProxyRequest
public ulong GuildId { get; init; }
public ulong ChannelId { get; init; }
public ulong? ThreadId { get; init; }
public ulong MessageId { get; init; }
public string Name { get; init; }
public string? AvatarUrl { get; init; }
public string? Content { get; init; }
@ -45,6 +47,7 @@ public record ProxyRequest
public bool AllowEveryone { get; init; }
public Message.MessageFlags? Flags { get; init; }
public bool Tts { get; init; }
public Message.MessagePoll? Poll { get; init; }
}
public class WebhookExecutorService
@ -83,7 +86,8 @@ public class WebhookExecutorService
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, bool clearAttachments = false)
{
var allowedMentions = newContent.ParseMentions() with
{
@ -92,7 +96,7 @@ public class WebhookExecutorService
};
ulong? threadId = null;
var channel = await _cache.GetOrFetchChannel(_rest, channelId);
var channel = await _cache.GetOrFetchChannel(_rest, guildId, channelId);
if (channel.IsThread())
{
threadId = channelId;
@ -104,7 +108,10 @@ public class WebhookExecutorService
{
Content = newContent,
AllowedMentions = allowedMentions,
Embeds = (clearEmbeds == true ? Optional<Embed[]>.Some(new Embed[] { }) : Optional<Embed[]>.None()),
Embeds = (clearEmbeds ? Optional<Embed[]>.Some(new Embed[] { }) : Optional<Embed[]>.None()),
Attachments = (clearAttachments
? Optional<Message.Attachment[]>.Some(new Message.Attachment[] { })
: Optional<Message.Attachment[]>.None())
};
return await _rest.EditWebhookMessage(webhook.Id, webhook.Token, messageId, editReq, threadId);
@ -154,6 +161,26 @@ public class WebhookExecutorService
}).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;
using (_metrics.Measure.Timer.Time(BotMetrics.WebhookResponseTime))
{

View file

@ -63,11 +63,23 @@ public static class AvatarUtils
// This lets us add resizing parameters to "borrow" their media proxy server to downsize the image
// which in turn makes it more likely to be underneath the size limit!
private static readonly Regex DiscordCdnUrl =
new(@"^https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d{17,19})/(\d{17,19})/([^/\\&\?]+)\.(png|jpg|jpeg|webp)(\?.*)?$");
new(@"^https?://(?:cdn\.discordapp\.com|media\.discordapp\.net)/attachments/(\d{17,19})/(\d{17,19})/([^/\\&\?]+)\.(png|jpe?g|gif|webp)(?:\?(?<query>.*))?$", RegexOptions.IgnoreCase);
private static readonly string DiscordMediaUrlReplacement =
"https://media.discordapp.net/attachments/$1/$2/$3.$4?width=256&height=256";
public static string? TryRewriteCdnUrl(string? url) =>
url == null ? null : DiscordCdnUrl.Replace(url, DiscordMediaUrlReplacement);
public static string? TryRewriteCdnUrl(string? url)
{
if (url == null)
return null;
var match = DiscordCdnUrl.Match(url);
var newUrl = DiscordCdnUrl.Replace(url, DiscordMediaUrlReplacement);
if (match.Groups["query"].Success)
newUrl += "&" + match.Groups["query"].Value;
return newUrl;
}
public static bool IsDiscordCdnUrl(string? url) => url != null && DiscordCdnUrl.Match(url).Success;
}

View file

@ -55,7 +55,7 @@ public static class ContextUtils
.WaitFor(ReactionPredicate, timeout);
}
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply)
public static async Task<bool> ConfirmWithReply(this Context ctx, string expectedReply, bool treatAsHid = false)
{
bool Predicate(MessageCreateEvent e) =>
e.Author.Id == ctx.Author.Id && e.ChannelId == ctx.Channel.Id;
@ -63,7 +63,11 @@ public static class ContextUtils
var msg = await ctx.Services.Resolve<HandlerQueue<MessageCreateEvent>>()
.WaitFor(Predicate, Duration.FromMinutes(1));
return string.Equals(msg.Content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
var content = msg.Content;
if (treatAsHid)
content = content.ToLower().Replace("-", null);
return string.Equals(content, expectedReply, StringComparison.InvariantCultureIgnoreCase);
}
public static async Task Paginate<T>(this Context ctx, IAsyncEnumerable<T> items, int totalCount,

View file

@ -20,7 +20,6 @@ public static class DiscordUtils
public const uint Blue = 0x1f99d8;
public const uint Green = 0x00cc78;
public const uint Red = 0xef4b3d;
public const uint Gray = 0x979c9f;
private static readonly Regex USER_MENTION = new("<@!?(\\d{17,19})>");
private static readonly Regex ROLE_MENTION = new("<@&(\\d{17,19})>");
@ -35,7 +34,7 @@ public static class DiscordUtils
private static readonly Regex UNBROKEN_LINK_REGEX = new("<?(https?:\\/\\/[^\\s<]+[^<.,:;\"')\\]\\s])>?");
public static string NameAndMention(this User user) =>
$"{user.Username}{(user.Discriminator == "0" ? "" : $"#{user.Discriminator}")} ({user.Mention()})";
$"{user.Username.EscapeMarkdown()}{(user.Discriminator == "0" ? "" : $"#{user.Discriminator}")} ({user.Mention()})";
public static Instant SnowflakeToInstant(ulong snowflake) =>
Instant.FromUtc(2015, 1, 1, 0, 0, 0) + Duration.FromMilliseconds(snowflake >> 22);

View file

@ -16,10 +16,11 @@ public class InteractionContext
private readonly ILifetimeScope _provider;
private readonly IMetrics _metrics;
public InteractionContext(ILifetimeScope provider, InteractionCreateEvent evt, PKSystem system)
public InteractionContext(ILifetimeScope provider, InteractionCreateEvent evt, PKSystem system, SystemConfig config)
{
Event = evt;
System = system;
Config = config;
Cache = provider.Resolve<IDiscordCache>();
Rest = provider.Resolve<DiscordApiClient>();
Repository = provider.Resolve<ModelRepository>();
@ -31,6 +32,7 @@ public class InteractionContext
internal readonly DiscordApiClient Rest;
internal readonly ModelRepository Repository;
public readonly PKSystem System;
public readonly SystemConfig Config;
public InteractionCreateEvent Event { get; }
@ -74,12 +76,22 @@ public class InteractionContext
});
}
public async Task Defer()
{
await Respond(InteractionResponse.ResponseType.DeferredChannelMessageWithSource,
new InteractionApplicationCommandCallbackData
{
Components = Array.Empty<MessageComponent>(),
Flags = Message.MessageFlags.Ephemeral,
});
}
public async Task Ignore()
{
await Respond(InteractionResponse.ResponseType.DeferredUpdateMessage,
new InteractionApplicationCommandCallbackData
{
Components = Event.Message.Components
Components = Event.Message?.Components ?? Array.Empty<MessageComponent>()
});
}

View file

@ -49,17 +49,17 @@ public static class MiscUtils
if (e is WebhookExecutionErrorOnDiscordsEnd) return false;
// 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.
if (e is TaskCanceledException) return false;
// if (e is TaskCanceledException) return false;
// 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;
// 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.
return true;
@ -89,12 +89,15 @@ public static class MiscUtils
if (e is NpgsqlException tpe && tpe.InnerException is TimeoutException)
return false;
// Ignore thread pool exhaustion errors
if (e is NpgsqlException npe && npe.Message.Contains("The connection pool has been exhausted"))
return false;
// ignore "Exception while reading from stream"
if (e is NpgsqlException npe2 && npe2.Message.Contains("Exception while reading from stream"))
if (e is NpgsqlException npe &&
(
// Ignore thread pool exhaustion errors
npe.Message.Contains("The connection pool has been exhausted")
// ignore "Exception while reading from stream"
|| npe.Message.Contains("Exception while reading from stream")
// ignore "Exception while connecting"
|| npe.Message.Contains("Exception while connecting")
))
return false;
return true;

View file

@ -24,8 +24,20 @@ public static class ModelUtils
public static string DisplayName(this PKMember member) =>
member.DisplayName ?? member.Name;
public static string Reference(this PKMember member, Context ctx) => EntityReference(member.Hid, member.NameFor(ctx));
public static string Reference(this PKGroup group, Context ctx) => EntityReference(group.Hid, group.NameFor(ctx));
public static string Reference(this PKMember member, Context ctx) => EntityReference(member.DisplayHid(ctx.Config), member.NameFor(ctx));
public static string Reference(this PKGroup group, Context ctx) => EntityReference(group.DisplayHid(ctx.Config), group.NameFor(ctx));
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, bool shouldPad = true) => HidTransform(group.Hid, cfg, isList, shouldPad);
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, bool shouldPad = true) =>
HidUtils.HidTransform(
hid,
cfg != null && cfg.HidDisplaySplit,
cfg != null && cfg.HidDisplayCaps,
isList && shouldPad ? (cfg?.HidListPadding ?? SystemConfig.HidPadFormat.None) : SystemConfig.HidPadFormat.None // padding only on lists
);
private static string EntityReference(string hid, string name)
{

View file

@ -38,9 +38,11 @@ public class SerilogGatewayEnricherFactory
{
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.PermissionsIn(channel.Value);
var botPermissions = await _cache.BotPermissionsIn(guildIdForCache, channel.Value);
props.Add(new LogEventProperty("BotPermissions", new ScalarValue(botPermissions)));
}
}
@ -52,7 +54,7 @@ public class SerilogGatewayEnricherFactory
props.Add(new LogEventProperty("UserId", new ScalarValue(user.Value)));
if (evt is MessageCreateEvent mce)
props.Add(new LogEventProperty("UserPermissions", new ScalarValue(await _cache.PermissionsFor(mce))));
props.Add(new LogEventProperty("UserPermissions", new ScalarValue(await _cache.PermissionsForMCE(mce))));
return new Inner(props);
}

View file

@ -36,15 +36,15 @@
},
"Sentry": {
"type": "Direct",
"requested": "[3.11.1, )",
"resolved": "3.11.1",
"contentHash": "T/NLfs6MMkUSYsPEDajB9ad0124T18I0uUod5MNOev3iwjvcnIEQBrStEX2olbIxzqfvGXzQ/QFqTfA2ElLPlA=="
"requested": "[4.12.1, )",
"resolved": "4.12.1",
"contentHash": "OLf7885OKHWLaTLTyw884mwOT4XKCWj2Hz5Wuz/TJemJqXwCIdIljkJBIoeHviRUPvtB7ulDgeYXf/Z7ScToSA=="
},
"SixLabors.ImageSharp": {
"type": "Direct",
"requested": "[3.0.1, )",
"resolved": "3.0.1",
"contentHash": "o0v/J6SJwp3RFrzR29beGx0cK7xcMRgOyIuw8ZNLQyNnBhiyL/vIQKn7cfycthcWUPG3XezUjFwBWzkcUUDFbg=="
"requested": "[3.1.5, )",
"resolved": "3.1.5",
"contentHash": "lNtlq7dSI/QEbYey+A0xn48z5w4XHSffF8222cC4F4YwTXfEImuiBavQcWjr49LThT/pRmtWJRcqA/PlL+eJ6g=="
},
"App.Metrics": {
"type": "Transitive",
@ -337,8 +337,8 @@
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "3.1.10",
"contentHash": "bKHbgzbGsPZbEaExRaJqBz3WQ1GfhMttM23e1nivLJ8HbA3Ad526mW2G2K350q3Dc3HG83I5W8uSZWG4Rv4IpA=="
"resolved": "6.0.0",
"contentHash": "/HggWBbTwy8TgebGSX5DBZ24ndhzi93sHUBDvP1IxbZD7FDokYzdAr6+vbWGjw2XAfR2EJ1sfKUotpjHnFWPxA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
@ -356,8 +356,8 @@
},
"Microsoft.NETCore.Platforms": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
"resolved": "1.1.0",
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
},
"Microsoft.NETCore.Targets": {
"type": "Transitive",
@ -374,23 +374,6 @@
"System.Runtime": "4.3.0"
}
},
"Microsoft.Win32.Registry": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==",
"dependencies": {
"System.Security.AccessControl": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"Microsoft.Win32.SystemEvents": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "Bh6blKG8VAKvXiLe2L+sEsn62nc1Ij34MrNxepD2OCrS5cpCwQa9MeLyhVQPQ/R4Wlzwuy6wMK8hLb11QPDRsQ==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0"
}
},
"NETStandard.Library": {
"type": "Transitive",
"resolved": "1.6.1",
@ -466,8 +449,8 @@
},
"Npgsql": {
"type": "Transitive",
"resolved": "4.1.5",
"contentHash": "juDlNse+SKfXRP0VSgpJkpdCcaVLZt8m37EHdRX+8hw+GG69Eat1Y0MdEfl+oetdOnf9E133GjIDEjg9AF6HSQ==",
"resolved": "4.1.13",
"contentHash": "p79cObfuRgS8KD5sFmQUqVlINEkJm39bCrzRclicZE1942mKcbLlc0NdoVKhBeZPv//prK/sVTUmRVxdnoPCoA==",
"dependencies": {
"System.Runtime.CompilerServices.Unsafe": "4.6.0"
}
@ -483,10 +466,10 @@
},
"Pipelines.Sockets.Unofficial": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "7hzHplEIVOGBl5zOQZGX/DiJDHjq+RVRVrYgDiqXb6RriqWAdacXxp+XO9WSrATCEXyNOUOQg9aqQArsjase/A==",
"resolved": "2.2.8",
"contentHash": "zG2FApP5zxSx6OcdJQLbZDk2AVlN2BNQD6MorwIfV6gVj0RRxWPEp2LXAxqDGZqeNV1Zp0BNPcNaey/GXmTdvQ==",
"dependencies": {
"System.IO.Pipelines": "5.0.0"
"System.IO.Pipelines": "5.0.1"
}
},
"Polly": {
@ -726,11 +709,11 @@
},
"StackExchange.Redis": {
"type": "Transitive",
"resolved": "2.2.88",
"contentHash": "JJi1jcO3/ZiamBhlsC/TR8aZmYf+nqpGzMi0HRRCy5wJkUPmMnRp0kBA6V84uhU8b531FHSdTDaFCAyCUJomjA==",
"resolved": "2.8.16",
"contentHash": "WaoulkOqOC9jHepca3JZKFTqndCWab5uYS7qCzmiQDlrTkFaDN7eLSlEfHycBxipRnQY9ppZM7QSsWAwUEGblw==",
"dependencies": {
"Pipelines.Sockets.Unofficial": "2.2.0",
"System.Diagnostics.PerformanceCounter": "5.0.0"
"Microsoft.Extensions.Logging.Abstractions": "6.0.0",
"Pipelines.Sockets.Unofficial": "2.2.8"
}
},
"System.AppContext": {
@ -773,15 +756,6 @@
"System.Threading.Tasks": "4.3.0"
}
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "aM7cbfEfVNlEEOj3DsZP+2g9NRwbkyiAv2isQEzw7pnkDg9ekCU2m1cdJLM02Uq691OaCS91tooaxcEn8d0q5w==",
"dependencies": {
"System.Security.Cryptography.ProtectedData": "5.0.0",
"System.Security.Permissions": "5.0.0"
}
},
"System.Console": {
"type": "Transitive",
"resolved": "4.3.0",
@ -809,17 +783,6 @@
"resolved": "4.7.1",
"contentHash": "j81Lovt90PDAq8kLpaJfJKV/rWdWuEk6jfV+MBkee33vzYLEUsy4gXK8laa9V2nZlLM9VM9yA/OOQxxPEJKAMw=="
},
"System.Diagnostics.PerformanceCounter": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "kcQWWtGVC3MWMNXdMDWfrmIlFZZ2OdoeT6pSNVRtk9+Sa7jwdPiMlNwb0ZQcS7NRlT92pCfmjRtkSWUW3RAKwg==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0",
"Microsoft.Win32.Registry": "5.0.0",
"System.Configuration.ConfigurationManager": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"System.Diagnostics.Tools": {
"type": "Transitive",
"resolved": "4.3.0",
@ -840,14 +803,6 @@
"System.Runtime": "4.3.0"
}
},
"System.Drawing.Common": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "SztFwAnpfKC8+sEKXAFxCBWhKQaEd97EiOL7oZJZP56zbqnLpmxACWA8aGseaUExciuEAUuR9dY8f7HkTRAdnw==",
"dependencies": {
"Microsoft.Win32.SystemEvents": "5.0.0"
}
},
"System.Globalization": {
"type": "Transitive",
"resolved": "4.3.0",
@ -965,8 +920,8 @@
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "irMYm3vhVgRsYvHTU5b2gsT2CwT/SMM6LZFzuJjpIvT5Z4CshxNsaoBC1X/LltwuR3Opp8d6jOS/60WwOb7Q2Q=="
"resolved": "5.0.1",
"contentHash": "qEePWsaq9LoEEIqhbGe6D5J8c9IqQOUuTzzV6wn1POlfdLkJliZY3OlB0j0f17uMWlqZYjH7txj+2YbyrIA8Yg=="
},
"System.Linq": {
"type": "Transitive",
@ -1229,15 +1184,6 @@
"System.Runtime.Extensions": "4.3.0"
}
},
"System.Security.AccessControl": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==",
"dependencies": {
"Microsoft.NETCore.Platforms": "5.0.0",
"System.Security.Principal.Windows": "5.0.0"
}
},
"System.Security.Cryptography.Algorithms": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1350,11 +1296,6 @@
"System.Threading.Tasks": "4.3.0"
}
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "HGxMSAFAPLNoxBvSfW08vHde0F9uh7BjASwu6JF9JnXuEPhCY3YUqURn0+bQV/4UWeaqymmrHWV+Aw9riQCtCA=="
},
"System.Security.Cryptography.X509Certificates": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1387,20 +1328,6 @@
"runtime.native.System.Security.Cryptography.OpenSsl": "4.3.0"
}
},
"System.Security.Permissions": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "uE8juAhEkp7KDBCdjDIE3H9R1HJuEHqeqX8nLX9gmYKWwsqk3T5qZlPx8qle5DPKimC/Fy3AFTdV7HamgCh9qQ==",
"dependencies": {
"System.Security.AccessControl": "5.0.0",
"System.Windows.Extensions": "5.0.0"
}
},
"System.Security.Principal.Windows": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA=="
},
"System.Text.Encoding": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1474,14 +1401,6 @@
"System.Runtime": "4.3.0"
}
},
"System.Windows.Extensions": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "c1ho9WU9ZxMZawML+ssPKZfdnrg/OjR3pe0m9v8230z3acqphwvPJqzAkH54xRYm5ntZHGG1EPP3sux9H3qSPg==",
"dependencies": {
"System.Drawing.Common": "5.0.0"
}
},
"System.Xml.ReaderWriter": {
"type": "Transitive",
"resolved": "4.3.0",
@ -1556,7 +1475,7 @@
"Newtonsoft.Json": "[13.0.1, )",
"NodaTime": "[3.0.3, )",
"NodaTime.Serialization.JsonNet": "[3.0.0, )",
"Npgsql": "[4.1.5, )",
"Npgsql": "[4.1.13, )",
"Npgsql.NodaTime": "[4.1.5, )",
"Serilog": "[2.12.0, )",
"Serilog.Extensions.Logging": "[3.0.1, )",
@ -1569,7 +1488,7 @@
"Serilog.Sinks.Seq": "[5.2.2, )",
"SqlKata": "[2.3.7, )",
"SqlKata.Execution": "[2.3.7, )",
"StackExchange.Redis": "[2.2.88, )",
"StackExchange.Redis": "[2.8.16, )",
"System.Interactive.Async": "[5.0.0, )",
"ipnetwork2": "[2.5.381, )"
}

View file

@ -8,13 +8,14 @@ public class CoreConfig
public string? MessagesDatabase { get; set; }
public string? DatabasePassword { get; set; }
public string RedisAddr { get; set; }
public bool UseRedisMetrics { get; set; } = false;
public string SentryUrl { get; set; }
public string InfluxUrl { get; set; }
public string InfluxDb { get; set; }
public string LogDir { get; set; }
public string? ElasticUrl { get; set; }
public string? SeqLogUrl { get; set; }
public string? DispatchProxyUrl { get; set; }
public string? DispatchProxyToken { get; set; }
public LogEventLevel ConsoleLogLevel { get; set; } = LogEventLevel.Debug;
public LogEventLevel ElasticLogLevel { get; set; } = LogEventLevel.Information;

View file

@ -83,10 +83,12 @@ internal partial class Database: IDatabase
SqlMapper.AddTypeHandler(new NumericIdHandler<MemberId, int>(i => new MemberId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<SwitchId, int>(i => new SwitchId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<GroupId, int>(i => new GroupId(i)));
SqlMapper.AddTypeHandler(new NumericIdHandler<AbuseLogId, int>(i => new AbuseLogId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<SystemId, int>(i => new SystemId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<MemberId, int>(i => new MemberId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<SwitchId, int>(i => new SwitchId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<GroupId, int>(i => new GroupId(i)));
SqlMapper.AddTypeHandler(new NumericIdArrayHandler<AbuseLogId, int>(i => new AbuseLogId(i)));
// Register our custom types to Npgsql
// Without these it'll still *work* but break at the first launch + probably cause other small issues

View file

@ -18,6 +18,7 @@ public class MessageContext
public bool InBlacklist { get; }
public bool InLogBlacklist { get; }
public bool LogCleanupEnabled { get; }
public bool RequireSystemTag { get; }
public bool ProxyEnabled { get; }
public SwitchId? LastSwitch { get; }
public MemberId[] LastSwitchMembers { get; } = new MemberId[0];
@ -25,10 +26,13 @@ public class MessageContext
public string? SystemTag { get; }
public string? SystemGuildTag { get; }
public bool TagEnabled { get; }
public string? NameFormat { get; }
public string? SystemAvatar { get; }
public string? SystemGuildAvatar { get; }
public bool AllowAutoproxy { get; }
public int? LatchTimeout { get; }
public bool CaseSensitiveProxyTags { get; }
public bool ProxyErrorMessageEnabled { get; }
public bool ProxySwitch { get; }
public bool DenyBotUsage { get; }
}

View file

@ -0,0 +1,18 @@
#nullable enable
namespace PluralKit.Core;
public static class MessageContextExt
{
public static bool HasProxyableTag(this MessageContext ctx)
{
var tag = ctx.SystemGuildTag ?? ctx.SystemTag;
if (!ctx.TagEnabled || tag == null)
return false;
var format = ctx.NameFormat ?? ProxyMember.DefaultFormat;
if (!format.Contains("{tag}"))
return false;
return true;
}
}

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