Compare commits

...

24 Commits

Author SHA1 Message Date
Kwoth
757c9b564d Updated changelog. Version upped to 5.1.16 2024-10-28 08:21:27 +00:00
Kwoth
07cef3eb5e Added .nc and related commands.
You can set pixel colors (and text) on a 500x350 canvas, pepega version of r/place
You use currency to set pixels.
see whole canvas: .nc
set pixel: .ncsp <pos> <color> <text?>
get pixel: .ncp <pos>
zoom: .ncz <pos> or .ncz x y
2024-10-28 08:17:23 +00:00
Kwoth
85c525e19b api: added command feed and shard update feed 2024-10-23 21:29:40 +00:00
Kwoth
477581f616 docs: Updated CHANGELOG, upped version to 5.1.15 2024-10-21 14:29:08 +00:00
Kwoth
ff30105816 fix: Fixed several features which weren't getting loaded on startup 2024-10-21 14:15:42 +00:00
Kwoth
49f04a594b fix: fixed expire settings not returned on api 2024-10-21 04:36:54 +00:00
Kwoth
716090a132 fix: fix migration incase there is invalid data 2024-10-21 04:08:33 +00:00
Kwoth
c835514c7b dev: split warn punishments into a separate table
api: Added warn endpoints
fix: Reminders should now be able to ping everyone if the user who created the reminder has that permission
2024-10-21 03:14:46 +00:00
Kwoth
b136e7ff0e fix: author name will be counted as content in embeds. Embeds will now be valid if they only have an author specified 2024-10-19 19:21:00 +00:00
Kwoth
9dd2997b0f dev: added botonguild api endpoint 2024-10-16 15:15:41 +00:00
Kwoth
fde5309ea4 dev: added quote api 2024-10-16 01:52:56 +00:00
Kwoth
a8e4173e9b fix: grpc api fix 2024-10-13 21:37:58 +00:00
Kwoth
74b4c4b64d change: cleanup command will now also clear greetsettings and autpublish channels
dev: Cleaned up some comments, changed grpc api
2024-10-10 16:01:49 +00:00
Kwoth
6cc5a160a2 fix: .greetmsg (and related commands) and .greettest (and other greet test commands) will now show the correct response string when the toggle is disabled 2024-10-07 20:02:43 +00:00
Kwoth
ca8e022db6 fix: .waifulb will no longer show #0000 discriminators, for real this time 2024-10-07 12:56:28 +00:00
Kwoth
cd8c14c607 change: Leaderboards will show 10 users per page 2024-10-07 09:07:32 +00:00
Kwoth
1340533c21 fix: fix cleanup migration 2024-10-06 14:17:26 +00:00
Kwoth
14d86b9042 fix: grpc api fix 2024-10-04 03:31:44 +00:00
Kwoth
3a504a954f add: Added options '-c' option for '.xpglb' which will show global xp leaderboard only with this server's users 2024-10-04 03:24:18 +00:00
Kwoth
822ce0b8de fix: Alias collision fixed, .qse will be quotesearch, .qs will remain queuesearch (music)
fix: Improved guild config cleanup migration by removing invalid Permissiosnv2 entries (thx Leon)
2024-10-04 02:00:46 +00:00
Kwoth
40490a4656 docs: Version upped to 5.1.14, updated CHANGELOG.md 2024-10-03 13:01:11 +00:00
Kwoth
0cf7909fef change: improved .xplb -c, it will now correctly work only on users who are still in the server, isntead of only top 1k
fix: Fixed medusa error on bot startup
2024-10-03 12:58:45 +00:00
Kwoth
de8d4b7d9e docs: Version upped to 5.1.13, updated CHANGELOG.md
fix: Fixed seq comment in creds
2024-10-03 12:05:16 +00:00
Kwoth
0123892038 fix: Grpc api will no longer start unless it's enabled in creds 2024-10-03 11:59:47 +00:00
69 changed files with 16067 additions and 609 deletions

View File

@@ -2,6 +2,60 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [5.1.16] - 28.10.2024
## Added
- Added .ncanvas and related commands.
- You can set pixel colors (and text) on a 500x350 canvas, pepega version of r/place
- You use currency to set pixels.
- Commands:
- see the entire canvas: `.nc`
- zoom: `.ncz <pos>` or `.ncz x y`
- set pixel: `.ncsp <pos> <color> <text?>`
- get pixel: `.ncp <pos>`
- Owners can use .ncsetimg to set a starting image, use `.h .setimg` for instructions
- Owners can reset the whole canvas via `.ncreset`
## [5.1.15] - 21.10.2024
## Added
- Added -c option for `.xpglb`
-
## Change
- Leaderboards will now show 10 users per page
- A lot of internal changes and improvements
## Fixed
- Fixed a big issue which caused several features to not get loaded on bot restart
- Alias collision fix `.qse` is now quotesearch, `.qs` will stay `.queuesearch`
- Fixed some migrations which would prevent users from updating from ancient versions
- Waifulb will no longer show #0000 discrims
- More `.greet` command fixes
- Author name will now be counted as content in embeds. Embeds can now only have author fields and still be valid
- Grpc api fixes, and additions
## [5.1.14] - 03.10.2024
## Changed
- Improved `.xplb -c`, it will now correctly only show users who are still in the server with no count limit
## Fixed
- Fixed medusa load error on startup
## [5.1.13] - 03.10.2024
### Fixed
- Grpc api server will no longer start unless enabled in creds
- Seq comment in creds fixed
## [5.1.12] - 03.10.2024
### Added

View File

@@ -12,13 +12,14 @@ namespace NadekoBot.Generators
{
public readonly record struct MethodPermData
{
public readonly string Name;
public readonly string Value;
public readonly ImmutableArray<(string Name, string Value)> MethodPerms;
public readonly ImmutableArray<string> NoAuthRequired;
public MethodPermData(string name, string value)
public MethodPermData(ImmutableArray<(string Name, string Value)> methodPerms,
ImmutableArray<string> noAuthRequired)
{
Name = name;
Value = value;
MethodPerms = methodPerms;
NoAuthRequired = noAuthRequired;
}
}
@@ -26,7 +27,7 @@ namespace NadekoBot.Generators
[Generator]
public class GrpcApiPermGenerator : IIncrementalGenerator
{
public const string Attribute =
public const string GRPC_API_PERM_ATTRIBUTE =
"""
namespace NadekoBot.GrpcApi;
@@ -37,13 +38,26 @@ namespace NadekoBot.Generators
public GrpcApiPermAttribute(GuildPerm value) => Value = value;
}
""";
public const string GRPC_NO_AUTH_REQUIRED_ATTRIBUTE =
"""
namespace NadekoBot.GrpcApi;
[System.AttributeUsage(System.AttributeTargets.Method)]
public class GrpcNoAuthRequiredAttribute : System.Attribute
{
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx => ctx.AddSource("GrpcApiPermAttribute.cs",
SourceText.From(Attribute, Encoding.UTF8)));
SourceText.From(GRPC_API_PERM_ATTRIBUTE, Encoding.UTF8)));
context.RegisterPostInitializationOutput(ctx => ctx.AddSource("GrpcNoAuthRequiredAttribute.cs",
SourceText.From(GRPC_NO_AUTH_REQUIRED_ATTRIBUTE, Encoding.UTF8)));
var enumsToGenerate = context.SyntaxProvider
var perms = context.SyntaxProvider
.ForAttributeWithMetadataName(
"NadekoBot.GrpcApi.GrpcApiPermAttribute",
predicate: static (s, _) => s is MethodDeclarationSyntax,
@@ -52,11 +66,24 @@ namespace NadekoBot.Generators
.Select(static (x, _) => x!.Value)
.Collect();
context.RegisterSourceOutput(enumsToGenerate,
var all = context.SyntaxProvider
.ForAttributeWithMetadataName(
"NadekoBot.GrpcApi.GrpcNoAuthRequiredAttribute",
predicate: static (s, _) => s is MethodDeclarationSyntax,
transform: static (ctx, _) => GetNoAuthMethodName(ctx.SemanticModel, ctx.TargetNode))
.Collect()
.Combine(perms)
.Select((x, _) => new MethodPermData(x.Right, x.Left));
context.RegisterSourceOutput(all,
static (spc, source) => Execute(source, spc));
}
private static MethodPermData? GetMethodSemanticTargets(SemanticModel model, SyntaxNode node)
private static string GetNoAuthMethodName(SemanticModel model, SyntaxNode node)
=> ((MethodDeclarationSyntax)node).Identifier.Text;
private static (string Name, string Value)? GetMethodSemanticTargets(SemanticModel model, SyntaxNode node)
{
var method = (MethodDeclarationSyntax)node;
@@ -64,20 +91,14 @@ namespace NadekoBot.Generators
var attr = method.AttributeLists
.SelectMany(x => x.Attributes)
.FirstOrDefault();
// .FirstOrDefault(x => x.Name.ToString() == "GrpcApiPermAttribute");
if (attr is null)
return null;
// if (model.GetSymbolInfo(attr).Symbol is not IMethodSymbol attrSymbol)
// return null;
return new MethodPermData(name, attr.ArgumentList?.Arguments[0].ToString() ?? "__missing_perm__");
// return new MethodPermData(name, attrSymbol.Parameters[0].ContainingType.ToDisplayString() + "." + attrSymbol.Parameters[0].Name);
return (name, attr.ArgumentList?.Arguments[0].ToString() ?? "__missing_perm__");
}
private static void Execute(ImmutableArray<MethodPermData> fields, SourceProductionContext ctx)
private static void Execute(MethodPermData data, SourceProductionContext ctx)
{
using (var stringWriter = new StringWriter())
using (var sw = new IndentedTextWriter(stringWriter))
@@ -87,16 +108,17 @@ namespace NadekoBot.Generators
sw.WriteLine("namespace NadekoBot.GrpcApi;");
sw.WriteLine();
sw.WriteLine("public partial class PermsInterceptor");
sw.WriteLine("public partial class GrpcApiPermsInterceptor");
sw.WriteLine("{");
sw.Indent++;
sw.WriteLine("public static FrozenDictionary<string, GuildPerm> perms = new Dictionary<string, GuildPerm>()");
sw.WriteLine(
"private static FrozenDictionary<string, GuildPerm> _perms = new Dictionary<string, GuildPerm>()");
sw.WriteLine("{");
sw.Indent++;
foreach (var field in fields)
foreach (var field in data.MethodPerms)
{
sw.WriteLine("{{ \"{0}\", {1} }},", field.Name, field.Value);
}
@@ -104,6 +126,21 @@ namespace NadekoBot.Generators
sw.Indent--;
sw.WriteLine("}.ToFrozenDictionary();");
sw.WriteLine();
sw.WriteLine("private static FrozenSet<string> _noAuthRequired = new HashSet<string>()");
sw.WriteLine("{");
sw.Indent++;
foreach (var noauth in data.NoAuthRequired)
{
sw.WriteLine("{{ \"{0}\" }},", noauth);
}
sw.WriteLine("");
sw.Indent--;
sw.WriteLine("}.ToFrozenSet();");
sw.Indent--;
sw.WriteLine("}");

View File

@@ -0,0 +1,47 @@
syntax = "proto3";
option csharp_namespace = "NadekoBot.GrpcApi";
import "google/protobuf/empty.proto";
package ncanvas;
service GrpcNCanvas {
rpc GetCanvas(google.protobuf.Empty) returns (CanvasReply);
rpc GetPixel(GetPixelRequest) returns (GetPixelReply);
rpc SetPixel(SetPixelRequest) returns (SetPixelReply);
}
message CanvasReply {
repeated uint32 pixels = 1;
int32 width = 2;
int32 height = 3;
}
message GetPixelRequest {
int32 x = 1;
int32 y = 2;
}
message GetPixelReply {
string color = 1;
uint32 packedColor = 2;
int32 positionX = 3;
int32 positionY = 4;
int64 price = 5;
string text = 6;
string position = 7;
}
message SetPixelRequest {
string position = 1;
string color = 2;
string text = 3;
int64 price = 4;
}
message SetPixelReply {
string error = 1;
bool success = 2;
optional GetPixelReply pixel = 3;
}

View File

@@ -1,26 +0,0 @@
syntax = "proto3";
option csharp_namespace = "NadekoBot.GrpcApi";
package econ;
service GrpcEcon {
rpc GetEconomy(EconomyRequest) returns (EconomyReply);
}
message EconomyRequest {
string guildId = 1;
}
message EconomyReply {
uint64 totalOwned = 1;
uint64 byTopOnePercent = 2;
uint64 plantedAmount = 3;
uint64 ownedByTheBot = 4;
uint64 inTheBank = 5;
uint64 totalEconomy = 6;
}
message CurrencyLbRequest {
int32 page = 1;
}

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
option csharp_namespace = "NadekoBot.GrpcApi";
import "google/protobuf/empty.proto";
import "google/protobuf/empty.proto";
package exprs;
@@ -10,6 +10,10 @@ service GrpcExprs {
rpc GetExprs(GetExprsRequest) returns (GetExprsReply);
rpc AddExpr(AddExprRequest) returns (AddExprReply);
rpc DeleteExpr(DeleteExprRequest) returns (google.protobuf.Empty);
rpc GetQuotes(GetQuotesRequest) returns (GetQuotesReply);
rpc AddQuote(AddQuoteRequest) returns (AddQuoteReply);
rpc DeleteQuote(DeleteQuoteRequest) returns (google.protobuf.Empty);
}
message DeleteExprRequest {
@@ -32,7 +36,7 @@ message ExprDto {
string id = 1;
string trigger = 2;
string response = 3;
bool ca = 4;
bool ad = 5;
bool dm = 6;
@@ -47,4 +51,39 @@ message AddExprRequest {
message AddExprReply {
string id = 1;
bool success = 2;
}
message GetQuotesRequest {
uint64 guildId = 1;
string query = 2;
int32 page = 3;
}
message GetQuotesReply {
repeated QuoteDto quotes = 1;
int32 totalCount = 2;
}
message QuoteDto {
string id = 1;
string trigger = 2;
string response = 3;
uint64 authorId = 4;
string authorName = 5;
}
message AddQuoteRequest {
uint64 guildId = 1;
QuoteDto quote = 2;
}
message AddQuoteReply {
string id = 1;
bool success = 2;
}
message DeleteQuoteRequest {
string id = 1;
uint64 guildId = 2;
}

View File

@@ -5,20 +5,13 @@ option csharp_namespace = "NadekoBot.GrpcApi";
package greet;
service GrpcGreet {
rpc GetGreetSettings (GetGreetRequest) returns (GetGreetReply);
rpc GetGreetSettings (GetGreetRequest) returns (GrpcGreetSettings);
rpc UpdateGreet (UpdateGreetRequest) returns (UpdateGreetReply);
rpc TestGreet (TestGreetRequest) returns (TestGreetReply);
}
message GetGreetReply {
GrpcGreetSettings greet = 1;
GrpcGreetSettings greetDm = 2;
GrpcGreetSettings bye = 3;
GrpcGreetSettings boost = 4;
}
message GrpcGreetSettings {
optional uint64 channelId = 1;
string channelId = 1;
string message = 2;
bool isEnabled = 3;
GrpcGreetType type = 4;
@@ -26,6 +19,7 @@ message GrpcGreetSettings {
message GetGreetRequest {
uint64 guildId = 1;
GrpcGreetType type = 2;
}
message UpdateGreetRequest {
@@ -41,7 +35,7 @@ enum GrpcGreetType {
}
message UpdateGreetReply {
bool success = 1;
bool Success = 1;
}
message TestGreetRequest {

View File

@@ -3,43 +3,50 @@ syntax = "proto3";
option csharp_namespace = "NadekoBot.GrpcApi";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
package other;
service GrpcOther {
rpc GetGuilds(google.protobuf.Empty) returns (GetGuildsReply);
rpc BotOnGuild(BotOnGuildRequest) returns (BotOnGuildReply);
rpc GetTextChannels(GetTextChannelsRequest) returns (GetTextChannelsReply);
rpc GetRoles(GetRolesRequest) returns (GetRolesReply);
rpc GetCurrencyLb(GetLbRequest) returns (CurrencyLbReply);
rpc GetXpLb(GetLbRequest) returns (XpLbReply);
rpc GetWaifuLb(GetLbRequest) returns (WaifuLbReply);
rpc GetShardStatuses(google.protobuf.Empty) returns (GetShardStatusesReply);
rpc GetShardStats(google.protobuf.Empty) returns (stream ShardStatsReply);
rpc GetCommandFeed(google.protobuf.Empty) returns (stream CommandFeedEntry);
rpc GetServerInfo(ServerInfoRequest) returns (GetServerInfoReply);
}
message GetGuildsReply {
repeated GuildReply guilds = 1;
message CommandFeedEntry {
string command = 1;
}
message GuildReply {
uint64 id = 1;
string name = 2;
string iconUrl = 3;
message GetRolesRequest {
uint64 guildId = 1;
}
message GetShardStatusesReply {
repeated ShardStatusReply shards = 1;
message GetRolesReply {
repeated RoleReply roles = 1;
}
message ShardStatusReply {
message BotOnGuildRequest {
uint64 guildId = 1;
}
message BotOnGuildReply {
bool success = 1;
}
message ShardStatsReply {
int32 id = 1;
string status = 2;
int32 guildCount = 3;
google.protobuf.Timestamp lastUpdate = 4;
string uptime = 4;
int64 commands = 5;
}
message GetTextChannelsRequest{

View File

@@ -6,11 +6,17 @@ package warn;
service GrpcWarn {
rpc GetWarnSettings (WarnSettingsRequest) returns (WarnSettingsReply);
rpc SetWarnExpiry(SetWarnExpiryRequest) returns (SetWarnExpiryReply);
rpc AddWarnp (AddWarnpRequest) returns (AddWarnpReply);
rpc DeleteWarnp (DeleteWarnpRequest) returns (DeleteWarnpReply);
rpc GetLatestWarnings(GetLatestWarningsRequest) returns (GetLatestWarningsReply);
rpc GetUserWarnings(GetUserWarningsRequest) returns (GetUserWarningsReply);
rpc ClearWarning(ClearWarningRequest) returns (ClearWarningReply);
rpc SetWarnExpiry(SetWarnExpiryRequest) returns (SetWarnExpiryReply);
rpc ForgiveWarning(ForgiveWarningRequest) returns (ForgiveWarningReply);
rpc DeleteWarning(ForgiveWarningRequest) returns (ForgiveWarningReply);
}
message WarnSettingsRequest {
uint64 guildId = 1;
@@ -19,12 +25,14 @@ message WarnSettingsRequest {
message WarnPunishment {
int32 threshold = 1;
string action = 2;
int64 duration = 3;
int32 duration = 3;
string role = 4;
}
message WarnSettingsReply {
repeated WarnPunishment punishments = 1;
int32 expiryDays = 2;
bool deleteOnExpire = 3;
}
message AddWarnpRequest {
@@ -38,7 +46,7 @@ message AddWarnpReply {
message DeleteWarnpRequest {
uint64 guildId = 1;
int32 warnpIndex = 2;
int32 threshold = 2;
}
message DeleteWarnpReply {
@@ -47,37 +55,53 @@ message DeleteWarnpReply {
message GetUserWarningsRequest {
uint64 guildId = 1;
uint64 user_id = 2;
string user = 2;
int32 page = 3;
}
message GetUserWarningsReply {
repeated Warning warnings = 1;
int32 totalCount = 2;
}
message Warning {
int32 id = 1;
string id = 1;
string reason = 2;
int64 timestamp = 3;
int64 expiry_timestamp = 4;
bool cleared = 5;
string clearedBy = 6;
int64 weight = 4;
bool forgiven = 5;
string forgivenBy = 6;
string user = 7;
uint64 userId = 8;
string moderator = 9;
}
message ClearWarningRequest {
message ForgiveWarningRequest {
uint64 guildId = 1;
uint64 userId = 2;
optional int32 warnId = 3;
string warnId = 2;
string modName = 3;
}
message ClearWarningReply {
message ForgiveWarningReply {
bool success = 1;
}
message SetWarnExpiryRequest {
uint64 guildId = 1;
int32 expiryDays = 2;
bool deleteOnExpire = 3;
}
message SetWarnExpiryReply {
bool success = 1;
}
message GetLatestWarningsRequest {
uint64 guildId = 1;
int32 page = 2;
}
message GetLatestWarningsReply {
repeated Warning warnings = 1;
int32 totalCount = 2;
}

View File

@@ -87,14 +87,7 @@ public static class DiscordUserExtensions
> users.AsQueryable().Where(y => y.UserId == id).Select(y => y.TotalXp).FirstOrDefault())
.Count()
+ 1;
public static async Task<IReadOnlyCollection<DiscordUser>> GetUsersXpLeaderboardFor(this DbSet<DiscordUser> users, int page, int perPage)
=> await users.ToLinqToDBTable()
.OrderByDescending(x => x.TotalXp)
.Skip(page * perPage)
.Take(perPage)
.ToArrayAsyncLinqToDB();
public static Task<List<DiscordUser>> GetTopRichest(
this DbSet<DiscordUser> users,
ulong botId,

View File

@@ -57,8 +57,7 @@ public static class GuildConfigExtensions
List<ulong> availableGuilds)
{
var result = await configs
.AsQueryable()
.Include(x => x.CommandCooldowns)
.IncludeEverything()
.Where(x => availableGuilds.Contains(x.GuildId))
.AsNoTracking()
.ToArrayAsync();
@@ -96,7 +95,6 @@ public static class GuildConfigExtensions
GuildId = guildId,
Permissions = Permissionv2.GetDefaultPermlist,
WarningsInitialized = true,
WarnPunishments = DefaultWarnPunishments
});
ctx.SaveChanges();
}
@@ -104,7 +102,6 @@ public static class GuildConfigExtensions
if (!config.WarningsInitialized)
{
config.WarningsInitialized = true;
config.WarnPunishments = DefaultWarnPunishments;
}
return config;

View File

@@ -26,17 +26,6 @@ public static class UserXpExtensions
return usr;
}
public static async Task<IReadOnlyCollection<UserXpStats>> GetUsersFor(
this DbSet<UserXpStats> xps,
ulong guildId,
int page)
=> await xps.ToLinqToDBTable()
.Where(x => x.GuildId == guildId)
.OrderByDescending(x => x.Xp + x.AwardedXp)
.Skip(page * 9)
.Take(9)
.ToArrayAsyncLinqToDB();
public static async Task<List<UserXpStats>> GetTopUserXps(this DbSet<UserXpStats> xps, ulong guildId, int count)
=> await xps.ToLinqToDBTable()
.Where(x => x.GuildId == guildId)

View File

@@ -77,7 +77,6 @@ public class GuildConfig : DbEntity
public HashSet<UnroleTimer> UnroleTimer { get; set; } = new();
public HashSet<VcRoleInfo> VcRoleInfos { get; set; }
public HashSet<CommandAlias> CommandAliases { get; set; } = new();
public List<WarningPunishment> WarnPunishments { get; set; } = new();
public bool WarningsInitialized { get; set; }
public HashSet<SlowmodeIgnoredUser> SlowmodeIgnoredUsers { get; set; }
public HashSet<SlowmodeIgnoredRole> SlowmodeIgnoredRoles { get; set; }

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace NadekoBot.Db.Models;
public class NCPixel
{
[Key]
public int Id { get; set; }
public required int Position { get; init; }
public required long Price { get; init; }
public required ulong OwnerId { get; init; }
public required uint Color { get; init; }
[MaxLength(256)]
public required string Text { get; init; }
}

View File

@@ -3,6 +3,7 @@ namespace NadekoBot.Db.Models;
public class WarningPunishment : DbEntity
{
public ulong GuildId { get; set; }
public int Count { get; set; }
public PunishmentAction Punishment { get; set; }
public int Time { get; set; }

View File

@@ -62,7 +62,6 @@ public abstract class NadekoContext : DbContext
public DbSet<ArchivedTodoListModel> TodosArchive { get; set; }
public DbSet<HoneypotChannel> HoneyPotChannels { get; set; }
// todo add guild colors
// public DbSet<GuildColors> GuildColors { get; set; }
@@ -74,6 +73,16 @@ public abstract class NadekoContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
#region NCanvas
modelBuilder.Entity<NCPixel>()
.HasAlternateKey(x => x.Position);
modelBuilder.Entity<NCPixel>()
.HasIndex(x => x.OwnerId);
#endregion
#region QUOTES
var quoteEntity = modelBuilder.Entity<Quote>();
@@ -195,11 +204,6 @@ public abstract class NadekoContext : DbContext
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GuildConfig>()
.HasMany(x => x.WarnPunishments)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<GuildConfig>()
.HasMany(x => x.SlowmodeIgnoredRoles)
.WithOne()
@@ -277,6 +281,18 @@ public abstract class NadekoContext : DbContext
#endregion
#region WarningPunishments
var warnpunishmentEntity = modelBuilder.Entity<WarningPunishment>(b =>
{
b.HasAlternateKey(x => new
{
x.GuildId,
x.Count
});
});
#endregion
#region Self Assignable Roles
@@ -339,6 +355,7 @@ public abstract class NadekoContext : DbContext
du.HasIndex(x => x.TotalXp);
du.HasIndex(x => x.CurrencyAmount);
du.HasIndex(x => x.UserId);
du.HasIndex(x => x.Username);
});
#endregion
@@ -695,7 +712,7 @@ public abstract class NadekoContext : DbContext
gs
.Property(x => x.IsEnabled)
.HasDefaultValue(false);
gs
.Property(x => x.AutoDeleteTimer)
.HasDefaultValue(0);

View File

@@ -38,6 +38,7 @@ left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;")
DELETE FROM "DelMsgOnCmdChannel" WHERE "GuildConfigId" is NULL;
DELETE FROM "WarningPunishment" WHERE "GuildConfigId" NOT IN (SELECT "Id" from "GuildConfigs");
DELETE FROM "StreamRoleBlacklistedUser" WHERE "StreamRoleSettingsId" is NULL;
DELETE FROM "Permissions" WHERE "GuildConfigId" NOT IN (SELECT "Id" from "GuildConfigs");
""");
}
@@ -65,4 +66,20 @@ left join guildconfigs on reactionrolemessage.guildconfigid = guildconfigs.id;")
WHERE SendBoostMessage = TRUE;
""");
}
public static void AddGuildIdsToWarningPunishment(MigrationBuilder builder)
{
builder.Sql("""
DELETE FROM WarningPunishment WHERE GuildConfigId IS NULL OR GuildConfigId NOT IN (SELECT Id FROM GuildConfigs);
UPDATE WarningPunishment
SET GuildId = (SELECT GuildId FROM GuildConfigs WHERE Id = GuildConfigId);
DELETE FROM WarningPunishment as wp
WHERE (wp.Count, wp.GuildConfigId) in (
SELECT wp2.Count, wp2.GuildConfigId FROM WarningPunishment as wp2
GROUP BY wp2.Count, wp2.GuildConfigId
HAVING COUNT(id) > 1
);
""");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class warnsplit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "guildid",
table: "warningpunishment",
type: "numeric(20,0)",
nullable: false,
defaultValue: 0m);
MigrationQueries.AddGuildIdsToWarningPunishment(migrationBuilder);
migrationBuilder.DropForeignKey(
name: "fk_warningpunishment_guildconfigs_guildconfigid",
table: "warningpunishment");
migrationBuilder.DropIndex(
name: "ix_warningpunishment_guildconfigid",
table: "warningpunishment");
migrationBuilder.DropColumn(
name: "guildconfigid",
table: "warningpunishment");
migrationBuilder.AddUniqueConstraint(
name: "ak_warningpunishment_guildid_count",
table: "warningpunishment",
columns: new[] { "guildid", "count" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "ak_warningpunishment_guildid_count",
table: "warningpunishment");
migrationBuilder.DropColumn(
name: "guildid",
table: "warningpunishment");
migrationBuilder.AddColumn<int>(
name: "guildconfigid",
table: "warningpunishment",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_warningpunishment_guildconfigid",
table: "warningpunishment",
column: "guildconfigid");
migrationBuilder.AddForeignKey(
name: "fk_warningpunishment_guildconfigs_guildconfigid",
table: "warningpunishment",
column: "guildconfigid",
principalTable: "guildconfigs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class ncanvas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ncpixel",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
position = table.Column<int>(type: "integer", nullable: false),
price = table.Column<long>(type: "bigint", nullable: false),
ownerid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
color = table.Column<long>(type: "bigint", nullable: false),
text = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_ncpixel", x => x.id);
table.UniqueConstraint("ak_ncpixel_position", x => x.position);
});
migrationBuilder.CreateIndex(
name: "ix_discorduser_username",
table: "discorduser",
column: "username");
migrationBuilder.CreateIndex(
name: "ix_ncpixel_ownerid",
table: "ncpixel",
column: "ownerid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ncpixel");
migrationBuilder.DropIndex(
name: "ix_discorduser_username",
table: "discorduser");
}
}
}

View File

@@ -799,6 +799,9 @@ namespace NadekoBot.Migrations.PostgreSql
b.HasIndex("UserId")
.HasDatabaseName("ix_discorduser_userid");
b.HasIndex("Username")
.HasDatabaseName("ix_discorduser_username");
b.ToTable("discorduser", (string)null);
});
@@ -1627,6 +1630,49 @@ namespace NadekoBot.Migrations.PostgreSql
b.ToTable("muteduserid", (string)null);
});
modelBuilder.Entity("NadekoBot.Db.Models.NCPixel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("Color")
.HasColumnType("bigint")
.HasColumnName("color");
b.Property<decimal>("OwnerId")
.HasColumnType("numeric(20,0)")
.HasColumnName("ownerid");
b.Property<int>("Position")
.HasColumnType("integer")
.HasColumnName("position");
b.Property<long>("Price")
.HasColumnType("bigint")
.HasColumnName("price");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("text");
b.HasKey("Id")
.HasName("pk_ncpixel");
b.HasAlternateKey("Position")
.HasName("ak_ncpixel_position");
b.HasIndex("OwnerId")
.HasDatabaseName("ix_ncpixel_ownerid");
b.ToTable("ncpixel", (string)null);
});
modelBuilder.Entity("NadekoBot.Db.Models.NadekoExpression", b =>
{
b.Property<int>("Id")
@@ -2938,9 +2984,9 @@ namespace NadekoBot.Migrations.PostgreSql
.HasColumnType("timestamp without time zone")
.HasColumnName("dateadded");
b.Property<int?>("GuildConfigId")
.HasColumnType("integer")
.HasColumnName("guildconfigid");
b.Property<decimal>("GuildId")
.HasColumnType("numeric(20,0)")
.HasColumnName("guildid");
b.Property<int>("Punishment")
.HasColumnType("integer")
@@ -2957,8 +3003,8 @@ namespace NadekoBot.Migrations.PostgreSql
b.HasKey("Id")
.HasName("pk_warningpunishment");
b.HasIndex("GuildConfigId")
.HasDatabaseName("ix_warningpunishment_guildconfigid");
b.HasAlternateKey("GuildId", "Count")
.HasName("ak_warningpunishment_guildid_count");
b.ToTable("warningpunishment", (string)null);
});
@@ -3616,15 +3662,6 @@ namespace NadekoBot.Migrations.PostgreSql
b.Navigation("User");
});
modelBuilder.Entity("NadekoBot.Db.Models.WarningPunishment", b =>
{
b.HasOne("NadekoBot.Db.Models.GuildConfig", null)
.WithMany("WarnPunishments")
.HasForeignKey("GuildConfigId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_warningpunishment_guildconfigs_guildconfigid");
});
modelBuilder.Entity("NadekoBot.Db.Models.XpCurrencyReward", b =>
{
b.HasOne("NadekoBot.Db.Models.XpSettings", "XpSettings")
@@ -3740,8 +3777,6 @@ namespace NadekoBot.Migrations.PostgreSql
b.Navigation("VcRoleInfos");
b.Navigation("WarnPunishments");
b.Navigation("XpSettings");
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
/// <inheritdoc />
public partial class warnsplit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<ulong>(
name: "GuildId",
table: "WarningPunishment",
type: "INTEGER",
nullable: false,
defaultValue: 0ul);
MigrationQueries.AddGuildIdsToWarningPunishment(migrationBuilder);
migrationBuilder.DropForeignKey(
name: "FK_WarningPunishment_GuildConfigs_GuildConfigId",
table: "WarningPunishment");
migrationBuilder.DropIndex(
name: "IX_WarningPunishment_GuildConfigId",
table: "WarningPunishment");
migrationBuilder.DropColumn(
name: "GuildConfigId",
table: "WarningPunishment");
migrationBuilder.AddUniqueConstraint(
name: "AK_WarningPunishment_GuildId_Count",
table: "WarningPunishment",
columns: new[] { "GuildId", "Count" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropUniqueConstraint(
name: "AK_WarningPunishment_GuildId_Count",
table: "WarningPunishment");
migrationBuilder.DropColumn(
name: "GuildId",
table: "WarningPunishment");
migrationBuilder.AddColumn<int>(
name: "GuildConfigId",
table: "WarningPunishment",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_WarningPunishment_GuildConfigId",
table: "WarningPunishment",
column: "GuildConfigId");
migrationBuilder.AddForeignKey(
name: "FK_WarningPunishment_GuildConfigs_GuildConfigId",
table: "WarningPunishment",
column: "GuildConfigId",
principalTable: "GuildConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
/// <inheritdoc />
public partial class ncanvas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "NCPixel",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Position = table.Column<int>(type: "INTEGER", nullable: false),
Price = table.Column<long>(type: "INTEGER", nullable: false),
OwnerId = table.Column<ulong>(type: "INTEGER", nullable: false),
Color = table.Column<uint>(type: "INTEGER", nullable: false),
Text = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NCPixel", x => x.Id);
table.UniqueConstraint("AK_NCPixel_Position", x => x.Position);
});
migrationBuilder.CreateIndex(
name: "IX_DiscordUser_Username",
table: "DiscordUser",
column: "Username");
migrationBuilder.CreateIndex(
name: "IX_NCPixel_OwnerId",
table: "NCPixel",
column: "OwnerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NCPixel");
migrationBuilder.DropIndex(
name: "IX_DiscordUser_Username",
table: "DiscordUser");
}
}
}

View File

@@ -596,6 +596,8 @@ namespace NadekoBot.Migrations
b.HasIndex("UserId");
b.HasIndex("Username");
b.ToTable("DiscordUser");
});
@@ -1213,6 +1215,38 @@ namespace NadekoBot.Migrations
b.ToTable("MutedUserId");
});
modelBuilder.Entity("NadekoBot.Db.Models.NCPixel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("Color")
.HasColumnType("INTEGER");
b.Property<ulong>("OwnerId")
.HasColumnType("INTEGER");
b.Property<int>("Position")
.HasColumnType("INTEGER");
b.Property<long>("Price")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasAlternateKey("Position");
b.HasIndex("OwnerId");
b.ToTable("NCPixel");
});
modelBuilder.Entity("NadekoBot.Db.Models.NadekoExpression", b =>
{
b.Property<int>("Id")
@@ -2183,7 +2217,7 @@ namespace NadekoBot.Migrations
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<int?>("GuildConfigId")
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<int>("Punishment")
@@ -2197,7 +2231,7 @@ namespace NadekoBot.Migrations
b.HasKey("Id");
b.HasIndex("GuildConfigId");
b.HasAlternateKey("GuildId", "Count");
b.ToTable("WarningPunishment");
});
@@ -2760,14 +2794,6 @@ namespace NadekoBot.Migrations
b.Navigation("User");
});
modelBuilder.Entity("NadekoBot.Db.Models.WarningPunishment", b =>
{
b.HasOne("NadekoBot.Db.Models.GuildConfig", null)
.WithMany("WarnPunishments")
.HasForeignKey("GuildConfigId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("NadekoBot.Db.Models.XpCurrencyReward", b =>
{
b.HasOne("NadekoBot.Db.Models.XpSettings", "XpSettings")
@@ -2880,8 +2906,6 @@ namespace NadekoBot.Migrations
b.Navigation("VcRoleInfos");
b.Navigation("WarnPunishments");
b.Navigation("XpSettings");
});

View File

@@ -1,4 +1,3 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
@@ -11,53 +10,63 @@ public class AutoPublishService : IExecNoCommand, IReadyExecutor, INService
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCredsProvider _creds;
private ConcurrentDictionary<ulong, ulong> _enabled;
private ConcurrentDictionary<ulong, ulong> _enabled = new();
public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds)
{
_db = db;
_client = client;
_creds = creds;
}
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
}
public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg)
{
if (guild is null)
return;
if (msg.Channel.GetChannelType() != ChannelType.News)
return;
if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id)
return;
await msg.CrosspostAsync(new RequestOptions()
{
RetryMode = RetryMode.AlwaysFail
});
}
// todo GUILDS
public async Task OnReadyAsync()
{
var creds = _creds.GetCreds();
await using var ctx = _db.GetDbContext();
var items = await ctx.GetTable<AutoPublishChannel>()
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId))
.ToListAsyncLinqToDB();
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId))
.ToListAsyncLinqToDB();
_enabled = items
.ToDictionary(x => x.GuildId, x => x.ChannelId)
.ToConcurrent();
.ToDictionary(x => x.GuildId, x => x.ChannelId)
.ToConcurrent();
_client.LeftGuild += ClientOnLeftGuild;
}
private async Task ClientOnLeftGuild(SocketGuild guild)
{
await using var ctx = _db.GetDbContext();
_enabled.TryRemove(guild.Id, out _);
await ctx.GetTable<AutoPublishChannel>()
.Where(x => x.GuildId == guild.Id)
.DeleteAsync();
}
public async Task<bool> ToggleAutoPublish(ulong guildId, ulong channelId)
{
await using var ctx = _db.GetDbContext();
var deleted = await ctx.GetTable<AutoPublishChannel>()
.DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId);
.DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId);
if (deleted != 0)
{
@@ -66,22 +75,22 @@ public class AutoPublishService : IExecNoCommand, IReadyExecutor, INService
}
await ctx.GetTable<AutoPublishChannel>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
old => new()
{
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
() => new()
{
GuildId = guildId
});
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
old => new()
{
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
() => new()
{
GuildId = guildId
});
_enabled[guildId] = channelId;
return true;

View File

@@ -206,6 +206,18 @@ public sealed class CleanupService : ICleanupService, IReadyExecutor, INService
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete autopublish channels
await ctx.GetTable<AutoPublishChannel>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete greet settings
await ctx.GetTable<GreetSettings>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
return new()
{

View File

@@ -138,8 +138,7 @@ public partial class Administration
[OwnerOnly]
public Task DeleteXp()
=> ConfirmActionInternalAsync("Delete Xp", () => _xcs.DeleteXp());
[Cmd]
[OwnerOnly]
public Task DeleteWaifus()

View File

@@ -200,9 +200,7 @@ public partial class Administration
if (!isEnabled)
{
var cmdName = GetCmdName(type);
await Response().Pending(strs.boostmsg_enable($"`{prefix}{cmdName}`")).SendAsync();
await SendGreetEnableHint(type);
}
}
@@ -226,19 +224,24 @@ public partial class Administration
await _service.Test(ctx.Guild.Id, type, (ITextChannel)ctx.Channel, user);
var conf = await _service.GetGreetSettingsAsync(ctx.Guild.Id, type);
if (conf?.IsEnabled is not true)
await SendGreetEnableHint(type);
}
private async Task SendGreetEnableHint(GreetType type)
{
var cmd = $"`{prefix}{GetCmdName(type)}`";
var str = type switch
{
GreetType.Greet => strs.boostmsg_enable(cmd),
GreetType.Bye => strs.greetmsg_enable(cmd),
GreetType.Boost => strs.byemsg_enable(cmd),
GreetType.Greet => strs.greetmsg_enable(cmd),
GreetType.Bye => strs.byemsg_enable(cmd),
GreetType.Boost => strs.boostmsg_enable(cmd),
GreetType.GreetDm => strs.greetdmmsg_enable(cmd),
_ => strs.error
};
if (conf?.IsEnabled is not true)
await Response().Pending(str).SendAsync();
await Response().Pending(str).SendAsync();
}
}
}

View File

@@ -66,10 +66,10 @@ public partial class Administration
{
await _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithErrorColor()
.WithDescription(GetText(strs.warned_on(ctx.Guild.ToString())))
.AddField(GetText(strs.moderator), ctx.User.ToString())
.AddField(GetText(strs.reason), reason ?? "-"))
.WithErrorColor()
.WithDescription(GetText(strs.warned_on(ctx.Guild.ToString())))
.AddField(GetText(strs.moderator), ctx.User.ToString())
.AddField(GetText(strs.reason), reason ?? "-"))
.SendAsync();
}
catch
@@ -85,8 +85,9 @@ public partial class Administration
catch (Exception ex)
{
Log.Warning(ex, "Exception occured while warning a user");
var errorEmbed = _sender.CreateEmbed().WithErrorColor()
.WithDescription(GetText(strs.cant_apply_punishment));
var errorEmbed = _sender.CreateEmbed()
.WithErrorColor()
.WithDescription(GetText(strs.cant_apply_punishment));
if (dmFailed)
errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -117,7 +118,7 @@ public partial class Administration
[Priority(1)]
public async Task WarnExpire()
{
var expireDays = await _service.GetWarnExpire(ctx.Guild.Id);
var (expireDays, _) = await _service.GetWarnExpire(ctx.Guild.Id);
if (expireDays == 0)
await Response().Confirm(strs.warns_dont_expire).SendAsync();
@@ -266,9 +267,9 @@ public partial class Administration
});
return _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.warnings_list))
.WithDescription(string.Join("\n", ws));
.WithOkColor()
.WithTitle(GetText(strs.warnings_list))
.WithDescription(string.Join("\n", ws));
})
.SendAsync();
}
@@ -278,7 +279,7 @@ public partial class Administration
[UserPerm(GuildPerm.Administrator)]
public Task WarnDelete(IGuildUser user, int index)
=> WarnDelete(user.Id, index);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
@@ -286,15 +287,15 @@ public partial class Administration
{
if (--index < 0)
return;
var warn = await _service.WarnDelete(userId, index);
var warn = await _service.WarnDelete(ctx.Guild.Id, userId, index);
if (warn is null)
{
await Response().Error(strs.warning_not_found).SendAsync();
return;
}
await Response().Confirm(strs.warning_deleted(Format.Bold(index.ToString()))).SendAsync();
}
@@ -311,7 +312,7 @@ public partial class Administration
{
if (index < 0)
return;
var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString());
var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString());
if (index == 0)
@@ -344,7 +345,7 @@ public partial class Administration
return;
}
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role);
var success = await _service.WarnPunish(ctx.Guild.Id, number, punish, time, role);
if (!success)
return;
@@ -380,7 +381,7 @@ public partial class Administration
if (punish is PunishmentAction.TimeOut && time is null)
return;
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time);
var success = await _service.WarnPunish(ctx.Guild.Id, number, punish, time);
if (!success)
return;
@@ -407,7 +408,7 @@ public partial class Administration
[UserPerm(GuildPerm.BanMembers)]
public async Task WarnPunish(int number)
{
if (!_service.WarnPunishRemove(ctx.Guild.Id, number))
if (!await _service.WarnPunishRemove(ctx.Guild.Id, number))
return;
await Response().Confirm(strs.warn_punish_rem(Format.Bold(number.ToString()))).SendAsync();
@@ -417,7 +418,7 @@ public partial class Administration
[RequireContext(ContextType.Guild)]
public async Task WarnPunishList()
{
var ps = _service.WarnPunishList(ctx.Guild.Id);
var ps = await _service.WarnPunishList(ctx.Guild.Id);
string list;
if (ps.Any())
@@ -478,13 +479,13 @@ public partial class Administration
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune);
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true)
.AddField("ID", userId.ToString(), true)
.AddField(GetText(strs.duration),
time.Time.ToPrettyStringHm(),
true);
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true)
.AddField("ID", userId.ToString(), true)
.AddField(GetText(strs.duration),
time.Time.ToPrettyStringHm(),
true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -505,11 +506,12 @@ public partial class Administration
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512));
await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField("ID", userId.ToString(), true))
.SendAsync();
await Response()
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField("ID", userId.ToString(), true))
.SendAsync();
}
else
await Ban(user, msg);
@@ -543,10 +545,10 @@ public partial class Administration
await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512));
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -738,10 +740,10 @@ public partial class Administration
catch { await ctx.Guild.RemoveBanAsync(user); }
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("☣ " + GetText(strs.sb_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
.WithOkColor()
.WithTitle("☣ " + GetText(strs.sb_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -793,10 +795,10 @@ public partial class Administration
await user.KickAsync((ctx.User + " | " + msg).TrimTo(512));
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.kicked_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
.WithOkColor()
.WithTitle(GetText(strs.kicked_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -826,8 +828,8 @@ public partial class Administration
var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg));
await _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithPendingColor()
.WithDescription(dmMessage))
.WithPendingColor()
.WithDescription(dmMessage))
.SendAsync();
}
catch
@@ -838,10 +840,10 @@ public partial class Administration
await user.SetTimeOutAsync(time.Time);
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⏳ " + GetText(strs.timedout_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
.WithOkColor()
.WithTitle("⏳ " + GetText(strs.timedout_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
@@ -899,9 +901,9 @@ public partial class Administration
missStr = "-";
var toSend = _sender.CreateEmbed()
.WithDescription(GetText(strs.mass_ban_in_progress(banning.Count)))
.AddField(GetText(strs.invalid(missing.Count)), missStr)
.WithPendingColor();
.WithDescription(GetText(strs.mass_ban_in_progress(banning.Count)))
.AddField(GetText(strs.invalid(missing.Count)), missStr)
.WithPendingColor();
var banningMessage = await Response().Embed(toSend).SendAsync();
@@ -919,11 +921,13 @@ public partial class Administration
}
await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_ban_completed(banning.Count())))
.AddField(GetText(strs.invalid(missing.Count)), missStr)
.WithOkColor()
.Build());
.WithDescription(
GetText(strs.mass_ban_completed(
banning.Count())))
.AddField(GetText(strs.invalid(missing.Count)),
missStr)
.WithOkColor()
.Build());
}
[Cmd]
@@ -945,10 +949,10 @@ public partial class Administration
//send a message but don't wait for it
var banningMessageTask = Response()
.Embed(_sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_kill_in_progress(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithPendingColor())
.WithDescription(
GetText(strs.mass_kill_in_progress(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithPendingColor())
.SendAsync();
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
@@ -966,11 +970,11 @@ public partial class Administration
var banningMessage = await banningMessageTask;
await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_kill_completed(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithOkColor()
.Build());
.WithDescription(
GetText(strs.mass_kill_completed(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithOkColor()
.Build());
}
public class WarnExpireOptions : INadekoCommandOptions

View File

@@ -1,9 +1,7 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Common.TypeReaders.Models;
using NadekoBot.Modules.Permissions.Services;
using NadekoBot.Db.Models;
using Newtonsoft.Json;
@@ -83,17 +81,24 @@ public class UserPunishService : INService, IReadyExecutor
};
long previousCount;
List<WarningPunishment> ps;
var ps = await WarnPunishList(guildId);
await using (var uow = _db.GetDbContext())
{
ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
previousCount = uow.Set<Warning>()
.ForId(guildId, userId)
.Where(w => !w.Forgiven && w.UserId == userId)
previousCount = uow.GetTable<Warning>()
.Where(w => w.GuildId == guildId && w.UserId == userId && !w.Forgiven)
.Sum(x => x.Weight);
uow.Set<Warning>().Add(warn);
await uow.GetTable<Warning>()
.InsertAsync(() => new()
{
UserId = userId,
GuildId = guildId,
Forgiven = false,
Reason = reason,
Moderator = modName,
Weight = weight,
DateAdded = DateTime.UtcNow,
});
await uow.SaveChangesAsync();
}
@@ -260,12 +265,12 @@ public class UserPunishService : INService, IReadyExecutor
.ToListAsyncLinqToDB();
var cleared = await uow.GetTable<Warning>()
.Where(x => toClear.Contains(x.Id))
.UpdateAsync(_ => new()
{
Forgiven = true,
ForgivenBy = "expiry"
});
.Where(x => toClear.Contains(x.Id))
.UpdateAsync(_ => new()
{
Forgiven = true,
ForgivenBy = "expiry"
});
var toDelete = await uow.GetTable<Warning>()
.Where(x => uow.GetTable<GuildConfig>()
@@ -282,8 +287,8 @@ public class UserPunishService : INService, IReadyExecutor
.ToListAsyncLinqToDB();
var deleted = await uow.GetTable<Warning>()
.Where(x => toDelete.Contains(x.Id))
.DeleteAsync();
.Where(x => toDelete.Contains(x.Id))
.DeleteAsync();
if (cleared > 0 || deleted > 0)
{
@@ -324,11 +329,11 @@ public class UserPunishService : INService, IReadyExecutor
await uow.SaveChangesAsync();
}
public Task<int> GetWarnExpire(ulong guildId)
public Task<(int, bool)> GetWarnExpire(ulong guildId)
{
using var uow = _db.GetDbContext();
var config = uow.GuildConfigsForId(guildId, set => set);
return Task.FromResult(config.WarnExpireHours / 24);
return Task.FromResult((config.WarnExpireHours / 24, config.WarnExpireAction == WarnExpireAction.Delete));
}
public async Task WarnExpireAsync(ulong guildId, int days, bool delete)
@@ -377,18 +382,19 @@ public class UserPunishService : INService, IReadyExecutor
return toReturn;
}
public bool WarnPunish(
public async Task<bool> WarnPunish(
ulong guildId,
int number,
PunishmentAction punish,
StoopidTime time,
TimeSpan? time,
IRole role = null)
{
// these 3 don't make sense with time
if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles
&& time is not null)
return false;
if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49)))
if (number <= 0 || (time is not null && time > TimeSpan.FromDays(59)))
return false;
if (punish is PunishmentAction.AddRole && role is null)
@@ -397,47 +403,51 @@ public class UserPunishService : INService, IReadyExecutor
if (punish is PunishmentAction.TimeOut && time is null)
return false;
using var uow = _db.GetDbContext();
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
var toDelete = ps.Where(x => x.Count == number);
uow.RemoveRange(toDelete);
ps.Add(new()
{
Count = number,
Punishment = punish,
Time = (int?)time?.Time.TotalMinutes ?? 0,
RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?)
});
uow.SaveChanges();
var timeMinutes = (int?)time?.TotalMinutes ?? 0;
var roleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?);
await using var uow = _db.GetDbContext();
await uow.GetTable<WarningPunishment>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
Count = number,
Punishment = punish,
Time = timeMinutes,
RoleId = roleId
},
_ => new()
{
Punishment = punish,
Time = timeMinutes,
RoleId = roleId
},
() => new()
{
GuildId = guildId,
Count = number
});
return true;
}
public bool WarnPunishRemove(ulong guildId, int number)
public async Task<bool> WarnPunishRemove(ulong guildId, int count)
{
if (number <= 0)
return false;
await using var uow = _db.GetDbContext();
var numDeleted = await uow.GetTable<WarningPunishment>()
.DeleteAsync(x => x.GuildId == guildId && x.Count == count);
using var uow = _db.GetDbContext();
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
var p = ps.FirstOrDefault(x => x.Count == number);
if (p is not null)
{
uow.Remove(p);
uow.SaveChanges();
}
return true;
return numDeleted > 0;
}
public WarningPunishment[] WarnPunishList(ulong guildId)
public async Task<WarningPunishment[]> WarnPunishList(ulong guildId)
{
using var uow = _db.GetDbContext();
return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments))
.WarnPunishments.OrderBy(x => x.Count)
.ToArray();
await using var uow = _db.GetDbContext();
var wps = uow.GetTable<WarningPunishment>()
.Where(x => x.GuildId == guildId)
.OrderBy(x => x.Count)
.ToArray();
return wps;
}
public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill(
@@ -607,12 +617,12 @@ public class UserPunishService : INService, IReadyExecutor
return await _repSvc.ReplaceAsync(output, repCtx);
}
public async Task<Warning> WarnDelete(ulong userId, int index)
public async Task<Warning> WarnDelete(ulong guildId, ulong userId, int index)
{
await using var uow = _db.GetDbContext();
var warn = await uow.GetTable<Warning>()
.Where(x => x.UserId == userId)
.Where(x => x.GuildId == guildId && x.UserId == userId)
.OrderByDescending(x => x.DateAdded)
.Skip(index)
.FirstOrDefaultAsyncLinqToDB();
@@ -626,4 +636,73 @@ public class UserPunishService : INService, IReadyExecutor
return warn;
}
public async Task<bool> WarnDelete(ulong guildId, int id)
{
await using var uow = _db.GetDbContext();
var numDeleted = await uow.GetTable<Warning>()
.Where(x => x.GuildId == guildId && x.Id == id)
.DeleteAsync();
return numDeleted > 0;
}
public async Task<(IReadOnlyCollection<Warning> latest, int totalCount)> GetLatestWarnings(
ulong guildId,
int page = 1)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(page);
await using var uow = _db.GetDbContext();
var latest = await uow.GetTable<Warning>()
.Where(x => x.GuildId == guildId)
.OrderByDescending(x => x.DateAdded)
.Skip(10 * (page - 1))
.Take(10)
.ToListAsyncLinqToDB();
var totalCount = await uow.GetTable<Warning>()
.Where(x => x.GuildId == guildId)
.CountAsyncLinqToDB();
return (latest, totalCount);
}
public async Task<bool> ForgiveWarning(ulong requestGuildId, int warnId, string modName)
{
await using var uow = _db.GetDbContext();
var success = await uow.GetTable<Warning>()
.Where(x => x.GuildId == requestGuildId && x.Id == warnId)
.UpdateAsync(_ => new()
{
Forgiven = true,
ForgivenBy = modName,
})
== 1;
return success;
}
public async Task<(IReadOnlyCollection<Warning> latest, int totalCount)> GetUserWarnings(
ulong guildId,
ulong userId,
int page)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(page);
await using var uow = _db.GetDbContext();
var latest = await uow.GetTable<Warning>()
.Where(x => x.GuildId == guildId && x.UserId == userId)
.OrderByDescending(x => x.DateAdded)
.Skip(10 * (page - 1))
.Take(10)
.ToListAsyncLinqToDB();
var totalCount = await uow.GetTable<Warning>()
.Where(x => x.GuildId == guildId && x.UserId == userId)
.CountAsyncLinqToDB();
return (latest, totalCount);
}
}

View File

@@ -67,7 +67,6 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
// private readonly GlobalPermissionService _gperm;
// private readonly CmdCdService _cmdCds;
private readonly IPermissionChecker _permChecker;
private readonly ICommandHandler _cmd;
private readonly IBotStrings _strings;
private readonly IBot _bot;
private readonly IPubSub _pubSub;
@@ -84,7 +83,6 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
IBotStrings strings,
IBot bot,
DiscordSocketClient client,
ICommandHandler cmd,
IPubSub pubSub,
IMessageSenderService sender,
IReplacementService repSvc,
@@ -93,7 +91,6 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
_db = db;
_client = client;
_cmd = cmd;
_strings = strings;
_bot = bot;
_pubSub = pubSub;

View File

@@ -252,18 +252,18 @@ public partial class Gambling
var claimer = "no one";
string status;
var waifuUsername = w.Username.TrimTo(20);
var claimerUsername = w.Claimer?.TrimTo(20);
var waifuUsername = w.WaifuName.TrimTo(20);
var claimerUsername = w.ClaimerName?.TrimTo(20);
if (w.Claimer is not null)
claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
if (w.ClaimerName is not null)
claimer = $"{claimerUsername}";
if (w.Affinity is null)
status = $"... but {waifuUsername}'s heart is empty";
else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
else if (w.Affinity == w.ClaimerName)
status = $"... and {waifuUsername} likes {claimerUsername} too <3";
else
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}";
return $"**{waifuUsername}** - claimed by **{claimer}**\n\t{status}";
}
[Cmd]

View File

@@ -40,12 +40,17 @@ public static class WaifuExtensions
.Take(count)
.Select(x => new WaifuLbResult
{
Affinity = x.Affinity == null ? null : x.Affinity.Username,
AffinityDiscrim = x.Affinity == null ? null : x.Affinity.Discriminator,
Claimer = x.Claimer == null ? null : x.Claimer.Username,
ClaimerDiscrim = x.Claimer == null ? null : x.Claimer.Discriminator,
Username = x.Waifu.Username,
Discrim = x.Waifu.Discriminator,
Affinity = x.Affinity == null
? null
: x.Affinity.Username
+ (x.Affinity.Discriminator != "0000" ? "#" + x.Affinity.Discriminator : ""),
ClaimerName =
x.Claimer == null
? null
: x.Claimer.Username
+ (x.Claimer.Discriminator != "0000" ? "#" + x.Claimer.Discriminator : ""),
WaifuName = x.Waifu.Username
+ (x.Waifu.Discriminator != "0000" ? "#" + x.Waifu.Discriminator : ""),
Price = x.Price
})
.ToListAsyncEF();

View File

@@ -3,14 +3,11 @@ namespace NadekoBot.Db.Models;
public class WaifuLbResult
{
public string Username { get; set; }
public string Discrim { get; set; }
public string WaifuName { get; set; }
public string Claimer { get; set; }
public string ClaimerDiscrim { get; set; }
public string ClaimerName { get; set; }
public string Affinity { get; set; }
public string AffinityDiscrim { get; set; }
public long Price { get; set; }
}

View File

@@ -0,0 +1,24 @@
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Games;
public interface INCanvasService
{
Task<uint[]> GetCanvas();
Task<NCPixel[]> GetPixelGroup(int position);
Task<SetPixelResult> SetPixel(
int position,
uint color,
string text,
ulong userId,
long price);
Task<bool> SetImage(uint[] img);
Task<NCPixel?> GetPixel(int x, int y);
Task<NCPixel?> GetPixel(int position);
int GetHeight();
int GetWidth();
Task ResetAsync();
}

View File

@@ -0,0 +1,289 @@
using NadekoBot.Modules.Gambling.Services;
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Image = SixLabors.ImageSharp.Image;
namespace NadekoBot.Modules.Games;
public partial class Games
{
public sealed class NCanvasCommands : NadekoModule
{
private readonly INCanvasService _service;
private readonly IHttpClientFactory _http;
private readonly FontProvider _fonts;
private readonly GamblingConfigService _gcs;
public NCanvasCommands(
INCanvasService service,
IHttpClientFactory http,
FontProvider fonts,
GamblingConfigService gcs)
{
_service = service;
_http = http;
_fonts = fonts;
_gcs = gcs;
}
[Cmd]
public async Task NCanvas()
{
var pixels = await _service.GetCanvas();
var image = new Image<Rgba32>(_service.GetWidth(), _service.GetHeight());
Parallel.For(0,
image.Height,
y =>
{
var pixelAccessor = image.DangerousGetPixelRowMemory(y);
var row = pixelAccessor.Span;
for (int x = 0; x < image.Width; x++)
{
row[x] = new Rgba32(pixels[(y * image.Width) + x]);
}
});
await using var stream = await image.ToStreamAsync();
var hint = GetText(strs.nc_hint(prefix, _service.GetWidth(), _service.GetHeight()));
await Response()
.File(stream, "ncanvas.png")
.Embed(_sender.CreateEmbed()
.WithOkColor()
#if GLOBAL_NADEKO
.WithDescription("https://dashy.nadeko.bot/ncanvas")
#endif
.WithFooter(hint)
.WithImageUrl("attachment://ncanvas.png"))
.SendAsync();
}
[Cmd]
public Task NCzoom(int row, int col)
=> NCzoom((col * _service.GetWidth()) + row);
[Cmd]
public async Task NCzoom(kwum position)
{
var w = _service.GetWidth();
var h = _service.GetHeight();
if (position < 0 || position >= w * h)
{
await Response().Error(strs.invalid_input).SendAsync();
return;
}
using var img = await GetZoomImage(position);
await using var stream = await img.ToStreamAsync();
await ctx.Channel.SendFileAsync(stream, $"zoom_{position}.png");
}
private async Task<Image<Rgba32>> GetZoomImage(kwum position)
{
var w = _service.GetWidth();
var pixels = await _service.GetPixelGroup(position);
var origX = ((position % w) - 2) * 100;
var origY = ((position / w) - 2) * 100;
var image = new Image<Rgba32>(500, 500);
const float fontSize = 30;
var posFont = _fonts.NotoSans.CreateFont(fontSize, FontStyle.Bold);
var size = TextMeasurer.MeasureSize("wwww", new TextOptions(posFont));
var scale = 100f / size.Width;
if (scale < 1)
posFont = _fonts.NotoSans.CreateFont(fontSize * scale, FontStyle.Bold);
var outlinePen = new SolidPen(SixLabors.ImageSharp.Color.Black, 1f);
Parallel.For(0,
pixels.Length,
i =>
{
var pix = pixels[i];
var startX = pix.Position % w * 100 - origX;
var startY = pix.Position / w * 100 - origY;
var color = new Rgba32(pix.Color);
image.Mutate(x => FillRectangleExtensions.Fill(x,
new SolidBrush(color),
new RectangleF(startX, startY, 100, 100)));
image.Mutate(x =>
{
x.DrawText(new RichTextOptions(posFont)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Origin = new(startX + 50, startY + 50)
},
((kwum)pix.Position).ToString().PadLeft(2, '2'),
Brushes.Solid(SixLabors.ImageSharp.Color.White),
outlinePen);
});
});
// write the position on each section of the image
return image;
}
[Cmd]
public async Task NcSetPixel(kwum position, string colorHex, [Leftover] string text = "")
{
if (position < 0 || position >= _service.GetWidth() * _service.GetHeight())
{
await Response().Error(strs.invalid_input).SendAsync();
return;
}
if (colorHex.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
colorHex = colorHex[2..];
if (!Rgba32.TryParseHex(colorHex, out var clr))
{
await Response().Error(strs.invalid_color).SendAsync();
return;
}
var pixel = await _service.GetPixel(position);
if (pixel is null)
{
await Response().Error(strs.nc_pixel_not_found).SendAsync();
return;
}
var prompt = GetText(strs.nc_pixel_set_confirm(Format.Code(position.ToString()),
Format.Bold(CurrencyHelper.N(pixel.Price,
Culture,
_gcs.Data.Currency.Sign))));
if (!await PromptUserConfirmAsync(_sender.CreateEmbed()
.WithPendingColor()
.WithDescription(prompt)))
{
return;
}
await _service.SetPixel(position, clr.PackedValue, text, ctx.User.Id, pixel.Price);
using var img = await GetZoomImage(position);
await using var stream = await img.ToStreamAsync();
await Response()
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.nc_pixel_set(Format.Code(position.ToString()))))
.WithImageUrl($"attachment://zoom_{position}.png"))
.File(stream, $"zoom_{position}.png")
.SendAsync();
}
[Cmd]
public async Task NcPixel(int x, int y)
=> await NcPixel((y * _service.GetWidth()) + x);
[Cmd]
public async Task NcPixel(kwum position)
{
if (position < 0 || position >= _service.GetWidth() * _service.GetHeight())
{
await Response().Error(strs.invalid_input).SendAsync();
return;
}
var pixel = await _service.GetPixel(position);
if (pixel is null)
{
await Response().Error(strs.nc_pixel_not_found).SendAsync();
return;
}
var image = new Image<Rgba32>(100, 100);
image.Mutate(x
=> x.Fill(new SolidBrush(new Rgba32(pixel.Color)),
new RectangleF(0, 0, 100, 100)));
await using var stream = await image.ToStreamAsync();
var pos = new kwum(pixel.Position);
await Response()
.File(stream, $"{pixel.Position}.png")
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithDescription(string.IsNullOrWhiteSpace(pixel.Text) ? string.Empty : pixel.Text)
.WithTitle(GetText(strs.nc_pixel(pos)))
.AddField(GetText(strs.nc_position),
$"{pixel.Position % _service.GetWidth()} {pixel.Position / _service.GetWidth()}",
true)
.AddField(GetText(strs.price), pixel.Price.ToString(), true)
.AddField(GetText(strs.color), "#" + new Rgba32(pixel.Color).ToHex())
.WithImageUrl($"attachment://{pixel.Position}.png"))
.SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task NcSetImg()
{
var attach = ctx.Message.Attachments.FirstOrDefault();
if (attach is null)
{
await Response().Error(strs.no_attach_found).SendAsync();
return;
}
var w = _service.GetWidth();
var h = _service.GetHeight();
if (attach.Width != w || attach.Height != h)
{
await Response().Error(strs.invalid_img_size(w, h)).SendAsync();
return;
}
if (!await PromptUserConfirmAsync(_sender.CreateEmbed()
.WithDescription(
"This will reset the canvas to the specified image. All prices, text and colors will be reset.\n\n"
+ "Are you sure you want to continue?")))
return;
using var http = _http.CreateClient();
await using var stream = await http.GetStreamAsync(attach.Url);
using var img = await Image.LoadAsync<Rgba32>(stream);
var pixels = new uint[_service.GetWidth() * _service.GetHeight()];
Parallel.For(0,
_service.GetWidth() * _service.GetHeight(),
i => pixels[i] = img[i % _service.GetWidth(), i / _service.GetWidth()].PackedValue);
// for (var y = 0; y < _service.GetHeight(); y++)
// for (var x = 0; x < _service.GetWidth(); x++)
// pixels[(y * _service.GetWidth()) + x] = img[x, y].PackedValue;
await _service.SetImage(pixels);
await ctx.OkAsync();
}
[Cmd]
[OwnerOnly]
public async Task NcReset()
{
await _service.ResetAsync();
if (!await PromptUserConfirmAsync(_sender.CreateEmbed()
.WithDescription(
"This will delete all pixels and reset the canvas.\n\n"
+ "Are you sure you want to continue?")))
return;
await ctx.OkAsync();
}
}
}

View File

@@ -0,0 +1,206 @@
using LinqToDB;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db.Models;
using SixLabors.ImageSharp.ColorSpaces;
using SixLabors.ImageSharp.ColorSpaces.Conversion;
using SixLabors.ImageSharp.PixelFormats;
namespace NadekoBot.Modules.Games;
public sealed class NCanvasService : INCanvasService, IReadyExecutor, INService
{
private readonly TypedKey<uint[]> _canvasKey = new("ncanvas");
private readonly DbService _db;
private readonly IBotCache _cache;
private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs;
public const int CANVAS_WIDTH = 500;
public const int CANVAS_HEIGHT = 350;
public const int INITIAL_PRICE = 10;
public NCanvasService(
DbService db,
IBotCache cache,
DiscordSocketClient client,
ICurrencyService cs)
{
_db = db;
_cache = cache;
_client = client;
_cs = cs;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
await using var uow = _db.GetDbContext();
if (await uow.GetTable<NCPixel>().CountAsyncLinqToDB() > 0)
return;
await ResetAsync();
}
public async Task ResetAsync()
{
await using var uow = _db.GetDbContext();
await uow.GetTable<NCPixel>().DeleteAsync();
var toAdd = new List<int>();
for (var i = 0; i < CANVAS_WIDTH * CANVAS_HEIGHT; i++)
{
toAdd.Add(i);
}
await uow.GetTable<NCPixel>()
.BulkCopyAsync(toAdd.Select(x =>
{
var clr = ColorSpaceConverter.ToRgb(new Hsv(((float)Random.Shared.NextDouble() * 360),
(float)(0.5 + (Random.Shared.NextDouble() * 0.49)),
(float)(0.4 + (Random.Shared.NextDouble() / 5 + (x % 100 * 0.2)))))
.ToVector3();
var packed = new Rgba32(clr).PackedValue;
return new NCPixel()
{
Color = packed,
Price = 1,
Position = x,
Text = "",
OwnerId = 0
};
}));
}
private async Task<uint[]> InternalGetCanvas()
{
await using var uow = _db.GetDbContext();
var colors = await uow.GetTable<NCPixel>()
.OrderBy(x => x.Position)
.Select(x => x.Color)
.ToArrayAsyncLinqToDB();
return colors;
}
public async Task<uint[]> GetCanvas()
{
return await _cache.GetOrAddAsync(_canvasKey,
async () => await InternalGetCanvas(),
TimeSpan.FromSeconds(15))
?? [];
}
public async Task<SetPixelResult> SetPixel(
int position,
uint color,
string text,
ulong userId,
long price)
{
if (position < 0 || position >= CANVAS_WIDTH * CANVAS_HEIGHT)
return SetPixelResult.InvalidInput;
var wallet = await _cs.GetWalletAsync(userId);
var paid = await wallet.Take(price, new("canvas", "pixel", $"Bought pixel #{position}"));
if (!paid)
{
return SetPixelResult.NotEnoughMoney;
}
var success = false;
try
{
await using var uow = _db.GetDbContext();
var updates = await uow.GetTable<NCPixel>()
.Where(x => x.Position == position && x.Price <= price)
.UpdateAsync(old => new NCPixel()
{
Position = position,
Color = color,
Text = text,
OwnerId = userId,
Price = price + 1
});
success = updates > 0;
}
catch
{
}
if (!success)
{
await wallet.Add(price, new("canvas", "pixel-refund", $"Refund pixel #{position} purchase"));
}
return success ? SetPixelResult.Success : SetPixelResult.InsufficientPayment;
}
public async Task<bool> SetImage(uint[] colors)
{
if (colors.Length != CANVAS_WIDTH * CANVAS_HEIGHT)
return false;
await using var uow = _db.GetDbContext();
await uow.GetTable<NCPixel>().DeleteAsync();
await uow.GetTable<NCPixel>()
.BulkCopyAsync(colors.Select((x, i) => new NCPixel()
{
Color = x,
Price = INITIAL_PRICE,
Position = i,
Text = "",
OwnerId = 0
}));
return true;
}
public Task<NCPixel?> GetPixel(int x, int y)
{
ArgumentOutOfRangeException.ThrowIfNegative(x);
ArgumentOutOfRangeException.ThrowIfNegative(y);
if (x >= CANVAS_WIDTH || y >= CANVAS_HEIGHT)
return Task.FromResult<NCPixel?>(null);
return GetPixel(x + (y * CANVAS_WIDTH));
}
public async Task<NCPixel?> GetPixel(int position)
{
ArgumentOutOfRangeException.ThrowIfNegative(position);
await using var uow = _db.GetDbContext();
return await uow.GetTable<NCPixel>().FirstOrDefaultAsync(x => x.Position == position);
}
public async Task<NCPixel[]> GetPixelGroup(int position)
{
ArgumentOutOfRangeException.ThrowIfNegative(position);
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(position, CANVAS_WIDTH * CANVAS_HEIGHT);
await using var uow = _db.GetDbContext();
return await uow.GetTable<NCPixel>()
.Where(x => x.Position % CANVAS_WIDTH >= (position % CANVAS_WIDTH) - 2
&& x.Position % CANVAS_WIDTH <= (position % CANVAS_WIDTH) + 2
&& x.Position / CANVAS_WIDTH >= (position / CANVAS_WIDTH) - 2
&& x.Position / CANVAS_WIDTH <= (position / CANVAS_WIDTH) + 2)
.OrderBy(x => x.Position)
.ToArrayAsyncLinqToDB();
}
public int GetHeight()
=> CANVAS_HEIGHT;
public int GetWidth()
=> CANVAS_WIDTH;
}

View File

@@ -0,0 +1,9 @@
namespace NadekoBot.Modules.Games;
public enum SetPixelResult
{
Success,
InsufficientPayment,
NotEnoughMoney,
InvalidInput
}

View File

@@ -100,10 +100,6 @@ public sealed class AiAssistantService
using var client = _httpFactory.CreateClient();
// todo customize according to the bot's config
// - CurrencyName
// -
using var response = await client.SendAsync(request);
if (response.StatusCode == HttpStatusCode.TooManyRequests)

View File

@@ -26,6 +26,8 @@ public interface IQuoteService
ulong guildId,
string? keyword,
string text);
Task<(IReadOnlyCollection<Quote> quotes, int totalCount)> FindQuotesAsync(ulong guildId, string query, int page);
Task<IReadOnlyCollection<Quote>> GetGuildQuotesAsync(ulong guildId);
Task<int> RemoveAllByKeyword(ulong guildId, string keyword);
@@ -39,6 +41,7 @@ public interface IQuoteService
string text);
Task<Quote?> EditQuoteAsync(ulong authorId, int quoteId, string text);
Task<Quote?> EditQuoteAsync(ulong guildId, int quoteId, string keyword, string text);
Task<bool> DeleteQuoteAsync(
ulong guildId,

View File

@@ -169,6 +169,23 @@ public sealed class QuoteService : IQuoteService, INService
return q;
}
public async Task<Quote?> EditQuoteAsync(
ulong guildId,
int quoteId,
string keyword,
string text)
{
await using var uow = _db.GetDbContext();
var result = await uow.GetTable<Quote>()
.Where(x => x.Id == quoteId && x.GuildId == guildId)
.Set(x => x.Keyword, keyword)
.Set(x => x.Text, text)
.UpdateWithOutputAsync((del, ins) => ins);
var q = result.FirstOrDefault();
return q;
}
public async Task<bool> DeleteQuoteAsync(
ulong guildId,
ulong authorId,
@@ -219,4 +236,24 @@ public sealed class QuoteService : IQuoteService, INService
return true;
}
public async Task<(IReadOnlyCollection<Quote> quotes, int totalCount)> FindQuotesAsync(
ulong guildId,
string query,
int page)
{
await using var uow = _db.GetDbContext();
var baseQuery = uow.GetTable<Quote>()
.Where(x => x.GuildId == guildId)
.Where(x => x.Keyword.Contains(query) || x.Text.Contains(query));
var quotes = await baseQuery
.OrderBy(x => x.Id)
.Skip((page - 1) * 10)
.Take(10)
.ToListAsyncLinqToDB();
return (quotes, await baseQuery.CountAsyncLinqToDB());
}
}

View File

@@ -197,26 +197,29 @@ public class RemindService : INService, IReadyExecutor, IRemindService
var st = SmartText.CreateFrom(r.Message);
var res = _sender.Response(ch)
.UserBasedMentions(_client.GetGuild(r.ServerId)?.GetUser(r.UserId));
if (st is SmartEmbedText set)
{
await _sender.Response(ch).Embed(set.GetEmbed()).SendAsync();
await res.Embed(set.GetEmbed()).SendAsync();
}
else if (st is SmartEmbedTextArray seta)
{
await _sender.Response(ch).Embeds(seta.GetEmbedBuilders()).SendAsync();
await res.Embeds(seta.GetEmbedBuilders()).SendAsync();
}
else
{
await _sender.Response(ch)
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle("Reminder")
.AddField("Created At",
r.DateAdded.HasValue ? r.DateAdded.Value.ToLongDateString() : "?")
.AddField("By",
(await ch.GetUserAsync(r.UserId))?.ToString() ?? r.UserId.ToString()))
.Text(r.Message)
.SendAsync();
await res
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle("Reminder")
.AddField("Created At",
r.DateAdded.HasValue ? r.DateAdded.Value.ToLongDateString() : "?")
.AddField("By",
(await ch.GetUserAsync(r.UserId))?.ToString() ?? r.UserId.ToString()))
.Text(r.Message)
.SendAsync();
}
}
catch (Exception ex)

View File

@@ -107,7 +107,7 @@ public partial class Xp : NadekoModule<XpService>
[Cmd]
[UserPerm(GuildPerm.ManageChannels)]
[RequireContext(ContextType.Guild)]
public async Task XpExclude(Channel _, [Leftover] IChannel channel = null)
public async Task XpExclude(Channel _, [Leftover] IChannel? channel = null)
{
if (channel is null)
channel = ctx.Channel;
@@ -182,29 +182,28 @@ public partial class Xp : NadekoModule<XpService>
var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args);
await ctx.Channel.TriggerTypingAsync();
var socketGuild = (SocketGuild)ctx.Guild;
var allCleanUsers = new List<UserXpStats>();
if (opts.Clean)
{
await ctx.Channel.TriggerTypingAsync();
await _tracker.EnsureUsersDownloadedAsync(ctx.Guild);
allCleanUsers = (await _service.GetTopUserXps(ctx.Guild.Id, 1000))
.Where(user => socketGuild.GetUser(user.UserId) is not null)
.ToList();
}
var res = opts.Clean
? Response()
.Paginated()
.Items(allCleanUsers)
: Response()
.Paginated()
.PageItems((curPage) => _service.GetUserXps(ctx.Guild.Id, curPage));
async Task<IReadOnlyCollection<UserXpStats>> GetPageItems(int curPage)
{
var socketGuild = (SocketGuild)ctx.Guild;
if (opts.Clean)
{
return await _service.GetGuildUserXps(ctx.Guild.Id,
socketGuild.Users.Select(x => x.Id).ToList(),
curPage);
}
await res
.PageSize(9)
return await _service.GetGuildUserXps(ctx.Guild.Id, curPage);
}
await Response()
.Paginated()
.PageItems(GetPageItems)
.PageSize(10)
.CurrentPage(page)
.Page((users, curPage) =>
{
@@ -237,15 +236,33 @@ public partial class Xp : NadekoModule<XpService>
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task XpGlobalLeaderboard(int page = 1)
public async Task XpGlobalLeaderboard(int page = 1, params string[] args)
{
if (--page < 0 || page > 99)
return;
var (opts, _) = OptionsParser.ParseFrom(new LbOpts(), args);
await ctx.Channel.TriggerTypingAsync();
if (opts.Clean)
{
await _tracker.EnsureUsersDownloadedAsync(ctx.Guild);
}
async Task<IReadOnlyCollection<DiscordUser>> GetPageItems(int curPage)
{
if (opts.Clean)
{
return await _service.GetGlobalUserXps(page, ((SocketGuild)ctx.Guild).Users.Select(x => x.Id).ToList());
}
return await _service.GetGlobalUserXps(curPage);
}
await Response()
.Paginated()
.PageItems(async curPage => await _service.GetUserXps(curPage))
.PageSize(9)
.PageItems(GetPageItems)
.PageSize(10)
.Page((users, curPage) =>
{
var embed = _sender.CreateEmbed()
@@ -282,7 +299,9 @@ public partial class Xp : NadekoModule<XpService>
if (role.IsManaged)
return;
var count = await _service.AddXpToUsersAsync(ctx.Guild.Id, amount, role.Members.Select(x => x.Id).ToArray());
var count = await _service.AddXpToUsersAsync(ctx.Guild.Id,
amount,
role.Members.Select(x => x.Id).ToArray());
await Response()
.Confirm(
strs.xpadd_users(Format.Bold(amount.ToString()), Format.Bold(count.ToString())))

View File

@@ -12,6 +12,7 @@ using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using System.Threading.Channels;
using LinqToDB.EntityFrameworkCore;
using LinqToDB.Tools;
using NadekoBot.Modules.Patronage;
using Color = SixLabors.ImageSharp.Color;
using Exception = System.Exception;
@@ -563,23 +564,50 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
uow.SaveChanges();
}
public async Task<IReadOnlyCollection<UserXpStats>> GetUserXps(ulong guildId, int page)
public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, int page)
{
await using var uow = _db.GetDbContext();
return await uow.Set<UserXpStats>().GetUsersFor(guildId, page);
return await uow
.UserXpStats
.Where(x => x.GuildId == guildId)
.OrderByDescending(x => x.Xp + x.AwardedXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task<IReadOnlyCollection<UserXpStats>> GetTopUserXps(ulong guildId, int count)
public async Task<IReadOnlyCollection<UserXpStats>> GetGuildUserXps(ulong guildId, List<ulong> users, int page)
{
await using var uow = _db.GetDbContext();
return await uow.Set<UserXpStats>().GetTopUserXps(guildId, count);
return await uow.Set<UserXpStats>()
.Where(x => x.GuildId == guildId && x.UserId.In(users))
.OrderByDescending(x => x.Xp + x.AwardedXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public Task<IReadOnlyCollection<DiscordUser>> GetUserXps(int page, int perPage = 9)
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page)
{
using var uow = _db.GetDbContext();
return uow.Set<DiscordUser>()
.GetUsersXpLeaderboardFor(page, perPage);
await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>()
.OrderByDescending(x => x.TotalXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task<IReadOnlyCollection<DiscordUser>> GetGlobalUserXps(int page, List<ulong> users)
{
await using var uow = _db.GetDbContext();
return await uow.GetTable<DiscordUser>()
.Where(x => x.UserId.In(users))
.OrderByDescending(x => x.TotalXp)
.Skip(page * 10)
.Take(10)
.ToArrayAsyncLinqToDB();
}
public async Task ChangeNotificationType(ulong userId, ulong guildId, XpNotificationLocation type)

View File

@@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
<Version>5.1.12</Version>
<Version>5.1.16</Version>
<!-- Output/build -->
<RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory>

View File

@@ -2,21 +2,31 @@
using Grpc.Core;
using NadekoBot.Db.Models;
using NadekoBot.Modules.NadekoExpressions;
using NadekoBot.Modules.Utility;
namespace NadekoBot.GrpcApi;
public class ExprsSvc : GrpcExprs.GrpcExprsBase, INService
public class ExprsSvc : GrpcExprs.GrpcExprsBase, IGrpcSvc, INService
{
private readonly NadekoExpressionsService _svc;
private readonly IQuoteService _qs;
private readonly DiscordSocketClient _client;
public ExprsSvc(NadekoExpressionsService svc)
public ExprsSvc(NadekoExpressionsService svc, IQuoteService qs, DiscordSocketClient client)
{
_svc = svc;
_qs = qs;
_client = client;
}
[GrpcApiPerm(GuildPerm.Administrator)]
public ServerServiceDefinition Bind()
=> GrpcExprs.BindService(this);
public override async Task<AddExprReply> AddExpr(AddExprRequest request, ServerCallContext context)
{
if (string.IsNullOrWhiteSpace(request.Expr.Trigger) || string.IsNullOrWhiteSpace(request.Expr.Response))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Trigger and response are required"));
NadekoExpression expr;
if (!string.IsNullOrWhiteSpace(request.Expr.Id))
{
@@ -45,7 +55,6 @@ public class ExprsSvc : GrpcExprs.GrpcExprsBase, INService
};
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<GetExprsReply> GetExprs(GetExprsRequest request, ServerCallContext context)
{
var (exprs, totalCount) = await _svc.FindExpressionsAsync(request.GuildId, request.Query, request.Page);
@@ -66,11 +75,75 @@ public class ExprsSvc : GrpcExprs.GrpcExprsBase, INService
return reply;
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<Empty> DeleteExpr(DeleteExprRequest request, ServerCallContext context)
{
await _svc.DeleteAsync(request.GuildId, new kwum(request.Id));
if (kwum.TryParse(request.Id, out var id))
await _svc.DeleteAsync(request.GuildId, id);
return new Empty();
}
public override async Task<GetQuotesReply> GetQuotes(GetQuotesRequest request, ServerCallContext context)
{
if (request.Page < 0)
throw new RpcException(new Status(StatusCode.InvalidArgument, "Page must be >= 0"));
var (quotes, totalCount) = await _qs.FindQuotesAsync(request.GuildId, request.Query, request.Page);
var reply = new GetQuotesReply();
reply.TotalCount = totalCount;
reply.Quotes.AddRange(quotes.Select(x => new QuoteDto()
{
Id = new kwum(x.Id).ToString(),
Trigger = x.Keyword,
Response = x.Text,
AuthorId = x.AuthorId,
AuthorName = x.AuthorName
}));
return reply;
}
public override async Task<AddQuoteReply> AddQuote(AddQuoteRequest request, ServerCallContext context)
{
var userId = context.RequestHeaders.GetUserId();
if (string.IsNullOrWhiteSpace(request.Quote.Trigger) || string.IsNullOrWhiteSpace(request.Quote.Response))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Trigger and response are required"));
if (string.IsNullOrWhiteSpace(request.Quote.Id))
{
var q = await _qs.AddQuoteAsync(request.GuildId,
userId,
(await _client.GetUserAsync(userId))?.Username ?? userId.ToString(),
request.Quote.Trigger,
request.Quote.Response);
return new()
{
Id = new kwum(q.Id).ToString()
};
}
if (!kwum.TryParse(request.Quote.Id, out var qid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid quote id"));
await _qs.EditQuoteAsync(
request.GuildId,
new kwum(request.Quote.Id),
request.Quote.Trigger,
request.Quote.Response);
return new()
{
Id = new kwum(qid).ToString()
};
}
public override async Task<Empty> DeleteQuote(DeleteQuoteRequest request, ServerCallContext context)
{
await _qs.DeleteQuoteAsync(request.GuildId, context.RequestHeaders.GetUserId(), true, new kwum(request.Id));
return new Empty();
}
}

View File

@@ -3,7 +3,7 @@ using GreetType = NadekoBot.Services.GreetType;
namespace NadekoBot.GrpcApi;
public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, INService
public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, IGrpcSvc, INService
{
private readonly GreetService _gs;
private readonly DiscordSocketClient _client;
@@ -14,11 +14,8 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, INService
_client = client;
}
public GreetSettings GetDefaultGreet(GreetType type)
=> new GreetSettings()
{
GreetType = type
};
public ServerServiceDefinition Bind()
=> GrpcGreet.BindService(this);
private static GrpcGreetSettings ToConf(GreetSettings? conf)
{
@@ -29,40 +26,37 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, INService
{
Message = conf.MessageText,
Type = (GrpcGreetType)conf.GreetType,
ChannelId = conf.ChannelId ?? 0,
ChannelId = conf.ChannelId?.ToString() ?? string.Empty,
IsEnabled = conf.IsEnabled,
};
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<GetGreetReply> GetGreetSettings(GetGreetRequest request, ServerCallContext context)
public override async Task<GrpcGreetSettings> GetGreetSettings(GetGreetRequest request, ServerCallContext context)
{
var guildId = request.GuildId;
var greetConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Greet);
var byeConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Bye);
var boostConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.Boost);
var greetDmConf = await _gs.GetGreetSettingsAsync(guildId, GreetType.GreetDm);
// todo timer
var conf = await _gs.GetGreetSettingsAsync(guildId, (GreetType)request.Type);
return new GetGreetReply()
{
Greet = ToConf(greetConf),
Bye = ToConf(byeConf),
Boost = ToConf(boostConf),
GreetDm = ToConf(greetDmConf)
};
return ToConf(conf);
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<UpdateGreetReply> UpdateGreet(UpdateGreetRequest request, ServerCallContext context)
{
var gid = request.GuildId;
var s = request.Settings;
var msg = s.Message;
var type = GetGreetType(s.Type);
await _gs.SetMessage(gid, GetGreetType(s.Type), msg);
await _gs.SetGreet(gid, s.ChannelId, GetGreetType(s.Type), s.IsEnabled);
await _gs.SetGreet(gid, ulong.Parse(s.ChannelId), type, s.IsEnabled);
var settings = await _gs.GetGreetSettingsAsync(gid, type);
if (settings is null)
return new()
{
Success = false
};
return new()
{
@@ -70,7 +64,6 @@ public sealed class GreetByeSvc : GrpcGreet.GrpcGreetBase, INService
};
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override Task<TestGreetReply> TestGreet(TestGreetRequest request, ServerCallContext context)
=> TestGreet(request.GuildId, request.ChannelId, request.UserId, request.Type);

View File

@@ -0,0 +1,95 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Games;
using SixLabors.ImageSharp.PixelFormats;
namespace NadekoBot.GrpcApi;
public class NCanvasSvc : GrpcNCanvas.GrpcNCanvasBase, IGrpcSvc, INService
{
private readonly INCanvasService _nCanvas;
private readonly DiscordSocketClient _client;
public NCanvasSvc(INCanvasService nCanvas, DiscordSocketClient client)
{
_nCanvas = nCanvas;
_client = client;
}
public ServerServiceDefinition Bind()
=> GrpcNCanvas.BindService(this);
[GrpcNoAuthRequired]
public override async Task<CanvasReply> GetCanvas(Empty request, ServerCallContext context)
{
var pixels = await _nCanvas.GetCanvas();
var reply = new CanvasReply()
{
Width = _nCanvas.GetWidth(),
Height = _nCanvas.GetHeight()
};
reply.Pixels.AddRange(pixels);
return reply;
}
[GrpcNoAuthRequired]
public override async Task<GetPixelReply> GetPixel(GetPixelRequest request, ServerCallContext context)
{
var pixel = await _nCanvas.GetPixel(request.X, request.Y);
if (pixel is null)
throw new RpcException(new Status(StatusCode.NotFound, "Pixel not found"));
var reply = MapPixelToGrpcPixel(pixel);
return reply;
}
private GetPixelReply MapPixelToGrpcPixel(NCPixel pixel)
{
var reply = new GetPixelReply
{
Color = "#" + new Rgba32(pixel.Color).ToHex(),
PackedColor = pixel.Color,
Position = new kwum(pixel.Position).ToString(),
PositionX = pixel.Position % _nCanvas.GetWidth(),
PositionY = pixel.Position / _nCanvas.GetWidth(),
// Owner = await ((IDiscordClient)_client).GetUserAsync(pixel.OwnerId)?.ToString() ?? string.Empty,
// OwnerId = pixel.OwnerId.ToString(),
Price = pixel.Price,
Text = pixel.Text
};
return reply;
}
[GrpcNoAuthRequired]
public override async Task<SetPixelReply> SetPixel(SetPixelRequest request, ServerCallContext context)
{
if (!kwum.TryParse(request.Position, out var pos))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Position is invalid"));
if (!Rgba32.TryParseHex(request.Color, out var clr))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Color is invalid"));
var userId = context.RequestHeaders.GetUserId();
var result = await _nCanvas.SetPixel(pos, clr.PackedValue, request.Text, userId, request.Price);
var reply = new SetPixelReply()
{
Success = result == SetPixelResult.Success,
Error = result switch
{
SetPixelResult.Success => string.Empty,
SetPixelResult.InsufficientPayment => "You have to pay equal or more than the price.",
SetPixelResult.NotEnoughMoney => "You don't have enough currency. ",
SetPixelResult.InvalidInput =>
$"Invalid input. Position has to be >= 0 and < {_nCanvas.GetWidth()}x{_nCanvas.GetHeight()}",
_ => throw new ArgumentOutOfRangeException()
}
};
var pixel = await _nCanvas.GetPixel(pos);
if (pixel is not null)
reply.Pixel = MapPixelToGrpcPixel(pixel);
return reply;
}
}

View File

@@ -11,63 +11,69 @@ public static class GrpcApiExtensions
=> ulong.Parse(context.RequestHeaders.FirstOrDefault(x => x.Key == "userid")!.Value);
}
public sealed class OtherSvc : GrpcOther.GrpcOtherBase, INService
public sealed class OtherSvc : GrpcOther.GrpcOtherBase, IGrpcSvc, INService
{
private readonly IDiscordClient _client;
private readonly DiscordSocketClient _client;
private readonly XpService _xp;
private readonly ICurrencyService _cur;
private readonly WaifuService _waifus;
private readonly ICoordinator _coord;
private readonly IStatsService _stats;
private readonly CommandHandler _cmdHandler;
public OtherSvc(
DiscordSocketClient client,
XpService xp,
ICurrencyService cur,
WaifuService waifus,
ICoordinator coord,
IStatsService stats)
IStatsService stats,
CommandHandler cmdHandler)
{
_client = client;
_xp = xp;
_cur = cur;
_waifus = waifus;
_coord = coord;
_stats = stats;
_cmdHandler = cmdHandler;
}
public override async Task<GetGuildsReply> GetGuilds(Empty request, ServerCallContext context)
public ServerServiceDefinition Bind()
=> GrpcOther.BindService(this);
[GrpcNoAuthRequired]
public override Task<BotOnGuildReply> BotOnGuild(BotOnGuildRequest request, ServerCallContext context)
{
var guilds = await _client.GetGuildsAsync(CacheMode.CacheOnly);
var guild = _client.GetGuild(request.GuildId);
var reply = new GetGuildsReply();
var userId = context.GetUserId();
var toReturn = new List<IGuild>();
foreach (var g in guilds)
var reply = new BotOnGuildReply
{
var user = await g.GetUserAsync(userId, CacheMode.AllowDownload);
if (user.GuildPermissions.Has(GuildPermission.Administrator))
toReturn.Add(g);
}
Success = guild is not null
};
reply.Guilds.AddRange(toReturn
.Select(x => new GuildReply()
{
Id = x.Id,
Name = x.Name,
IconUrl = x.IconUrl
}));
return reply;
return Task.FromResult(reply);
}
public override Task<GetRolesReply> GetRoles(GetRolesRequest request, ServerCallContext context)
{
var g = _client.GetGuild(request.GuildId);
var roles = g?.Roles;
var reply = new GetRolesReply();
reply.Roles.AddRange(roles?.Select(x => new RoleReply()
{
Id = x.Id,
Name = x.Name,
Color = x.Color.ToString(),
IconUrl = x.GetIconUrl() ?? string.Empty,
})
?? new List<RoleReply>());
return Task.FromResult(reply);
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<GetTextChannelsReply> GetTextChannels(
GetTextChannelsRequest request,
ServerCallContext context)
{
var g = await _client.GetGuildAsync(request.GuildId);
IGuild g = _client.GetGuild(request.GuildId);
var reply = new GetTextChannelsReply();
var chs = await g.GetTextChannelsAsync();
@@ -81,21 +87,23 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, INService
return reply;
}
[GrpcNoAuthRequired]
public override async Task<CurrencyLbReply> GetCurrencyLb(GetLbRequest request, ServerCallContext context)
{
var users = await _cur.GetTopRichest(_client.CurrentUser.Id, request.Page, request.PerPage);
var reply = new CurrencyLbReply();
var entries = users.Select(async x =>
var entries = users.Select(x =>
{
var user = await _client.GetUserAsync(x.UserId, CacheMode.CacheOnly);
return new CurrencyLbEntryReply()
var user = _client.GetUser(x.UserId);
return Task.FromResult(new CurrencyLbEntryReply()
{
Amount = x.CurrencyAmount,
User = user?.ToString() ?? x.Username,
UserId = x.UserId,
Avatar = user?.RealAvatarUrl().ToString() ?? x.RealAvatarUrl()?.ToString()
};
});
});
reply.Entries.AddRange(await entries.WhenAll());
@@ -103,9 +111,10 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, INService
return reply;
}
[GrpcNoAuthRequired]
public override async Task<XpLbReply> GetXpLb(GetLbRequest request, ServerCallContext context)
{
var users = await _xp.GetUserXps(request.Page, request.PerPage);
var users = await _xp.GetGlobalUserXps(request.Page);
var reply = new XpLbReply();
@@ -127,6 +136,7 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, INService
return reply;
}
[GrpcNoAuthRequired]
public override async Task<WaifuLbReply> GetWaifuLb(GetLbRequest request, ServerCallContext context)
{
var waifus = await _waifus.GetTopWaifusAtPage(request.Page, request.PerPage);
@@ -134,33 +144,77 @@ public sealed class OtherSvc : GrpcOther.GrpcOtherBase, INService
var reply = new WaifuLbReply();
reply.Entries.AddRange(waifus.Select(x => new WaifuLbEntry()
{
ClaimedBy = x.Claimer ?? string.Empty,
IsMutual = x.Claimer == x.Affinity,
ClaimedBy = x.ClaimerName ?? string.Empty,
IsMutual = x.ClaimerName == x.Affinity,
Value = x.Price,
User = x.Username,
User = x.WaifuName,
}));
return reply;
}
public override Task<GetShardStatusesReply> GetShardStatuses(Empty request, ServerCallContext context)
[GrpcNoAuthRequired]
public override async Task GetShardStats(
Empty request,
IServerStreamWriter<ShardStatsReply> responseStream,
ServerCallContext context)
{
var reply = new GetShardStatusesReply();
// todo cache
var shards = _coord.GetAllShardStatuses();
reply.Shards.AddRange(shards.Select(x => new ShardStatusReply()
while (true)
{
Id = x.ShardId,
Status = x.ConnectionState.ToString(),
GuildCount = x.GuildCount,
LastUpdate = Timestamp.FromDateTime(x.LastUpdate),
}));
var stats = new ShardStatsReply()
{
Id = _client.ShardId,
Commands = _stats.CommandsRan,
Uptime = _stats.GetUptimeString(),
Status = GetConnectionState(_client.ConnectionState),
GuildCount = _client.Guilds.Count,
};
return Task.FromResult(reply);
await responseStream.WriteAsync(stats);
await Task.Delay(1000);
}
}
[GrpcNoAuthRequired]
public override async Task GetCommandFeed(
Empty request,
IServerStreamWriter<CommandFeedEntry> responseStream,
ServerCallContext context)
{
var taskCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Task OnCommandExecuted(IUserMessage userMessage, CommandInfo commandInfo)
{
try
{
responseStream.WriteAsync(new()
{
Command = commandInfo.Name
});
}
catch
{
_cmdHandler.CommandExecuted -= OnCommandExecuted;
taskCompletion.TrySetResult(true);
}
return Task.CompletedTask;
}
_cmdHandler.CommandExecuted += OnCommandExecuted;
await taskCompletion.Task;
}
private string GetConnectionState(ConnectionState clientConnectionState)
{
return clientConnectionState switch
{
ConnectionState.Connected => "Connected",
ConnectionState.Connecting => "Connecting",
_ => "Disconnected"
};
}
[GrpcApiPerm(GuildPerm.Administrator)]
public override async Task<GetServerInfoReply> GetServerInfo(ServerInfoRequest request, ServerCallContext context)
{
var info = await _stats.GetGuildInfoAsync(request.GuildId);

View File

@@ -0,0 +1,224 @@
using Grpc.Core;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Administration.Services;
using Enum = System.Enum;
namespace NadekoBot.GrpcApi;
public sealed class WarnSvc : GrpcWarn.GrpcWarnBase, IGrpcSvc, INService
{
private readonly UserPunishService _ups;
private readonly DiscordSocketClient _client;
public WarnSvc(UserPunishService ups, DiscordSocketClient client)
{
_ups = ups;
_client = client;
}
public ServerServiceDefinition Bind()
=> GrpcWarn.BindService(this);
public override async Task<WarnSettingsReply> GetWarnSettings(
WarnSettingsRequest request,
ServerCallContext context)
{
var list = await _ups.WarnPunishList(request.GuildId);
var wsr = new WarnSettingsReply();
(wsr.ExpiryDays, wsr.DeleteOnExpire) = await _ups.GetWarnExpire(request.GuildId);
wsr.Punishments.AddRange(list.Select(x => new WarnPunishment()
{
Action = x.Punishment.ToString(),
Duration = x.Time,
Threshold = x.Count,
Role = x.RoleId is ulong rid
? _client.GetGuild(request.GuildId)?.GetRole(rid)?.Name ?? x.RoleId?.ToString() ?? string.Empty
: string.Empty
}));
return wsr;
}
public override async Task<SetWarnExpiryReply> SetWarnExpiry(
SetWarnExpiryRequest request,
ServerCallContext context)
{
if (request.ExpiryDays > 366)
{
return new SetWarnExpiryReply()
{
Success = false
};
}
await _ups.WarnExpireAsync(request.GuildId, request.ExpiryDays, request.DeleteOnExpire);
return new SetWarnExpiryReply()
{
Success = true
};
}
public override async Task<DeleteWarnpReply> DeleteWarnp(DeleteWarnpRequest request, ServerCallContext context)
{
var succ = await _ups.WarnPunishRemove(request.GuildId, request.Threshold);
return new DeleteWarnpReply
{
Success = succ
};
}
public override async Task<AddWarnpReply> AddWarnp(AddWarnpRequest request, ServerCallContext context)
{
if (request.Punishment.Threshold <= 0)
throw new RpcException(new Status(StatusCode.InvalidArgument, "Threshold must be greater than 0"));
var g = _client.GetGuild(request.GuildId);
if (g is null)
throw new RpcException(new Status(StatusCode.NotFound, "Guild not found"));
if (!Enum.TryParse<PunishmentAction>(request.Punishment.Action, out var action))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid action"));
IRole? role = null;
if (action == PunishmentAction.AddRole && ulong.TryParse(request.Punishment.Role, out var roleId))
{
role = g.GetRole(roleId);
if (role is null)
return new AddWarnpReply()
{
Success = false
};
if(!ulong.TryParse(context.RequestHeaders.GetValue("userid"), out var userId))
return new AddWarnpReply()
{
Success = false
};
var user = await ((IGuild)g).GetUserAsync(userId);
if (user is null)
throw new RpcException(new Status(StatusCode.NotFound, "User not found"));
var userMaxRole = user.GetRoles().MaxBy(x => x.Position)?.Position ?? 0;
if (g.OwnerId != user.Id && userMaxRole <= role.Position)
{
return new AddWarnpReply()
{
Success = false
};
}
}
var duration = TimeSpan.FromMinutes(request.Punishment.Duration);
var succ = await _ups.WarnPunish(request.GuildId,
request.Punishment.Threshold,
action,
duration,
role
);
return new AddWarnpReply()
{
Success = succ
};
}
public override async Task<GetLatestWarningsReply> GetLatestWarnings(
GetLatestWarningsRequest request,
ServerCallContext context)
{
var (latest, count) = await _ups.GetLatestWarnings(request.GuildId, request.Page);
var reply = new GetLatestWarningsReply()
{
TotalCount = count
};
reply.Warnings.AddRange(latest.Select(MapWarningToGrpcWarning));
return reply;
}
public override async Task<GetUserWarningsReply> GetUserWarnings(
GetUserWarningsRequest request,
ServerCallContext context)
{
IReadOnlyCollection<Db.Models.Warning> latest = [];
var count = 0;
if (ulong.TryParse(request.User, out var userId))
{
(latest, count) = await _ups.GetUserWarnings(request.GuildId, userId, request.Page);
}
else if (_client.GetGuild(request.GuildId)?.Users.FirstOrDefault(x => x.Username == request.User) is { } user)
{
(latest, count) = await _ups.GetUserWarnings(request.GuildId, user.Id, request.Page);
}
else
{
}
var reply = new GetUserWarningsReply
{
TotalCount = count
};
reply.Warnings.AddRange(latest.Select(MapWarningToGrpcWarning));
return reply;
}
private Warning MapWarningToGrpcWarning(Db.Models.Warning x)
{
return new Warning
{
Id = new kwum(x.Id).ToString(),
Forgiven = x.Forgiven,
ForgivenBy = x.ForgivenBy ?? string.Empty,
Reason = x.Reason ?? string.Empty,
Timestamp = x.DateAdded is { } da ? Nadeko.Common.Extensions.ToTimestamp(da) : 0,
Weight = x.Weight,
Moderator = x.Moderator ?? string.Empty,
User = _client.GetUser(x.UserId)?.Username ?? x.UserId.ToString(),
UserId = x.UserId
};
}
public override async Task<ForgiveWarningReply> ForgiveWarning(
ForgiveWarningRequest request,
ServerCallContext context)
{
if (!kwum.TryParse(request.WarnId, out var wid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid warning ID"));
var succ = await _ups.ForgiveWarning(request.GuildId, wid, request.ModName);
return new ForgiveWarningReply
{
Success = succ
};
}
public override async Task<ForgiveWarningReply> DeleteWarning(
ForgiveWarningRequest request,
ServerCallContext context)
{
if (!kwum.TryParse(request.WarnId, out var wid))
throw new RpcException(new Status(StatusCode.InvalidArgument, "Invalid warning ID"));
var succ = await _ups.WarnDelete(request.GuildId, wid);
return new ForgiveWarningReply
{
Success = succ
};
}
}

View File

@@ -0,0 +1,119 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace NadekoBot.GrpcApi;
public sealed partial class GrpcApiPermsInterceptor : Interceptor
{
private const GuildPerm DEFAULT_PERMISSION = GuildPermission.Administrator;
private readonly DiscordSocketClient _client;
public GrpcApiPermsInterceptor(DiscordSocketClient client)
{
_client = client;
}
private async Task RequestHandler(ServerCallContext context)
{
try
{
var method = context.Method[(context.Method.LastIndexOf('/') + 1)..];
// get metadata
var metadata = context
.RequestHeaders
.ToDictionary(x => x.Key, x => x.Value);
Log.Information("grpc | g: {GuildId} | u: {UserID} | cmd: {Method}",
metadata.TryGetValue("guildid", out var gidString) ? gidString : "none",
metadata.TryGetValue("userid", out var uidString) ? uidString : "none",
method);
// there always has to be a user who makes the call
if (!metadata.ContainsKey("userid"))
throw new RpcException(new(StatusCode.Unauthenticated, "userid has to be specified."));
// get the method name without the service name
// if the method is explicitly marked as not requiring auth
if (_noAuthRequired.Contains(method))
return;
// otherwise the method requires auth, and if it requires auth then the guildid has to be specified
if (string.IsNullOrWhiteSpace(gidString))
throw new RpcException(new(StatusCode.Unauthenticated, "guildid has to be specified."));
var userId = ulong.Parse(metadata["userid"]);
var guildId = ulong.Parse(gidString);
// check if the user has the required permission
if (_perms.TryGetValue(method, out var perm))
{
await EnsureUserHasPermission(guildId, userId, perm);
}
else
{
// if not then use the default, which is Administrator permission
await EnsureUserHasPermission(guildId, userId, DEFAULT_PERMISSION);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error thrown by {ContextMethod}", context.Method);
throw;
}
}
private async Task EnsureUserHasPermission(ulong guildId, ulong userId, GuildPerm perm)
{
IGuild guild = _client.GetGuild(guildId);
var user = guild is null ? null : await guild.GetUserAsync(userId);
if (user is null)
throw new RpcException(new Status(StatusCode.NotFound, "User not found"));
if (!user.GuildPermissions.Has(perm))
throw new RpcException(new Status(StatusCode.PermissionDenied,
$"You need {perm} permission to use this method"));
}
public override async Task<TResponse> ClientStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
ServerCallContext context,
ClientStreamingServerMethod<TRequest, TResponse> continuation)
{
await RequestHandler(context);
return await continuation(requestStream, context);
}
public override async Task DuplexStreamingServerHandler<TRequest, TResponse>(
IAsyncStreamReader<TRequest> requestStream,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
DuplexStreamingServerMethod<TRequest, TResponse> continuation)
{
await RequestHandler(context);
await continuation(requestStream, responseStream, context);
}
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
await RequestHandler(context);
await continuation(request, responseStream, context);
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
await RequestHandler(context);
return await continuation(request, context);
}
}

View File

@@ -9,29 +9,23 @@ public class GrpcApiService : INService, IReadyExecutor
private Server? _app;
private readonly DiscordSocketClient _client;
private readonly OtherSvc _other;
private readonly ExprsSvc _exprs;
private readonly GreetByeSvc _greet;
private readonly IEnumerable<IGrpcSvc> _svcs;
private readonly IBotCredsProvider _creds;
public GrpcApiService(
DiscordSocketClient client,
OtherSvc other,
ExprsSvc exprs,
GreetByeSvc greet,
IEnumerable<IGrpcSvc> svcs,
IBotCredsProvider creds)
{
_client = client;
_other = other;
_exprs = exprs;
_greet = greet;
_svcs = svcs;
_creds = creds;
}
public Task OnReadyAsync()
{
var creds = _creds.GetCreds();
if (creds.GrpcApi is null || creds.GrpcApi.Enabled)
if (creds.GrpcApi is null || !creds.GrpcApi.Enabled)
return Task.CompletedTask;
try
@@ -39,28 +33,40 @@ public class GrpcApiService : INService, IReadyExecutor
var host = creds.GrpcApi.Host;
var port = creds.GrpcApi.Port + _client.ShardId;
var interceptor = new PermsInterceptor(_client);
var interceptor = new GrpcApiPermsInterceptor(_client);
_app = new Server()
{
Services =
var serverCreds = ServerCredentials.Insecure;
if (creds.GrpcApi is
{
GrpcOther.BindService(_other).Intercept(interceptor),
GrpcExprs.BindService(_exprs).Intercept(interceptor),
GrpcGreet.BindService(_greet).Intercept(interceptor),
},
CertPrivateKey: not null and not "",
CertChain: not null and not ""
} cert)
{
serverCreds = new SslServerCredentials(
new[] { new KeyCertificatePair(cert.CertChain, cert.CertPrivateKey) });
}
_app = new()
{
Ports =
{
new(host, port, ServerCredentials.Insecure),
new(host, port, serverCreds),
}
};
foreach (var svc in _svcs)
{
_app.Services.Add(svc.Bind().Intercept(interceptor));
}
_app.Start();
Log.Information("Grpc Api Server started on port {Host}:{Port}", host, port);
}
catch
catch (Exception ex)
{
Log.Error(ex, "Error starting Grpc Api Server");
_app?.ShutdownAsync().GetAwaiter().GetResult();
}

View File

@@ -0,0 +1,8 @@
using Grpc.Core;
namespace NadekoBot.GrpcApi;
public interface IGrpcSvc
{
ServerServiceDefinition Bind();
}

View File

@@ -1,67 +0,0 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace NadekoBot.GrpcApi;
public sealed partial class PermsInterceptor : Interceptor
{
private readonly DiscordSocketClient _client;
public PermsInterceptor(DiscordSocketClient client)
{
_client = client;
Log.Information("interceptor created");
}
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
try
{
Log.Information("Starting receiving call. Type/Method: {Type} / {Method}",
MethodType.Unary,
context.Method);
// get metadata
var metadata = context
.RequestHeaders
.ToDictionary(x => x.Key, x => x.Value);
var method = context.Method[(context.Method.LastIndexOf('/') + 1)..];
if (perms.TryGetValue(method, out var perm))
{
Log.Information("Required permission for {Method} is {Perm}",
method,
perm);
var userId = ulong.Parse(metadata["userid"]);
var guildId = ulong.Parse(metadata["guildid"]);
IGuild guild = _client.GetGuild(guildId);
var user = guild is null ? null : await guild.GetUserAsync(userId);
if (user is null)
throw new RpcException(new Status(StatusCode.NotFound, "User not found"));
if (!user.GuildPermissions.Has(perm))
throw new RpcException(new Status(StatusCode.PermissionDenied,
$"You need {perm} permission to use this method"));
}
else
{
Log.Information("No permission required for {Method}", method);
}
return await continuation(request, context);
}
catch (Exception ex)
{
Log.Error(ex, "Error thrown by {ContextMethod}", context.Method);
throw;
}
}
}

View File

@@ -0,0 +1,9 @@
using Grpc.Core;
namespace NadekoBot.GrpcApi;
public static class SvcExtensions
{
public static ulong GetUserId(this Metadata meta)
=> ulong.Parse(meta.FirstOrDefault(x => x.Key == "userid")!.Value);
}

View File

@@ -76,6 +76,9 @@ public readonly struct kwum : IEquatable<kwum>
public override string ToString()
{
if (_value == 0)
return VALID_CHARACTERS[0].ToString();
var count = VALID_CHARACTERS.Length;
var localValue = _value;
var arrSize = (int)Math.Log(localValue, count) + 1;

View File

@@ -6,7 +6,7 @@ namespace NadekoBot.Common;
public sealed class Creds : IBotCreds
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 11;
public int Version { get; set; } = 13;
[Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")]
public string Token { get; set; }
@@ -164,7 +164,7 @@ public sealed class Creds : IBotCreds
public GrpcApiConfig GrpcApi { get; set; }
[Comment("""
Url to
Url and api key to a seq server. If url is set, bot will try to send logs to it.
""")]
public SeqConfig Seq { get; set; }
@@ -292,8 +292,8 @@ public sealed class Creds : IBotCreds
public sealed record GrpcApiConfig
{
public bool Enabled { get; set; } = false;
public string CertPath { get; set; } = string.Empty;
public string CertPassword { get; set; } = string.Empty;
public string CertChain { get; set; } = string.Empty;
public string CertPrivateKey { get; set; } = string.Empty;
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 43120;
}

View File

@@ -140,9 +140,9 @@ public sealed class BotCredsProvider : IBotCredsProvider
creds.BotCache = BotCacheImplemenation.Memory;
}
if (creds.Version < 11)
if (creds.Version < 13)
{
creds.Version = 11;
creds.Version = 13;
File.WriteAllText(CREDS_FILE_NAME, Yaml.Serializer.Serialize(creds));
}
}

View File

@@ -287,9 +287,9 @@ public sealed partial class ResponseBuilder
return this;
}
public ResponseBuilder UserBasedMentions()
public ResponseBuilder UserBasedMentions(IGuildUser? permUser = null)
{
sanitizeMentions = !((InternalResolveUser() as IGuildUser)?.GuildPermissions.MentionEveryone ?? false);
sanitizeMentions = !((InternalResolveUser() as IGuildUser ?? permUser)?.GuildPermissions.MentionEveryone ?? false);
return this;
}

View File

@@ -69,6 +69,7 @@ public abstract record SmartEmbedTextBase : SmartText
=> !string.IsNullOrWhiteSpace(Title)
|| !string.IsNullOrWhiteSpace(Description)
|| !string.IsNullOrWhiteSpace(Url)
|| !string.IsNullOrWhiteSpace(Author?.Name)
|| !string.IsNullOrWhiteSpace(Thumbnail)
|| !string.IsNullOrWhiteSpace(Image)
|| (Footer is not null

View File

@@ -52,4 +52,14 @@ public class StoopidTime
Time = ts
};
}
public static implicit operator TimeSpan(StoopidTime st)
=> st.Time;
public static implicit operator StoopidTime(TimeSpan ts)
=> new()
{
Input = ts.ToString(),
Time = ts
};
}

View File

@@ -365,7 +365,6 @@ quoteshow:
- qushow
quotesearch:
- quotesearch
- qs
- qse
- qsearch
quoteid:
@@ -1425,4 +1424,24 @@ afk:
keep:
- keep
leaveunkeptservers:
- leaveunkeptservers
- leaveunkeptservers
ncanvas:
- ncanvas
- nc
- ncanv
ncsetimg:
- ncsetimg
- ncsi
ncsetpixel:
- ncsetpixel
- ncsp
- ncs
nczoom:
- nczoom
- ncz
ncpixel:
- ncpixel
- ncp
- ncgp
ncreset:
- ncreset

View File

@@ -15,7 +15,7 @@ color:
pending: faa61a
# Default bot language. It has to be in the list of supported languages (.langli)
defaultLocale: en-US
# Style in which executed commands will show up in the console.
# Style in which executed commands will show up in the logs.
# Allowed values: Simple, Normal, None
consoleOutputType: Normal
# Whether the bot will check for new releases every hour

View File

@@ -394,6 +394,21 @@
"Administrator Server Permission"
]
},
{
"Aliases": [
".leaveunkeptservers"
],
"Description": "Leaves all servers whose owners didn't run .keep",
"Usage": [
".leaveunkeptservers"
],
"Submodule": "CleanupCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"Bot Owner Only"
]
},
{
"Aliases": [
".sqlselect"
@@ -586,13 +601,11 @@
},
{
"Aliases": [
".greetdel",
".grdel"
".greet"
],
"Description": "Sets the time it takes (in seconds) for greet messages to be auto-deleted. Set it to `0` to disable automatic deletion.",
"Description": "Toggles announcements on the current channel when someone joins the server.",
"Usage": [
".greetdel 0",
".greetdel 30"
".greet"
],
"Submodule": "GreetCommands",
"Module": "Administration",
@@ -603,11 +616,13 @@
},
{
"Aliases": [
".greet"
".greetdel",
".grdel"
],
"Description": "Toggles announcements on the current channel when someone joins the server.",
"Description": "Sets the time it takes (in seconds) for greet messages to be auto-deleted. Set it to `0` to disable automatic deletion.",
"Usage": [
".greet"
".greetdel 0",
".greetdel 30"
],
"Submodule": "GreetCommands",
"Module": "Administration",
@@ -676,21 +691,6 @@
"ManageServer Server Permission"
]
},
{
"Aliases": [
".byemsg"
],
"Description": "Sets a new leave announcement message which will be shown in the current channel. \nUsing this command with no message will show the current bye message. \nSupports [placeholders](https://docs.nadeko.bot/en/latest/placeholders/) and [embeds](https://eb.nadeko.bot/)",
"Usage": [
".byemsg %user.name% has left."
],
"Submodule": "GreetCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"ManageServer Server Permission"
]
},
{
"Aliases": [
".byedel"
@@ -709,12 +709,11 @@
},
{
"Aliases": [
".byetest"
".byemsg"
],
"Description": "Sends the bye message in the current channel as if you just left the server. You can optionally specify a different user.",
"Description": "Sets a new leave announcement message which will be shown in the current channel. \nUsing this command with no message will show the current bye message. \nSupports [placeholders](https://docs.nadeko.bot/en/latest/placeholders/) and [embeds](https://eb.nadeko.bot/)",
"Usage": [
".byetest",
".byetest @SomeoneElse"
".byemsg %user.name% has left."
],
"Submodule": "GreetCommands",
"Module": "Administration",
@@ -755,6 +754,22 @@
"ManageServer Server Permission"
]
},
{
"Aliases": [
".byetest"
],
"Description": "Sends the bye message in the current channel as if you just left the server. You can optionally specify a different user.",
"Usage": [
".byetest",
".byetest @SomeoneElse"
],
"Submodule": "GreetCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"ManageServer Server Permission"
]
},
{
"Aliases": [
".boosttest"
@@ -1090,7 +1105,7 @@
"Aliases": [
".antialt"
],
"Description": "Applies a punishment action to any user whose account is younger than the specified threshold. Specify time after the punishment to have a timed punishment (not all punishments support timers).",
"Description": "Applies a punishment action to any user whose account is younger than the specified threshold. \nAvailable Punishments are: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut\nYou can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h.\nMax message count is 10.\nProvide no parameters to disable.",
"Usage": [
".antialt 1h Ban",
".antialt 3d Mute 1h"
@@ -1106,7 +1121,7 @@
"Aliases": [
".antiraid"
],
"Description": "Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut",
"Description": "Sets an anti-raid protection on the server.\n\nFirst parameter is number of people which will trigger the protection.\n\nSecond parameter is a time interval in which that number of people needs to join in order to trigger the protection.\n\nThird parameter is punishment for those people.\nAvailable punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut\nYou can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h.\n\nProvide no parameters to disable.",
"Usage": [
".antiraid 5 20 Kick",
".antiraid 7 9 Ban",
@@ -1124,7 +1139,7 @@
"Aliases": [
".antispam"
],
"Description": "Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles, Warn, TimeOut",
"Description": "Applies a Punishment to people who repeat the same message X times in a row.\nAvailable Punishments are: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut\nYou can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h.\nMax message count is 10. \nProvide no parameters to disable.",
"Usage": [
".antispam 3 Mute",
".antispam 5 Ban",
@@ -1939,13 +1954,14 @@
},
{
"Aliases": [
".setactivity",
".setgame"
],
"Description": "Sets the bots game status to either Playing, Listening, or Watching.",
"Description": "Sets the bots game status to a Custom, Playing, Listening, or Watching status.",
"Usage": [
".setgame Playing with snakes.",
".setgame Watching anime.",
".setgame Listening music."
".setactivity Just chilling",
".setactivity Playing with snakes",
".setactivity Listening music"
],
"Submodule": "SelfCommands",
"Module": "Administration",
@@ -3674,7 +3690,7 @@
"Aliases": [
".choose"
],
"Description": "Chooses a thing from a list of things",
"Description": "Chooses a thing from a list of things. Separate items with a semicolon ;",
"Usage": [
".choose Get up;Sleep;Sleep more"
],
@@ -3772,6 +3788,98 @@
"Options": null,
"Requirements": []
},
{
"Aliases": [
".ncanvas",
".nc",
".ncanv"
],
"Description": "Shows the current nCanvas.\nThe canvas allows users to set each pixel's color and text using currency.",
"Usage": [
".ncanvas"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".nczoom",
".ncz"
],
"Description": "Zooms in on the nCanvas.\nBot will show the 10x10 grid with the position of each cell for use with `ncset`.\nYou can either use alphanumeric position (ex. s4u) or pixel x and y (ex. 123 123)",
"Usage": [
".nczoom sgu",
".nczoom 123 123"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".ncsetpixel",
".ncsp",
".ncs"
],
"Description": "Sets a pixel's color and text on the nCanvas.\nYou must specify the position of the pixel to set in alphanumeric format.\nYou can obtain alphanumeric position of the pixel by using `nczoom` or `ncp <x> <y>`",
"Usage": [
".ncsetpixel sgu #ff0000 Some text"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".ncpixel",
".ncp",
".ncgp"
],
"Description": "Shows the pixel at the specified position.\nYou can get pixel positions by using `nczoom`",
"Usage": [
".ncpixel sgu",
".ncpixel 123 123"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".ncsetimg",
".ncsi"
],
"Description": "Attach the image to the message sending the command to overwrite the nCanvas with it.\nAll prices and colors will be reset.\nThe image must be equal to the size of the nCanvas (default is 500x350)\nThis command is dangerous and irreversible.",
"Usage": [
".ncsetimg"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": [
"Bot Owner Only"
]
},
{
"Aliases": [
".ncreset"
],
"Description": "Clears the nCanvas.\nAll prices and colors will be reset.\nThis command is dangerous and irreversible.",
"Usage": [
".ncreset"
],
"Submodule": "NCanvasCommands",
"Module": "Games",
"Options": null,
"Requirements": [
"Bot Owner Only"
]
},
{
"Aliases": [
".nunchi"
@@ -5171,7 +5279,7 @@
],
"Description": "Toggles whether a module can be used on any server.",
"Usage": [
".globalmodule nsfw"
".globalmodule Gambling"
],
"Submodule": "GlobalPermissionCommands",
"Module": "Permissions",
@@ -6719,13 +6827,15 @@
},
{
"Aliases": [
".listquotes",
".liqu"
".quotelist",
".qli",
".quli",
".qulist"
],
"Description": "Lists all quotes on the server ordered alphabetically or by ID. 15 Per page.",
"Usage": [
".listquotes 3",
".listquotes 3 id"
".quotelist 3",
".quotelist 3 id"
],
"Submodule": "QuoteCommands",
"Module": "Utility",
@@ -6735,7 +6845,10 @@
{
"Aliases": [
".quoteprint",
"..."
".qp",
".qup",
"...",
".qprint"
],
"Description": "Prints a random quote with a specified name.",
"Usage": [
@@ -6749,7 +6862,9 @@
{
"Aliases": [
".quoteshow",
".qshow"
".qsh",
".qshow",
".qushow"
],
"Description": "Shows information about a quote with the specified ID.",
"Usage": [
@@ -6763,6 +6878,7 @@
{
"Aliases": [
".quotesearch",
".qse",
".qsearch"
],
"Description": "Shows a random quote given a search query. Partially matches in several ways: 1) Only content of any quote, 2) only by author, 3) keyword and content, 3) or keyword and author",
@@ -6782,7 +6898,7 @@
".quoteid",
".qid"
],
"Description": "Displays the quote with the specified ID number. Quote ID numbers can be found by typing `.liqu [num]` where `[num]` is a number of a page which contains 15 quotes.",
"Description": "-| Displays the quote with the specified ID number.",
"Usage": [
".quoteid 123456"
],
@@ -6794,6 +6910,9 @@
{
"Aliases": [
".quoteadd",
".qa",
".qadd",
".quadd",
".."
],
"Description": "Adds a new quote with the specified name and message.",
@@ -6808,6 +6927,8 @@
{
"Aliases": [
".quoteedit",
".qe",
".que",
".qedit"
],
"Description": "Edits a quote with the specified ID.",
@@ -6822,7 +6943,9 @@
{
"Aliases": [
".quotedelete",
".qdel"
".qd",
".qdel",
".qdelete"
],
"Description": "Deletes a quote with the specified ID. You have to either have the Manage Messages permission or be the creator of the quote to delete it.",
"Usage": [
@@ -6836,6 +6959,7 @@
{
"Aliases": [
".quotedeleteauthor",
".qda",
".qdelauth"
],
"Description": "Deletes all quotes by the specified author. If the author is not you, then ManageMessage server permission is required.",
@@ -6849,13 +6973,13 @@
},
{
"Aliases": [
".delallquotes",
".daq",
".delallq"
".quotesdeleteall",
".qdall",
".qdeleteall"
],
"Description": "Deletes all quotes on a specified keyword.",
"Description": "Deletes all quotes with the specified keyword.",
"Usage": [
".delallquotes kek"
".quotesdeleteall kek"
],
"Submodule": "QuoteCommands",
"Module": "Utility",
@@ -6867,6 +6991,7 @@
{
"Aliases": [
".quotesexport",
".qex",
".qexport"
],
"Description": "Exports quotes from the current server into a .yml file",
@@ -6883,6 +7008,8 @@
{
"Aliases": [
".quotesimport",
".qim",
".qimp",
".qimport"
],
"Description": "Upload the file or send the raw .yml data with this command to import all quotes from the specified string or file into the current server.",

View File

@@ -1,5 +1,4 @@
# DO NOT CHANGE
version: 1
# List of medusae automatically loaded at startup
loaded:
- ngrpc
loaded: []

View File

@@ -4567,4 +4567,70 @@ leaveunkeptservers:
- shardId:
desc: "Shard id from which to start leaving unkept servers."
- delay:
desc: "Delay in miliseconds between leaves"
desc: "Delay in miliseconds between leaves"
ncanvas:
desc: |-
Shows the current nCanvas.
The canvas allows users to set each pixel's color and text using currency.
ex:
- ''
params:
- { }
nczoom:
desc: |-
Zooms in on the nCanvas.
Bot will show the 10x10 grid with the position of each cell for use with `ncset`.
You can either use alphanumeric position (ex. s4u) or pixel x and y (ex. 123 123)
ex:
- 'sgu'
- '123 123'
params:
- position:
desc: "The position of the pixel to set in alphanumeric format."
- position:
desc: "The position of the pixel to set in pixel x and y format."
ncsetpixel:
desc: |-
Sets a pixel's color and text on the nCanvas.
You must specify the position of the pixel to set in alphanumeric format.
You can obtain alphanumeric position of the pixel by using `nczoom` or `ncp <x> <y>`
ex:
- 'sgu #ff0000 Some text'
params:
- position:
desc: "The position of the pixel to set in alphanumeric format."
- color:
desc: "The color of the pixel to set in HEX."
- text:
desc: "The optional text to set on the pixel."
ncsetimg:
desc: |-
Attach the image to the message sending the command to overwrite the nCanvas with it.
All prices and colors will be reset.
The image must be equal to the size of the nCanvas (default is 500x350)
This command is dangerous and irreversible.
ex:
- ''
params:
- { }
ncpixel:
desc: |-
Shows the pixel at the specified position.
You can get pixel positions by using `nczoom`
ex:
- 'sgu'
- '123 123'
params:
- position:
desc: "The position of the pixel to retrieve in alphanumeric format."
- position:
desc: "The position of the pixel to retrieve in pixel x and y format."
ncreset:
desc: |-
Clears the nCanvas.
All prices and colors will be reset.
This command is dangerous and irreversible.
ex:
- ''
params:
- { }

View File

@@ -1101,5 +1101,14 @@
"honeypot_on": "Honeypot enabled on this channel." ,
"honeypot_off": "Honeypot disabled.",
"afk_set": "AFK message set. Type a message in any channel to clear.",
"rero_message_not_found": "The specified message wasn't found. Make sure you've specified the message from this channel."
"rero_message_not_found": "The specified message wasn't found. Make sure you've specified the message from this channel.",
"nc_pixel_not_found": "Pixel not found",
"nc_pixel": "Pixel {0}",
"nc_position": "Position",
"nc_pixel_set": "Pixel {0} successfully set!",
"invalid_color": "Color you've specified is invalid.",
"nc_pixel_set_confirm": "Are you sure you want to set pixel {0}? It will cost you {1}",
"nc_hint": "Use `{0}nczoom x y` command to zoom in. Image is {1}x{2} pixels.",
"invalid_img_size": "Image must to be {0}x{1} pixels.",
"no_attach_found": "No attachment found. Please send the image along with this command."
}