Compare commits

..

28 Commits

Author SHA1 Message Date
Kwoth
b34fd6da4e Upped version to 4.2.15 2022-07-12 03:58:41 +02:00
Kwoth
c9287dc166 Merge branch 'v4' of https://gitlab.com/kwoth/nadekobot into v4 2022-07-11 02:58:01 +02:00
Kwoth
7885106266 Bot should no longer always notify server level gains 2022-07-11 02:57:24 +02:00
Kwoth
8efdd3dffe Merge branch 'hokutochen-v4-patch-33031' into 'v4'
added redis as optional to install in windows guide docs

See merge request Kwoth/nadekobot!258
2022-07-09 22:51:37 +00:00
Hokuto Chen
fb9a7964df added redis as optional to install in windows guide docs 2022-07-09 22:51:36 +00:00
Kwoth
1396d9d55a Updated changelog 2022-07-07 22:42:51 +02:00
Kwoth
e7ddcebeab Merge branch 'v4' of https://gitlab.com/kwoth/nadekobot into v4 2022-07-07 22:08:57 +02:00
Kwoth
9d3a386f32 nsfw shouldn't be disabled on private bots by default anymore 2022-07-07 22:08:45 +02:00
Kwoth
83c9c372e4 Fixed a certain command, scraping as the api is closed 2022-07-07 22:08:22 +02:00
Kwoth
4bb4209c92 Merge branch 'inner-xp-loop-rewrite' into 'v4'
Rewrite xp gain loop to be faster

See merge request Kwoth/nadekobot!253
2022-07-07 17:09:11 +00:00
Kwoth
744018802f Rewrite xp gain loop. Hopefully faster and fixes xp freeze bugs. 2022-07-07 17:09:11 +00:00
Kwoth
470bb9657f Fixed .timely button for sure this time 2022-07-06 13:41:27 +02:00
Kwoth
2fb4bb2ea4 images.yml should once again support local file paths 2022-07-06 04:15:16 +02:00
Kwoth
43dd37c4f1 .die should now set to invisible, not dnd, but it doesn't seem to have an effect anyway 2022-07-06 03:03:03 +02:00
Kwoth
5fac500dcf remind me interaction on .timely should now work correctly 2022-07-06 03:00:57 +02:00
Kwoth
fd25f5bf45 Multiword aliases are once again supported 2022-07-05 18:08:11 +02:00
Kwoth
9d3e80eb32 Fixed commands.en-US.yml 2022-07-03 23:13:00 +02:00
Kwoth
42cbb7f626 Updated CHANGELOG.md, Upped version to 4.2.14 2022-07-03 23:00:12 +02:00
Kwoth
4d175477f5 Bot will now 'try' to set status to invisible before going offline when '.die' command is used, but it doesn't seem to have (much/any) effect. .qsearch is more powerful 2022-07-03 22:26:41 +02:00
Kwoth
643987c41f Added .log userwarned 2022-07-03 21:58:05 +02:00
Kwoth
03396642a4 Added ban reason to .log userbanned (and if you used nadeko to ban someone, it will also show the mod who did the ban because nadeko adds it to the reason) 2022-07-03 17:03:27 +02:00
Kwoth
3fd5f0c97a Added warn punishment action for protection commands 2022-07-03 14:55:31 +02:00
Kwoth
5d78f29329 Added %server.icon% placeholder 2022-07-03 13:47:41 +02:00
Kwoth
2a98aceae6 Fixed elipsis alias bug, closes #295 2022-07-02 15:03:24 +02:00
Kwoth
5c933b676d .timely will now have a button to set a reminder 2022-07-02 14:15:02 +02:00
Kwoth
2e4de7723e Fixed .cash bank interaction not being ephemeral 2022-06-30 22:09:26 +02:00
Kwoth
a8e00a19ba Upped version to 4.2.12 2022-06-30 11:33:45 +02:00
Kwoth
8acf6b1194 Fixed .trivia --pokemon showing pokemon with id + 1 2022-06-30 11:32:36 +02:00
49 changed files with 10860 additions and 468 deletions

View File

@@ -2,6 +2,45 @@
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [4.2.15] - 12.07.2022
### Fixed
- Fixed `.nh*ntai` nsfw command
- Xp Freezes may have been fixed
- `data/images.yml` should once again support local file paths
- Fixed multiword aliases
## [4.2.14] - 03.07.2022
### Added
- Added `.log userwarned` (Logging user warnings)
- Claiming `.timely` will now show a button which you can click to set a reminder
- Added `%server.icon%` placeholder
- Added `warn` punishment action for protection commands (it won't work with `.warnp`)
### Changed
- `.log userbanned` will now have a ban reason
- When `.die` is used, bot will try to update it's status to `Invisible`
### Fixed
- Fixed elipsis character issue with aliases/quotes. You should now be able to set an elipsis to be an alias of `.quoteprint`
## [4.2.13] - 30.06.2022
### Fixed
- Fixed `.cash` bank interaction not being ephemeral anymore
## [4.2.12] - 30.06.2022
### Fixed
- Fixed `.trivia --pokemon` showing incorrect pokemons
## [4.2.11] - 29.06.2022 ## [4.2.11] - 29.06.2022
### Fixed ### Fixed

View File

@@ -32,6 +32,7 @@
- Click on **`DOWNLOAD`** at the lower right - Click on **`DOWNLOAD`** at the lower right
![Bot Setup](https://i.imgur.com/HqAl36p.png "Bot Setup") ![Bot Setup](https://i.imgur.com/HqAl36p.png "Bot Setup")
- Click on **`Install`** next to **`Redis`**. - Click on **`Install`** next to **`Redis`**.
- **(Note: Redis is optional unless you are are using the bot on 2000+ servers)**
- Note: If Redis fails to install, install Redis manually here: [Redis Installer](https://github.com/MicrosoftArchive/redis/releases/tag/win-3.0.504) Download and run the **`.msi`** file. - Note: If Redis fails to install, install Redis manually here: [Redis Installer](https://github.com/MicrosoftArchive/redis/releases/tag/win-3.0.504) Download and run the **`.msi`** file.
- If you will use the music module, click on **`Install`** next to **`FFMPEG`** and **`Youtube-DL`**. - If you will use the music module, click on **`Install`** next to **`FFMPEG`** and **`Youtube-DL`**.
- If any dependencies fail to install, you can temporarily disable your Windows Defender/AV until you install them. If you don't want to, then read [the last section of this guide](#Manual-Prerequisite-Installation). - If any dependencies fail to install, you can temporarily disable your Windows Defender/AV until you install them. If you don't want to, then read [the last section of this guide](#Manual-Prerequisite-Installation).

View File

@@ -160,7 +160,8 @@ public class CmdAttribute : System.Attribute
var methodModels = methods var methodModels = methods
.Select(x => MethodDeclarationToMethodModel(compilation, x!)) .Select(x => MethodDeclarationToMethodModel(compilation, x!))
.Where(static x => x is not null); .Where(static x => x is not null)
.Cast<MethodModel>();
var groups = methodModels var groups = methodModels
.GroupBy(static x => $"{x.Namespace}.{string.Join(".", x.Classes)}"); .GroupBy(static x => $"{x.Namespace}.{string.Join(".", x.Classes)}");
@@ -180,7 +181,7 @@ public class CmdAttribute : System.Attribute
var model = new FileModel( var model = new FileModel(
methods: elems, methods: elems,
ns: elems[0].Namespace, ns: elems[0].Namespace,
classHierarchy: elems[0].Classes classHierarchy: elems![0].Classes
); );
models.Add(model); models.Add(model);

View File

@@ -70,6 +70,7 @@ public sealed class Bot
: GatewayIntents.AllUnprivileged, : GatewayIntents.AllUnprivileged,
LogGatewayIntentWarnings = false, LogGatewayIntentWarnings = false,
FormatUsersInBidirectionalUnicode = false, FormatUsersInBidirectionalUnicode = false,
DefaultRetryMode = RetryMode.AlwaysRetry ^ RetryMode.RetryRatelimit
}); });
_commandService = new(new() _commandService = new(new()
@@ -260,6 +261,7 @@ public sealed class Bot
Client.JoinedGuild += Client_JoinedGuild; Client.JoinedGuild += Client_JoinedGuild;
Client.LeftGuild += Client_LeftGuild; Client.LeftGuild += Client_LeftGuild;
// _ = Client.SetStatusAsync(UserStatus.Online);
Log.Information("Shard {ShardId} logged in", Client.ShardId); Log.Information("Shard {ShardId} logged in", Client.ShardId);
} }

View File

@@ -27,5 +27,6 @@ public enum LogType
UserPresence, UserPresence,
VoicePresence, VoicePresence,
VoicePresenceTts, VoicePresenceTts,
UserMuted UserMuted,
UserWarned,
} }

View File

@@ -67,13 +67,13 @@ public abstract class NadekoModule : ModuleBase
// localized replies // localized replies
public Task<IUserMessage> ReplyErrorLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null) public Task<IUserMessage> ReplyErrorLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null)
=> SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}"); => SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyPendingLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null) public Task<IUserMessage> ReplyPendingLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null)
=> SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}"); => SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyConfirmLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null) public Task<IUserMessage> ReplyConfirmLocalizedAsync(LocStr str, NadekoButtonInteraction inter = null)
=> SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}"); => SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public async Task<bool> PromptUserConfirmAsync(IEmbedBuilder embed) public async Task<bool> PromptUserConfirmAsync(IEmbedBuilder embed)
{ {

View File

@@ -57,6 +57,6 @@ public sealed class QueueRunner
} }
} }
public ValueTask Enqueue(Func<Task> action) public ValueTask EnqueueAsync(Func<Task> action)
=> _channel.Writer.WriteAsync(action); => _channel.Writer.WriteAsync(action);
} }

View File

@@ -55,6 +55,7 @@ public class ReplacementBuilder
_reps.TryAdd("%server%", () => g is null ? "DM" : g.Name); _reps.TryAdd("%server%", () => g is null ? "DM" : g.Name);
_reps.TryAdd("%server.id%", () => g is null ? "DM" : g.Id.ToString()); _reps.TryAdd("%server.id%", () => g is null ? "DM" : g.Id.ToString());
_reps.TryAdd("%server.name%", () => g is null ? "DM" : g.Name); _reps.TryAdd("%server.name%", () => g is null ? "DM" : g.Name);
_reps.TryAdd("%server.icon%", () => g is null ? null : g.IconUrl);
_reps.TryAdd("%server.members%", () => g is { } sg ? sg.MemberCount.ToString() : "?"); _reps.TryAdd("%server.members%", () => g is { } sg ? sg.MemberCount.ToString() : "?");
_reps.TryAdd("%server.boosters%", () => g.PremiumSubscriptionCount.ToString()); _reps.TryAdd("%server.boosters%", () => g.PremiumSubscriptionCount.ToString());
_reps.TryAdd("%server.boost_level%", () => ((int)g.PremiumTier).ToString()); _reps.TryAdd("%server.boost_level%", () => ((int)g.PremiumTier).ToString());

View File

@@ -44,12 +44,12 @@ public static class QuoteExtensions
var rngk = new NadekoRandom(); var rngk = new NadekoRandom();
return (await quotes.AsQueryable() return (await quotes.AsQueryable()
.Where(q => q.GuildId == guildId .Where(q => q.GuildId == guildId
&& q.Keyword == keyword && (keyword == null || q.Keyword == keyword)
&& EF.Functions.Like(q.Text.ToUpper(), $"%{text.ToUpper()}%") && (EF.Functions.Like(q.Text.ToUpper(), $"%{text.ToUpper()}%")
// && q.Text.Contains(text, StringComparison.OrdinalIgnoreCase) || EF.Functions.Like(q.AuthorName, text)))
) .ToListAsync())
.ToListAsync()).OrderBy(_ => rngk.Next()) .OrderBy(_ => rngk.Next())
.FirstOrDefault(); .FirstOrDefault();
} }
public static void RemoveAllByKeyword(this DbSet<Quote> quotes, ulong guildId, string keyword) public static void RemoveAllByKeyword(this DbSet<Quote> quotes, ulong guildId, string keyword)

View File

@@ -48,7 +48,8 @@ public enum PunishmentAction
RemoveRoles, RemoveRoles,
ChatMute, ChatMute,
VoiceMute, VoiceMute,
AddRole AddRole,
Warn
} }
public class AntiSpamIgnore : DbEntity public class AntiSpamIgnore : DbEntity

View File

@@ -29,4 +29,5 @@ public class LogSetting : DbEntity
public ulong? LogVoicePresenceId { get; set; } public ulong? LogVoicePresenceId { get; set; }
public ulong? LogVoicePresenceTTSId { get; set; } public ulong? LogVoicePresenceTTSId { get; set; }
public ulong? LogWarnsId { get; set; }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations.Mysql
{
public partial class logwarns : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<ulong>(
name: "logwarnsid",
table: "logsettings",
type: "bigint unsigned",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "logwarnsid",
table: "logsettings");
}
}
}

View File

@@ -1406,6 +1406,10 @@ namespace NadekoBot.Migrations.Mysql
.HasColumnType("bigint unsigned") .HasColumnType("bigint unsigned")
.HasColumnName("logvoicepresencettsid"); .HasColumnName("logvoicepresencettsid");
b.Property<ulong?>("LogWarnsId")
.HasColumnType("bigint unsigned")
.HasColumnName("logwarnsid");
b.Property<ulong?>("MessageDeletedId") b.Property<ulong?>("MessageDeletedId")
.HasColumnType("bigint unsigned") .HasColumnType("bigint unsigned")
.HasColumnName("messagedeletedid"); .HasColumnName("messagedeletedid");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
public partial class logwarns : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "logwarnsid",
table: "logsettings",
type: "numeric(20,0)",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "logwarnsid",
table: "logsettings");
}
}
}

View File

@@ -1472,6 +1472,10 @@ namespace NadekoBot.Migrations.PostgreSql
.HasColumnType("numeric(20,0)") .HasColumnType("numeric(20,0)")
.HasColumnName("logvoicepresencettsid"); .HasColumnName("logvoicepresencettsid");
b.Property<decimal?>("LogWarnsId")
.HasColumnType("numeric(20,0)")
.HasColumnName("logwarnsid");
b.Property<decimal?>("MessageDeletedId") b.Property<decimal?>("MessageDeletedId")
.HasColumnType("numeric(20,0)") .HasColumnType("numeric(20,0)")
.HasColumnName("messagedeletedid"); .HasColumnName("messagedeletedid");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
public partial class logwarns : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "StreamOnlineMessages",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT");
migrationBuilder.AddColumn<ulong>(
name: "LogWarnsId",
table: "LogSettings",
type: "INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LogWarnsId",
table: "LogSettings");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "StreamOnlineMessages",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
}
}
}

View File

@@ -278,7 +278,6 @@ namespace NadekoBot.Migrations
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("Type") b.Property<int>("Type")
@@ -1100,6 +1099,9 @@ namespace NadekoBot.Migrations
b.Property<ulong?>("LogVoicePresenceTTSId") b.Property<ulong?>("LogVoicePresenceTTSId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<ulong?>("LogWarnsId")
.HasColumnType("INTEGER");
b.Property<ulong?>("MessageDeletedId") b.Property<ulong?>("MessageDeletedId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

View File

@@ -335,6 +335,8 @@ public partial class Administration
{ {
try try
{ {
await _client.SetStatusAsync(UserStatus.Invisible);
_ = _client.StopAsync();
await ReplyConfirmLocalizedAsync(strs.shutting_down); await ReplyConfirmLocalizedAsync(strs.shutting_down);
} }
catch catch

View File

@@ -26,6 +26,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
private readonly IMemoryCache _memoryCache; private readonly IMemoryCache _memoryCache;
private readonly ConcurrentHashSet<ulong> _ignoreMessageIds = new(); private readonly ConcurrentHashSet<ulong> _ignoreMessageIds = new();
private readonly UserPunishService _punishService;
public LogCommandService( public LogCommandService(
DiscordSocketClient client, DiscordSocketClient client,
@@ -35,7 +36,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
ProtectionService prot, ProtectionService prot,
GuildTimezoneService tz, GuildTimezoneService tz,
IMemoryCache memoryCache, IMemoryCache memoryCache,
IEmbedBuilderService eb) IEmbedBuilderService eb,
UserPunishService punishService)
{ {
_client = client; _client = client;
_memoryCache = memoryCache; _memoryCache = memoryCache;
@@ -45,6 +47,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
_mute = mute; _mute = mute;
_prot = prot; _prot = prot;
_tz = tz; _tz = tz;
_punishService = punishService;
using (var uow = db.GetDbContext()) using (var uow = db.GetDbContext())
{ {
var guildIds = client.Guilds.Select(x => x.Id).ToList(); var guildIds = client.Guilds.Select(x => x.Id).ToList();
@@ -78,6 +82,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
_mute.UserUnmuted += MuteCommands_UserUnmuted; _mute.UserUnmuted += MuteCommands_UserUnmuted;
_prot.OnAntiProtectionTriggered += TriggeredAntiProtection; _prot.OnAntiProtectionTriggered += TriggeredAntiProtection;
_punishService.OnUserWarned += PunishServiceOnOnUserWarned;
} }
public async Task OnReadyAsync() public async Task OnReadyAsync()
@@ -183,6 +189,30 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
GuildLogSettings.AddOrUpdate(guildId, _ => logSetting, (_, _) => logSetting); GuildLogSettings.AddOrUpdate(guildId, _ => logSetting, (_, _) => logSetting);
} }
private async Task PunishServiceOnOnUserWarned(Warning arg)
{
if (!GuildLogSettings.TryGetValue(arg.GuildId, out var logSetting) || logSetting.LogWarnsId is null)
return;
var g = _client.GetGuild(arg.GuildId);
ITextChannel? logChannel;
if ((logChannel = await TryGetLogChannel(g, logSetting, LogType.UserWarned)) is null)
return;
var embed = _eb.Create()
.WithOkColor()
.WithTitle($"⚠️ User Warned")
.WithDescription($"<@{arg.UserId}> | {arg.UserId}")
.AddField("Mod", arg.Moderator)
.AddField("Reason", string.IsNullOrWhiteSpace(arg.Reason) ? "-" : arg.Reason, true)
.WithFooter(CurrentTime(g));
await logChannel.EmbedAsync(embed);
}
private Task _client_UserUpdated(SocketUser before, SocketUser uAfter) private Task _client_UserUpdated(SocketUser before, SocketUser uAfter)
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -296,6 +326,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
channelId = logSetting.LogVoicePresenceTTSId = channelId = logSetting.LogVoicePresenceTTSId =
logSetting.LogVoicePresenceTTSId is null ? cid : default; logSetting.LogVoicePresenceTTSId is null ? cid : default;
break; break;
case LogType.UserWarned:
channelId = logSetting.LogWarnsId = logSetting.LogWarnsId is null ? cid : default;
break;
} }
uow.SaveChanges(); uow.SaveChanges();
@@ -945,11 +978,25 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
ITextChannel? logChannel; ITextChannel? logChannel;
if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null) if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null)
return; return;
string? reason = null;
try
{
var ban = await guild.GetBanAsync(usr);
reason = ban?.Reason;
}
catch
{
}
var embed = _eb.Create() var embed = _eb.Create()
.WithOkColor() .WithOkColor()
.WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned)) .WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned))
.WithDescription(usr.ToString()!) .WithDescription(usr.ToString()!)
.AddField("Id", usr.Id.ToString()) .AddField("Id", usr.Id.ToString())
.AddField("Reason", string.IsNullOrWhiteSpace(reason) ? "-" : reason)
.WithFooter(CurrentTime(guild)); .WithFooter(CurrentTime(guild));
var avatarUrl = usr.GetAvatarUrl(); var avatarUrl = usr.GetAvatarUrl();
@@ -1130,6 +1177,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
case LogType.UserMuted: case LogType.UserMuted:
id = logSetting.UserMutedId; id = logSetting.UserMutedId;
break; break;
case LogType.UserWarned:
id = logSetting.LogWarnsId;
break;
} }
if (id is null or 0) if (id is null or 0)
@@ -1200,6 +1250,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
case LogType.VoicePresenceTts: case LogType.VoicePresenceTts:
newLogSetting.LogVoicePresenceTTSId = null; newLogSetting.LogVoicePresenceTTSId = null;
break; break;
case LogType.UserWarned:
newLogSetting.LogWarnsId = null;
break;
} }
GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting); GuildLogSettings.AddOrUpdate(guildId, newLogSetting, (_, _) => newLogSetting);

View File

@@ -341,7 +341,8 @@ public partial class Administration
public async partial Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null) public async partial Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null)
{ {
// this should never happen. Addrole has its own method with higher priority // this should never happen. Addrole has its own method with higher priority
if (punish == PunishmentAction.AddRole) // also disallow warn punishment for getting warned
if (punish is PunishmentAction.AddRole or PunishmentAction.Warn)
return; return;
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time); var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time);

View File

@@ -18,6 +18,8 @@ public class UserPunishService : INService, IReadyExecutor
private readonly BotConfigService _bcs; private readonly BotConfigService _bcs;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
public event Func<Warning, Task> OnUserWarned = static delegate { return Task.CompletedTask; };
public UserPunishService( public UserPunishService(
MuteService mute, MuteService mute,
DbService db, DbService db,
@@ -93,6 +95,8 @@ public class UserPunishService : INService, IReadyExecutor
await uow.SaveChangesAsync(); await uow.SaveChangesAsync();
} }
_ = OnUserWarned(warn);
var totalCount = previousCount + weight; var totalCount = previousCount + weight;
var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount) var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount)
@@ -185,6 +189,9 @@ public class UserPunishService : INService, IReadyExecutor
guild.Id); guild.Id);
} }
break;
case PunishmentAction.Warn:
await Warn(guild, user.Id, mod, 1, reason);
break; break;
} }
} }

View File

@@ -7,6 +7,7 @@ using NadekoBot.Modules.Utility.Patronage;
using NadekoBot.Modules.Gambling.Bank; using NadekoBot.Modules.Gambling.Bank;
using NadekoBot.Modules.Gambling.Common; using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services; using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Modules.Utility.Services;
using NadekoBot.Services.Currency; using NadekoBot.Services.Currency;
using NadekoBot.Services.Database.Models; using NadekoBot.Services.Database.Models;
using System.Collections.Immutable; using System.Collections.Immutable;
@@ -44,6 +45,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly GamblingConfigService _configService; private readonly GamblingConfigService _configService;
private readonly IBankService _bank; private readonly IBankService _bank;
private readonly IPatronageService _ps; private readonly IPatronageService _ps;
private readonly RemindService _remind;
private IUserMessage rdMsg; private IUserMessage rdMsg;
@@ -54,7 +56,8 @@ public partial class Gambling : GamblingModule<GamblingService>
DownloadTracker tracker, DownloadTracker tracker,
GamblingConfigService configService, GamblingConfigService configService,
IBankService bank, IBankService bank,
IPatronageService ps) IPatronageService ps,
RemindService remind)
: base(configService) : base(configService)
{ {
_db = db; _db = db;
@@ -62,6 +65,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_client = client; _client = client;
_bank = bank; _bank = bank;
_ps = ps; _ps = ps;
_remind = remind;
_enUsCulture = new CultureInfo("en-US", false).NumberFormat; _enUsCulture = new CultureInfo("en-US", false).NumberFormat;
_enUsCulture.NumberDecimalDigits = 0; _enUsCulture.NumberDecimalDigits = 0;
@@ -110,6 +114,39 @@ public partial class Gambling : GamblingModule<GamblingService>
PrettyName = "Timely" PrettyName = "Timely"
}; };
public class RemindMeInteraction : NInteraction
{
public RemindMeInteraction(
[NotNull] DiscordSocketClient client,
ulong userId,
[NotNull] Func<SocketMessageComponent, Task> action)
: base(client, userId, action)
{
}
protected override NadekoInteractionData Data
=> new NadekoInteractionData(
Emote: Emoji.Parse("⏰"),
CustomId: "timely:remind_me",
Text: "Remind me"
);
}
private Func<SocketMessageComponent, Task> RemindTimelyAction(DateTime when)
=> async smc =>
{
var tt = TimestampTag.FromDateTime(when, TimestampTagStyles.Relative);
await _remind.AddReminderAsync(ctx.User.Id,
ctx.User.Id,
ctx.Guild.Id,
true,
when,
GetText(strs.timely_time));
await smc.RespondConfirmAsync(_eb, GetText(strs.remind_timely(tt)), ephemeral: true);
};
[Cmd] [Cmd]
public async partial Task Timely() public async partial Task Timely()
{ {
@@ -135,7 +172,11 @@ public partial class Gambling : GamblingModule<GamblingService>
await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim")); await _cs.AddAsync(ctx.User.Id, val, new("timely", "claim"));
await ReplyConfirmLocalizedAsync(strs.timely(N(val), period)); var inter = new RemindMeInteraction(_client,
ctx.User.Id,
RemindTimelyAction(DateTime.UtcNow.Add(TimeSpan.FromHours(period))));
await ReplyConfirmLocalizedAsync(strs.timely(N(val), period), inter.GetInteraction());
} }
[Cmd] [Cmd]

View File

@@ -19,14 +19,14 @@ public class TriviaQuestionPool
{ {
var pokes = await _cache.GetPokemonMapAsync(); var pokes = await _cache.GetPokemonMapAsync();
if (pokes is null or { Length: 0 }) if (pokes is null or { Count: 0 })
return default; return default;
var num = _rng.Next(1, _maxPokemonId + 1); var num = _rng.Next(1, _maxPokemonId + 1);
return new(new() return new(new()
{ {
Question = "Who's That Pokémon?", Question = "Who's That Pokémon?",
Answer = pokes[num].Name.ToTitleCase(), Answer = pokes[num].ToTitleCase(),
Category = "Pokemon", Category = "Pokemon",
ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png", ImageUrl = $@"https://nadeko.bot/images/pokemon/shadows/{num}.png",
AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png" AnswerImageUrl = $@"https://nadeko.bot/images/pokemon/real/{num}.png"

View File

@@ -0,0 +1,9 @@
using NadekoBot.Modules.Searches.Common;
namespace NadekoBot.Modules.Nsfw;
public interface INhentaiService
{
Task<Gallery?> GetAsync(uint id);
Task<IReadOnlyList<uint>> GetIdsBySearchAsync(string search);
}

View File

@@ -0,0 +1,115 @@
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
using NadekoBot.Modules.Searches.Common;
namespace NadekoBot.Modules.Nsfw;
public sealed class NhentaiScraperService : INhentaiService, INService
{
private readonly IHttpClientFactory _httpFactory;
private static readonly HtmlParser _htmlParser = new(new()
{
IsScripting = false,
IsEmbedded = false,
IsSupportingProcessingInstructions = false,
IsKeepingSourceReferences = false,
IsNotSupportingFrames = true
});
public NhentaiScraperService(IHttpClientFactory httpFactory)
{
_httpFactory = httpFactory;
}
private HttpClient GetHttpClient()
{
var http = _httpFactory.CreateClient();
http.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.5060.114 Safari/537.36");
http.DefaultRequestHeaders.Add("Cookie", "cf_clearance=I5pR71P4wJkRBFTLFjBndI.GwfKwT.Gx06uS8XNmRJo-1657214595-0-150; csrftoken=WMWRLtsQtBVQYvYkbqXKJHI9T1JwWCdd3tNhoxHn7aHLUYHAqe60XFUKAoWsJtda");
return http;
}
public async Task<Gallery?> GetAsync(uint id)
{
using var http = GetHttpClient();
try
{
var url = $"https://nhentai.net/g/{id}/";
var strRes = await http.GetStringAsync(url);
var doc = await _htmlParser.ParseDocumentAsync(strRes);
var title = doc.QuerySelector("#info .title")?.TextContent;
var fullTitle = doc.QuerySelector("meta[itemprop=\"name\"]")?.Attributes["content"]?.Value
?? title;
var thumb = (doc.QuerySelector("#cover a img") as IHtmlImageElement)?.Dataset["src"];
var tagsElem = doc.QuerySelector("#tags");
var pageCount = tagsElem?.QuerySelector("a.tag[href^=\"/search/?q=pages\"] span")?.TextContent;
var likes = doc.QuerySelector(".buttons .btn-disabled.btn.tooltip span span")?.TextContent?.Trim('(', ')');
var uploadedAt = (tagsElem?.QuerySelector(".tag-container .tags time.nobold") as IHtmlTimeElement)?.DateTime;
var tags = tagsElem?.QuerySelectorAll(".tag-container .tags > a.tag[href^=\"/tag\"]")
.Cast<IHtmlAnchorElement>()
.Select(x => new Tag()
{
Name = x.QuerySelector("span:first-child")?.TextContent,
Url = $"https://nhentai.net{x.PathName}"
})
.ToArray();
if (string.IsNullOrWhiteSpace(fullTitle))
return null;
if (!int.TryParse(pageCount, out var pc))
return null;
if (!int.TryParse(likes, out var lc))
return null;
if (!DateTime.TryParse(uploadedAt, out var ua))
return null;
return new Gallery(id,
url,
fullTitle,
title,
thumb,
pc,
lc,
ua,
tags);
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with id {NhentaiId} not found", id);
return null;
}
}
public async Task<IReadOnlyList<uint>> GetIdsBySearchAsync(string search)
{
using var http = GetHttpClient();
try
{
var url = $"https://nhentai.net/search/?q={Uri.EscapeDataString(search)}&sort=popular-today";
var strRes = await http.GetStringAsync(url);
var doc = await _htmlParser.ParseDocumentAsync(strRes);
var elems = doc.QuerySelectorAll(".container .gallery a")
.Cast<IHtmlAnchorElement>()
.Where(x => x.PathName.StartsWith("/g/"))
.Select(x => x.PathName[3..^1])
.Select(uint.Parse)
.ToArray();
return elems;
}
catch (HttpRequestException)
{
Log.Warning("Nhentai search for {NhentaiSearch} failed", search);
return Array.Empty<uint>();
}
}
}

View File

@@ -404,15 +404,19 @@ public partial class NSFW : NadekoModule<ISearchImagesService>
.Join(" "); .Join(" ");
var embed = _eb.Create() var embed = _eb.Create()
.WithTitle(g.Title) .WithTitle(g.Title)
.WithDescription(g.FullTitle) .WithDescription(g.FullTitle)
.WithImageUrl(g.Thumbnail) .WithImageUrl(g.Thumbnail)
.WithUrl(g.Url) .WithUrl(g.Url)
.AddField(GetText(strs.favorites), g.Likes, true) .AddField(GetText(strs.favorites), g.Likes, true)
.AddField(GetText(strs.pages), g.PageCount, true) .AddField(GetText(strs.pages), g.PageCount, true)
.AddField(GetText(strs.tags), tagString, true) .AddField(GetText(strs.tags),
.WithFooter(g.UploadedAt.ToString("f")) string.IsNullOrWhiteSpace(tagString)
.WithOkColor(); ? "?"
: tagString,
true)
.WithFooter(g.UploadedAt.ToString("f"))
.WithOkColor();
await ctx.Channel.EmbedAsync(embed); await ctx.Channel.EmbedAsync(embed);
} }

View File

@@ -1,10 +0,0 @@
#nullable disable
namespace NadekoBot.Modules.Nsfw;
public interface INsfwService
{
}
public class NsfwService
{
}

View File

@@ -1,21 +1,11 @@
#nullable disable #nullable disable warnings
using LinqToDB; using LinqToDB;
using NadekoBot.Modules.Nsfw.Common; using NadekoBot.Modules.Nsfw.Common;
using NadekoBot.Modules.Searches.Common; using NadekoBot.Modules.Searches.Common;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace NadekoBot.Modules.Nsfw; namespace NadekoBot.Modules.Nsfw;
public record UrlReply
{
public string Error { get; init; }
public string Url { get; init; }
public string Rating { get; init; }
public string Provider { get; init; }
public List<string> Tags { get; } = new();
}
public class SearchImagesService : ISearchImagesService, INService public class SearchImagesService : ISearchImagesService, INService
{ {
private ConcurrentDictionary<ulong, HashSet<string>> BlacklistedTags { get; } private ConcurrentDictionary<ulong, HashSet<string>> BlacklistedTags { get; }
@@ -23,18 +13,22 @@ public class SearchImagesService : ISearchImagesService, INService
public ConcurrentDictionary<ulong, Timer> AutoHentaiTimers { get; } = new(); public ConcurrentDictionary<ulong, Timer> AutoHentaiTimers { get; } = new();
public ConcurrentDictionary<ulong, Timer> AutoBoobTimers { get; } = new(); public ConcurrentDictionary<ulong, Timer> AutoBoobTimers { get; } = new();
public ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; } = new(); public ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; } = new();
private readonly Random _rng; private readonly Random _rng;
private readonly SearchImageCacher _cache; private readonly SearchImageCacher _cache;
private readonly IHttpClientFactory _httpFactory; private readonly IHttpClientFactory _httpFactory;
private readonly DbService _db; private readonly DbService _db;
private readonly INhentaiService _nh;
private readonly object _taglock = new(); private readonly object _taglock = new();
public SearchImagesService( public SearchImagesService(
DbService db, DbService db,
SearchImageCacher cacher, SearchImageCacher cacher,
IHttpClientFactory httpFactory) IHttpClientFactory httpFactory,
INhentaiService nh)
{ {
_nh = nh;
_db = db; _db = db;
_rng = new NadekoRandom(); _rng = new NadekoRandom();
_cache = cacher; _cache = cacher;
@@ -284,85 +278,18 @@ public class SearchImagesService : ISearchImagesService, INService
#region Nhentai #region Nhentai
private string GetNhentaiExtensionInternal(string s) public Task<Gallery?> GetNhentaiByIdAsync(uint id)
=> s switch => _nh.GetAsync(id);
{
"j" => "jpg",
"p" => "png",
"g" => "gif",
_ => "jpg"
};
private Gallery ModelToGallery(NhentaiApiModel.Gallery model) public async Task<Gallery?> GetNhentaiBySearchAsync(string search)
{ {
var thumbnail = $"https://t.nhentai.net/galleries/{model.MediaId}/thumb." var ids = await _nh.GetIdsBySearchAsync(search);
+ GetNhentaiExtensionInternal(model.Images.Thumbnail.T);
var url = $"https://nhentai.net/g/{model.Id}"; if (ids.Count == 0)
return new(model.Id.ToString(),
url,
model.Title.English,
model.Title.Pretty,
thumbnail,
model.NumPages,
model.NumFavorites,
model.UploadDate.ToUnixTimestamp().UtcDateTime,
model.Tags.Map(x => new Tag
{
Name = x.Name,
Url = "https://nhentai.com/" + x.Url
}));
}
private async Task<NhentaiApiModel.Gallery> GetNhentaiByIdInternalAsync(uint id)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/gallery/" + id);
return JsonConvert.DeserializeObject<NhentaiApiModel.Gallery>(res);
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with id {NhentaiId} not found", id);
return null;
}
}
private async Task<NhentaiApiModel.Gallery[]> SearchNhentaiInternalAsync(string search)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/galleries/search?query=" + search);
return JsonConvert.DeserializeObject<NhentaiApiModel.SearchResult>(res).Result;
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with search {NhentaiSearch} not found", search);
return null;
}
}
public async Task<Gallery> GetNhentaiByIdAsync(uint id)
{
var model = await GetNhentaiByIdInternalAsync(id);
return ModelToGallery(model);
}
private static readonly string[] _bannedTags = { "loli", "lolicon", "shota", "shotacon", "cub" };
public async Task<Gallery> GetNhentaiBySearchAsync(string search)
{
var models = await SearchNhentaiInternalAsync(search);
models = models.Where(x => !x.Tags.Any(t => _bannedTags.Contains(t.Name))).ToArray();
if (models.Length == 0)
return null; return null;
return ModelToGallery(models[_rng.Next(0, models.Length)]); var id = ids[_rng.Next(0, ids.Count)];
return await _nh.GetAsync(id);
} }
#endregion #endregion

View File

@@ -0,0 +1,10 @@
namespace NadekoBot.Modules.Nsfw;
public record UrlReply
{
public string Error { get; init; }
public string Url { get; init; }
public string Rating { get; init; }
public string Provider { get; init; }
public List<string> Tags { get; } = new();
}

View File

@@ -9,7 +9,7 @@ public sealed class Tag
public sealed class Gallery public sealed class Gallery
{ {
public string Id { get; } public uint Id { get; }
public string Url { get; } public string Url { get; }
public string FullTitle { get; } public string FullTitle { get; }
public string Title { get; } public string Title { get; }
@@ -21,7 +21,7 @@ public sealed class Gallery
public Gallery( public Gallery(
string id, uint id,
string url, string url,
string fullTitle, string fullTitle,
string title, string title,

View File

@@ -1,121 +0,0 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot.Modules.Searches.Common;
public static class NhentaiApiModel
{
public class Title
{
[JsonProperty("english")]
public string English { get; set; }
[JsonProperty("japanese")]
public string Japanese { get; set; }
[JsonProperty("pretty")]
public string Pretty { get; set; }
}
public class Page
{
[JsonProperty("t")]
public string T { get; set; }
[JsonProperty("w")]
public int W { get; set; }
[JsonProperty("h")]
public int H { get; set; }
}
public class Cover
{
[JsonProperty("t")]
public string T { get; set; }
[JsonProperty("w")]
public int W { get; set; }
[JsonProperty("h")]
public int H { get; set; }
}
public class Thumbnail
{
[JsonProperty("t")]
public string T { get; set; }
[JsonProperty("w")]
public int W { get; set; }
[JsonProperty("h")]
public int H { get; set; }
}
public class Images
{
[JsonProperty("pages")]
public List<Page> Pages { get; set; }
[JsonProperty("cover")]
public Cover Cover { get; set; }
[JsonProperty("thumbnail")]
public Thumbnail Thumbnail { get; set; }
}
public class Tag
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("type")]
public string Type { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
[JsonProperty("count")]
public int Count { get; set; }
}
public class Gallery
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("media_id")]
public string MediaId { get; set; }
[JsonProperty("title")]
public Title Title { get; set; }
[JsonProperty("images")]
public Images Images { get; set; }
[JsonProperty("scanlator")]
public string Scanlator { get; set; }
[JsonProperty("upload_date")]
public double UploadDate { get; set; }
[JsonProperty("tags")]
public Tag[] Tags { get; set; }
[JsonProperty("num_pages")]
public int NumPages { get; set; }
[JsonProperty("num_favorites")]
public int NumFavorites { get; set; }
}
public class SearchResult
{
[JsonProperty("result")]
public Gallery[] Result { get; set; }
}
}

View File

@@ -27,7 +27,8 @@ public class CommandMapService : IInputTransformer, INService
AliasMaps = new(configs.ToDictionary(x => x.GuildId, AliasMaps = new(configs.ToDictionary(x => x.GuildId,
x => new ConcurrentDictionary<string, string>(x.CommandAliases.DistinctBy(ca => ca.Trigger) x => new ConcurrentDictionary<string, string>(x.CommandAliases.DistinctBy(ca => ca.Trigger)
.ToDictionary(ca => ca.Trigger, ca => ca.Mapping)))); .ToDictionary(ca => ca.Trigger, ca => ca.Mapping),
StringComparer.OrdinalIgnoreCase)));
_db = db; _db = db;
} }
@@ -56,34 +57,69 @@ public class CommandMapService : IInputTransformer, INService
if (AliasMaps.TryGetValue(guild.Id, out var maps)) if (AliasMaps.TryGetValue(guild.Id, out var maps))
{ {
var keys = maps.Keys.OrderByDescending(x => x.Length); string newInput = null;
foreach (var (k, v) in maps)
foreach (var k in keys)
{ {
string newInput; if (string.Equals(input, k, StringComparison.OrdinalIgnoreCase))
if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
newInput = maps[k] + input.Substring(k.Length, input.Length - k.Length);
else if (input.Equals(k, StringComparison.InvariantCultureIgnoreCase))
newInput = maps[k];
else
continue;
try
{ {
var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}"); newInput = v;
_ = Task.Run(async () => }
{ else if (input.StartsWith(k + ' ', StringComparison.OrdinalIgnoreCase))
await Task.Delay(1500); {
await toDelete.DeleteAsync(new() newInput = v + ' ' + input[k.Length..];
{
RetryMode = RetryMode.AlwaysRetry
});
});
} }
catch { }
return newInput; if (newInput is not null)
{
try
{
var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
_ = Task.Run(async () =>
{
await Task.Delay(1500);
await toDelete.DeleteAsync(new()
{
RetryMode = RetryMode.AlwaysRetry
});
});
}
catch
{
}
return newInput;
}
} }
return null;
// var keys = maps.Keys.OrderByDescending(x => x.Length);
// foreach (var k in keys)
// {
// string newInput;
// if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
// newInput = maps[k] + input.Substring(k.Length, input.Length - k.Length);
// else if (input.Equals(k, StringComparison.InvariantCultureIgnoreCase))
// newInput = maps[k];
// else
// continue;
//
// try
// {
// var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
// _ = Task.Run(async () =>
// {
// await Task.Delay(1500);
// await toDelete.DeleteAsync(new()
// {
// RetryMode = RetryMode.AlwaysRetry
// });
// });
// }
// catch { }
//
// return newInput;
// }
} }
return null; return null;

View File

@@ -1,4 +1,4 @@
#nullable disable #nullable disable warnings
using NadekoBot.Common.Yml; using NadekoBot.Common.Yml;
using NadekoBot.Db; using NadekoBot.Db;
using NadekoBot.Services.Database.Models; using NadekoBot.Services.Database.Models;
@@ -134,30 +134,40 @@ public partial class Utility
.WithFooter( .WithFooter(
GetText(strs.created_by($"{data.AuthorName} ({data.AuthorId})")))); GetText(strs.created_by($"{data.AuthorName} ({data.AuthorId})"))));
[Cmd] private async Task QuoteSearchinternalAsync(string? keyword, string textOrAuthor)
[RequireContext(ContextType.Guild)]
public async partial Task QuoteSearch(string keyword, [Leftover] string text)
{ {
if (string.IsNullOrWhiteSpace(keyword) || string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(textOrAuthor))
return; return;
keyword = keyword.ToUpperInvariant(); keyword = keyword?.ToUpperInvariant();
Quote keywordquote; Quote quote;
await using (var uow = _db.GetDbContext()) await using (var uow = _db.GetDbContext())
{ {
keywordquote = await uow.Quotes.SearchQuoteKeywordTextAsync(ctx.Guild.Id, keyword, text); quote = await uow.Quotes.SearchQuoteKeywordTextAsync(ctx.Guild.Id, keyword, textOrAuthor);
} }
if (keywordquote is null) if (quote is null)
return; return;
await ctx.Channel.SendMessageAsync($"`#{keywordquote.Id}` 💬 " await ctx.Channel.SendMessageAsync($"`#{quote.Id}` 💬 "
+ keyword.ToLowerInvariant() + quote.Keyword.ToLowerInvariant()
+ ": " + ": "
+ keywordquote.Text.SanitizeAllMentions()); + quote.Text.SanitizeAllMentions());
} }
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public partial Task QuoteSearch(string textOrAuthor)
=> QuoteSearchinternalAsync(null, textOrAuthor);
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public partial Task QuoteSearch(string keyword, [Leftover] string textOrAuthor)
=> QuoteSearchinternalAsync(keyword, textOrAuthor);
[Cmd] [Cmd]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
public async partial Task QuoteId(int id) public async partial Task QuoteId(int id)

View File

@@ -178,4 +178,27 @@ public class RemindService : INService, IReadyExecutor
public string What { get; set; } public string What { get; set; }
public TimeSpan Time { get; set; } public TimeSpan Time { get; set; }
} }
public async Task AddReminderAsync(ulong userId,
ulong targetId,
ulong? guildId,
bool isPrivate,
DateTime time,
string message)
{
var rem = new Reminder
{
UserId = userId,
ChannelId = targetId,
ServerId = guildId ?? 0,
IsPrivate = isPrivate,
When = time,
Message = message,
};
await using var ctx = _db.GetDbContext();
await ctx.Reminders
.AddAsync(rem);
await ctx.SaveChangesAsync();
}
} }

View File

@@ -221,7 +221,7 @@ public class StreamRoleService : IReadyExecutor, INService
} }
private async ValueTask RescanUser(IGuildUser user, StreamRoleSettings setting, IRole addRole = null) private async ValueTask RescanUser(IGuildUser user, StreamRoleSettings setting, IRole addRole = null)
=> await _queueRunner.Enqueue(() => RescanUserInternal(user, setting, addRole)); => await _queueRunner.EnqueueAsync(() => RescanUserInternal(user, setting, addRole));
private async Task RescanUserInternal(IGuildUser user, StreamRoleSettings setting, IRole addRole = null) private async Task RescanUserInternal(IGuildUser user, StreamRoleSettings setting, IRole addRole = null)
{ {
@@ -239,7 +239,7 @@ public class StreamRoleService : IReadyExecutor, INService
&& setting.Blacklist.All(x => x.UserId != user.Id) && setting.Blacklist.All(x => x.UserId != user.Id)
&& user.RoleIds.Contains(setting.FromRoleId)) && user.RoleIds.Contains(setting.FromRoleId))
{ {
await _queueRunner.Enqueue(async () => await _queueRunner.EnqueueAsync(async () =>
{ {
try try
{ {
@@ -277,7 +277,7 @@ public class StreamRoleService : IReadyExecutor, INService
//check if user is in the addrole //check if user is in the addrole
if (user.RoleIds.Contains(setting.AddRoleId)) if (user.RoleIds.Contains(setting.AddRoleId))
{ {
await _queueRunner.Enqueue(async () => await _queueRunner.EnqueueAsync(async () =>
{ {
try try
{ {

View File

@@ -1,4 +1,5 @@
#nullable disable #nullable disable
using LinqToDB;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db; using NadekoBot.Db;
@@ -12,7 +13,7 @@ using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing; using SixLabors.ImageSharp.Processing;
using StackExchange.Redis; using System.Threading.Channels;
using Color = SixLabors.ImageSharp.Color; using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image; using Image = SixLabors.ImageSharp.Image;
@@ -37,7 +38,6 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels; private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels;
private readonly ConcurrentHashSet<ulong> _excludedServers; private readonly ConcurrentHashSet<ulong> _excludedServers;
private readonly System.Collections.Concurrent.ConcurrentQueue<UserCacheItem> _addMessageXp = new();
private XpTemplate template; private XpTemplate template;
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
@@ -45,6 +45,10 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private readonly IPatronageService _ps; private readonly IPatronageService _ps;
private readonly IBotCache _c; private readonly IBotCache _c;
private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 50);
private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>();
public XpService( public XpService(
DiscordSocketClient client, DiscordSocketClient client,
Bot bot, Bot bot,
@@ -122,147 +126,131 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
public async Task OnReadyAsync() public async Task OnReadyAsync()
{ {
_ = Task.Run(() => _levelUpQueue.RunAsync());
using var timer = new PeriodicTimer(5.Seconds()); using var timer = new PeriodicTimer(5.Seconds());
while (await timer.WaitForNextTickAsync()) while (await timer.WaitForNextTickAsync())
{ {
await UpdateLoop(); await UpdateXp();
} }
} }
private async Task UpdateLoop() public sealed class MiniGuildXpStats
{
public long Xp { get; set; }
public XpNotificationLocation NotifyOnLevelUp { get; set; }
public ulong GuildId { get; set; }
public ulong UserId { get; set; }
}
private async Task UpdateXp()
{ {
try try
{ {
var toNotify = var reader = _xpGainQueue.Reader;
new List<(IGuild Guild, IMessageChannel MessageChannel, IUser User, long Level,
XpNotificationLocation NotifyType, NotifOf NotifOf)>();
var roleRewards = new Dictionary<ulong, List<XpRoleReward>>();
var curRewards = new Dictionary<ulong, List<XpCurrencyReward>>();
var toAddTo = new List<UserCacheItem>(); // sum up all gains into a single UserCacheItem
while (_addMessageXp.TryDequeue(out var usr)) var globalToAdd = new Dictionary<ulong, UserXpGainData>();
toAddTo.Add(usr); var guildToAdd = new Dictionary<ulong, Dictionary<ulong, UserXpGainData>>();
while (reader.TryRead(out var item))
var group = toAddTo.GroupBy(x => (GuildId: x.Guild.Id, x.User));
if (toAddTo.Count == 0)
return;
await using (var uow = _db.GetDbContext())
{ {
foreach (var item in group) // add global xp to these users
{ if (!globalToAdd.TryGetValue(item.User.Id, out var ci))
var xp = item.Sum(x => x.XpAmount); globalToAdd[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
var usr = uow.GetOrCreateUserXpStats(item.Key.GuildId, item.Key.User.Id);
var du = uow.GetOrCreateUser(item.Key.User);
var globalXp = du.TotalXp; // ad guild xp in these guilds to these users
var oldGlobalLevelData = new LevelStats(globalXp); if (!guildToAdd.TryGetValue(item.Guild.Id, out var users))
var newGlobalLevelData = new LevelStats(globalXp + xp); users = guildToAdd[item.Guild.Id] = new();
var oldGuildLevelData = new LevelStats(usr.Xp + usr.AwardedXp); if (!users.TryGetValue(item.User.Id, out ci))
usr.Xp += xp; users[item.User.Id] = item.Clone();
du.TotalXp += xp; else
if (du.Club is not null) ci.XpAmount += item.XpAmount;
du.Club.Xp += xp;
var newGuildLevelData = new LevelStats(usr.Xp + usr.AwardedXp);
if (oldGlobalLevelData.Level < newGlobalLevelData.Level)
{
var first = item.First();
if (du.NotifyOnLevelUp != XpNotificationLocation.None)
{
toNotify.Add((first.Guild, first.Channel, first.User, newGlobalLevelData.Level,
du.NotifyOnLevelUp, NotifOf.Global));
}
}
if (oldGuildLevelData.Level < newGuildLevelData.Level)
{
//send level up notification
var first = item.First();
if (usr.NotifyOnLevelUp != XpNotificationLocation.None)
{
toNotify.Add((first.Guild, first.Channel, first.User, newGuildLevelData.Level,
usr.NotifyOnLevelUp, NotifOf.Server));
}
//give role
if (!roleRewards.TryGetValue(usr.GuildId, out var rrews))
{
rrews = uow.XpSettingsFor(usr.GuildId).RoleRewards.ToList();
roleRewards.Add(usr.GuildId, rrews);
}
if (!curRewards.TryGetValue(usr.GuildId, out var crews))
{
crews = uow.XpSettingsFor(usr.GuildId).CurrencyRewards.ToList();
curRewards.Add(usr.GuildId, crews);
}
//loop through levels since last level up, so if a high amount of xp is gained, reward are still applied.
for (var i = oldGuildLevelData.Level + 1; i <= newGuildLevelData.Level; i++)
{
var rrew = rrews.FirstOrDefault(x => x.Level == i);
if (rrew is not null)
{
var role = first.User.Guild.GetRole(rrew.RoleId);
if (role is not null)
{
if (rrew.Remove)
_ = first.User.RemoveRoleAsync(role);
else
_ = first.User.AddRoleAsync(role);
}
}
//get currency reward for this level
var crew = crews.FirstOrDefault(x => x.Level == i);
if (crew is not null)
//give the user the reward if it exists
await _cs.AddAsync(item.Key.User.Id, crew.Amount, new("xp", "level-up"));
}
}
}
uow.SaveChanges();
} }
await toNotify.Select(async x => await using var ctx = _db.GetDbContext();
{ await using var tran = await ctx.Database.BeginTransactionAsync();
if (x.NotifOf == NotifOf.Server)
{
if (x.NotifyType == XpNotificationLocation.Dm)
{
await x.User.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_dm(x.User.Mention,
Format.Bold(x.Level.ToString()),
Format.Bold(x.Guild.ToString() ?? "-")),
x.Guild.Id));
}
else if (x.MessageChannel is not null) // channel
{
await x.MessageChannel.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_channel(x.User.Mention,
Format.Bold(x.Level.ToString())),
x.Guild.Id));
}
}
else
{
IMessageChannel chan;
if (x.NotifyType == XpNotificationLocation.Dm)
chan = await x.User.CreateDMChannelAsync();
else // channel
chan = x.MessageChannel;
await chan.SendConfirmAsync(_eb, // update global user xp in batches
_strings.GetText(strs.level_up_global(x.User.Mention, // group by xp amount and update the same amounts at the same time
Format.Bold(x.Level.ToString())), var dus = new List<DiscordUser>(globalToAdd.Count);
x.Guild.Id)); foreach (var group in globalToAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
} {
}) var items = await ctx.DiscordUser
.WhenAll(); .Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
TotalXp = old.TotalXp + group.Key
},
(_, n) => n);
dus.AddRange(items);
}
// update guild user xp in batches
var gxps = new List<UserXpStats>(globalToAdd.Count);
foreach (var (guildId, toAdd) in guildToAdd)
{
foreach (var group in toAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx
.UserXpStats
.Where(x => x.GuildId == guildId)
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
Xp = old.Xp + group.Key
},
(_, n) => n);
gxps.AddRange(items);
}
}
await tran.CommitAsync();
foreach (var du in dus)
{
var oldLevel = new LevelStats(du.TotalXp - globalToAdd[du.UserId].XpAmount);
var newLevel = new LevelStats(du.TotalXp);
if (oldLevel.Level != newLevel.Level)
{
var item = globalToAdd[du.UserId];
await _levelUpQueue.EnqueueAsync(
NotifyUser(item.Guild.Id,
item.Channel.Id,
du.UserId,
false,
oldLevel.Level,
newLevel.Level,
du.NotifyOnLevelUp));
}
}
foreach (var du in gxps)
{
if (guildToAdd.TryGetValue(du.GuildId, out var users)
&& users.TryGetValue(du.UserId, out var xpGainData))
{
var oldLevel = new LevelStats(du.Xp - xpGainData.XpAmount);
var newLevel = new LevelStats(du.Xp);
if (oldLevel.Level < newLevel.Level)
{
await _levelUpQueue.EnqueueAsync(
NotifyUser(xpGainData.Guild.Id,
xpGainData.Channel.Id,
du.UserId,
true,
oldLevel.Level,
newLevel.Level,
du.NotifyOnLevelUp));
}
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -270,7 +258,115 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
} }
} }
private Func<Task> NotifyUser(
ulong guildId,
ulong channelId,
ulong userId,
bool isServer,
long oldLevel,
long newLevel,
XpNotificationLocation notifyLoc)
=> async () =>
{
if (isServer)
{
await HandleRewardsInternalAsync(guildId, userId, oldLevel, newLevel);
}
await HandleNotifyInternalAsync(guildId, channelId, userId, isServer, newLevel, notifyLoc);
};
private async Task HandleRewardsInternalAsync(ulong guildId, ulong userId, long oldLevel, long newLevel)
{
await using var ctx = _db.GetDbContext();
var rrews = ctx.XpSettingsFor(guildId).RoleRewards.ToList();
var crews = ctx.XpSettingsFor(guildId).CurrencyRewards.ToList();
//loop through levels since last level up, so if a high amount of xp is gained, reward are still applied.
for (var i = oldLevel + 1; i <= newLevel; i++)
{
var rrew = rrews.FirstOrDefault(x => x.Level == i);
if (rrew is not null)
{
var guild = _client.GetGuild(guildId);
var role = guild?.GetRole(rrew.RoleId);
var user = guild?.GetUser(userId);
if (role is not null && user is not null)
{
if (rrew.Remove)
_ = user.RemoveRoleAsync(role);
else
_ = user.AddRoleAsync(role);
}
}
//get currency reward for this level
var crew = crews.FirstOrDefault(x => x.Level == i);
if (crew is not null)
{
//give the user the reward if it exists
await _cs.AddAsync(userId, crew.Amount, new("xp", "level-up"));
}
}
}
private async Task HandleNotifyInternalAsync(ulong guildId,
ulong channelId,
ulong userId,
bool isServer,
long newLevel,
XpNotificationLocation notifyLoc)
{
if (notifyLoc == XpNotificationLocation.None)
return;
var guild = _client.GetGuild(guildId);
var user = guild?.GetUser(userId);
var ch = guild?.GetTextChannel(channelId);
if (guild is null || user is null)
return;
if (isServer)
{
if (notifyLoc == XpNotificationLocation.Dm)
{
await user.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_dm(user.Mention,
Format.Bold(newLevel.ToString()),
Format.Bold(guild.ToString() ?? "-")),
guild.Id));
}
else // channel
{
await ch.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_channel(user.Mention,
Format.Bold(newLevel.ToString())),
guild.Id));
}
}
else // global level
{
var chan = notifyLoc switch
{
XpNotificationLocation.Dm => (IMessageChannel)await user.CreateDMChannelAsync(),
XpNotificationLocation.Channel => ch,
_ => null
};
if (chan is null)
return;
await chan.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_global(user.Mention,
Format.Bold(newLevel.ToString())),
guild.Id));
}
}
private const string XP_TEMPLATE_PATH = "./data/xp_template.json"; private const string XP_TEMPLATE_PATH = "./data/xp_template.json";
private void InternalReloadXpTemplate() private void InternalReloadXpTemplate()
{ {
try try
@@ -295,7 +391,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
{ {
Log.Warning("Loaded default xp_template.json values as the old one was version 0. " Log.Warning("Loaded default xp_template.json values as the old one was version 0. "
+ "Old one was renamed to xp_template.json.old"); + "Old one was renamed to xp_template.json.old");
File.WriteAllText("./data/xp_template.json.old", JsonConvert.SerializeObject(template, Formatting.Indented)); File.WriteAllText("./data/xp_template.json.old",
JsonConvert.SerializeObject(template, Formatting.Indented));
template = new(); template = new();
template.Version = 1; template.Version = 1;
File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(template, Formatting.Indented)); File.WriteAllText(XP_TEMPLATE_PATH, JsonConvert.SerializeObject(template, Formatting.Indented));
@@ -473,9 +570,11 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel) if (after.VoiceChannel is not null && after.VoiceChannel != before.VoiceChannel)
await ScanChannelForVoiceXp(after.VoiceChannel); await ScanChannelForVoiceXp(after.VoiceChannel);
else if (after.VoiceChannel is null) else if (after.VoiceChannel is null)
{
// In this case, the user left the channel and the previous for loops didn't catch // In this case, the user left the channel and the previous for loops didn't catch
// it because it wasn't in any new channel. So we need to get rid of it. // it because it wasn't in any new channel. So we need to get rid of it.
await UserLeftVoiceChannel(user, before.VoiceChannel); await UserLeftVoiceChannel(user, before.VoiceChannel);
}
}); });
return Task.CompletedTask; return Task.CompletedTask;
@@ -546,7 +645,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (actualXp > 0) if (actualXp > 0)
{ {
_addMessageXp.Enqueue(new() await _xpGainQueue.Writer.WriteAsync(new()
{ {
Guild = channel.Guild, Guild = channel.Guild,
User = user, User = user,
@@ -593,7 +692,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (!await SetUserRewardedAsync(user.Id)) if (!await SetUserRewardedAsync(user.Id))
return; return;
_addMessageXp.Enqueue(new() await _xpGainQueue.Writer.WriteAsync(new()
{ {
Guild = user.Guild, Guild = user.Guild,
Channel = arg.Channel, Channel = arg.Channel,
@@ -604,19 +703,19 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
return Task.CompletedTask; return Task.CompletedTask;
} }
public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount) // public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount)
{ // {
if (amount <= 0) // if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount)); // throw new ArgumentOutOfRangeException(nameof(amount));
//
_addMessageXp.Enqueue(new() // _xpGainQueue.Writer.WriteAsync(new()
{ // {
Guild = user.Guild, // Guild = user.Guild,
Channel = channel, // Channel = channel,
User = user, // User = user,
XpAmount = amount // XpAmount = amount
}); // });
} // }
public void AddXp(ulong userId, ulong guildId, int amount) public void AddXp(ulong userId, ulong guildId, int amount)
{ {

View File

@@ -3,7 +3,7 @@ using NadekoBot.Modules.Xp.Services;
namespace NadekoBot.Modules.Xp; namespace NadekoBot.Modules.Xp;
public class LevelStats public readonly struct LevelStats
{ {
public long Level { get; } public long Level { get; }
public long LevelXp { get; } public long LevelXp { get; }

View File

@@ -1,16 +1,13 @@
#nullable disable #nullable disable
using Cloneable;
namespace NadekoBot.Modules.Xp.Services; namespace NadekoBot.Modules.Xp.Services;
public class UserCacheItem [Cloneable]
public sealed partial class UserXpGainData : ICloneable<UserXpGainData>
{ {
public IGuildUser User { get; set; } public IGuildUser User { get; set; }
public IGuild Guild { get; set; } public IGuild Guild { get; set; }
public IMessageChannel Channel { get; set; } public IMessageChannel Channel { get; set; }
public int XpAmount { get; set; } public int XpAmount { get; set; }
public override int GetHashCode()
=> User.GetHashCode();
public override bool Equals(object obj)
=> obj is UserCacheItem uci && uci.User == User;
} }

View File

@@ -9,5 +9,5 @@ public interface ILocalDataCache
Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync(); Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync(); Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync(); Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
Task<PokemonNameId[]> GetPokemonMapAsync(); Task<IReadOnlyDictionary<int, string>> GetPokemonMapAsync();
} }

View File

@@ -67,11 +67,37 @@ public sealed class LocalDataCache : ILocalDataCache, INService
=> await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE); => await GetOrCreateCachedDataAsync(_pokemonAbilitiesKey, POKEMON_ABILITIES_FILE);
private static TypedKey<PokemonNameId[]> _pokeMapKey private static TypedKey<IReadOnlyDictionary<int, string>> _pokeMapKey
= new("pokemon:ab_map"); = new("pokemon:ab_map2"); // 2 because ab_map was storing arrays
public async Task<PokemonNameId[]?> GetPokemonMapAsync() public async Task<IReadOnlyDictionary<int, string>?> GetPokemonMapAsync()
=> await GetOrCreateCachedDataAsync(_pokeMapKey, POKEMON_MAP_PATH); => await _cache.GetOrAddAsync(_pokeMapKey,
async () =>
{
var fileName = POKEMON_MAP_PATH;
if (!File.Exists(fileName))
{
Log.Warning($"{fileName} is missing. Relevant data can't be loaded");
return default;
}
try
{
await using var stream = File.OpenRead(fileName);
var arr = await JsonSerializer.DeserializeAsync<PokemonNameId[]>(stream, _opts);
return (IReadOnlyDictionary<int, string>?)arr?.ToDictionary(x => x.Id, x => x.Name);
}
catch (Exception ex)
{
Log.Error(ex,
"Error reading {FileName} file: {ErrorMessage}",
fileName,
ex.Message);
return default;
}
});
private static TypedKey<TriviaQuestionModel[]> _triviaKey private static TypedKey<TriviaQuestionModel[]> _triviaKey

View File

@@ -59,6 +59,11 @@ public sealed class ImageCache : IImageCache, INService
GetImageKey(url), GetImageKey(url),
async () => async () =>
{ {
if (url.IsFile)
{
return await File.ReadAllBytesAsync(url.LocalPath);
}
using var http = _httpFactory.CreateClient(); using var http = _httpFactory.CreateClient();
var bytes = await http.GetByteArrayAsync(url); var bytes = await http.GetByteArrayAsync(url);
return bytes; return bytes;

View File

@@ -7,7 +7,7 @@ namespace NadekoBot.Services;
public sealed class StatsService : IStatsService, IReadyExecutor, INService public sealed class StatsService : IStatsService, IReadyExecutor, INService
{ {
public const string BOT_VERSION = "4.2.11"; public const string BOT_VERSION = "4.2.15";
public string Author public string Author
=> "Kwoth#2452"; => "Kwoth#2452";

View File

@@ -58,7 +58,8 @@ public static class SocketMessageComponentExtensions
bool ephemeral = false) bool ephemeral = false)
=> smc.RespondAsync(plainText, => smc.RespondAsync(plainText,
embed: embed?.Build(), embed: embed?.Build(),
embeds: embeds?.Map(x => x.Build())); embeds: embeds?.Map(x => x.Build()),
ephemeral: ephemeral);
public static Task RespondAsync( public static Task RespondAsync(
this SocketMessageComponent ch, this SocketMessageComponent ch,

View File

@@ -67,8 +67,7 @@ helpText: |-
# List of modules and commands completely blocked on the bot # List of modules and commands completely blocked on the bot
blocked: blocked:
commands: [] commands: []
modules: modules: []
- nsfw
# Which string will be used to recognize the commands # Which string will be used to recognize the commands
prefix: . prefix: .
# Toggles whether your bot will group greet/bye messages into a single message every 5 seconds. # Toggles whether your bot will group greet/bye messages into a single message every 5 seconds.

View File

@@ -601,9 +601,12 @@ quoteshow:
args: args:
- "123" - "123"
quotesearch: quotesearch:
desc: "Shows a random quote for a keyword that contains any text specified in the search." desc: "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"
args: args:
- "keyword text" - "\"find this long text\""
- "AuthorName"
- "keyword some text"
- "keyword AuthorName"
quoteid: quoteid:
desc: "Displays the quote with the specified ID number. Quote ID numbers can be found by typing `{0}liqu [num]` where `[num]` is a number of a page which contains 15 quotes." desc: "Displays the quote with the specified ID number. Quote ID numbers can be found by typing `{0}liqu [num]` where `[num]` is a number of a page which contains 15 quotes."
args: args:

View File

@@ -596,6 +596,7 @@
"quote_deleted": "Quote #{0} deleted.", "quote_deleted": "Quote #{0} deleted.",
"region": "Region", "region": "Region",
"remind": "I will remind {0} to {1} in {2} `({3:d.M.yyyy.} at {4:HH:mm})`", "remind": "I will remind {0} to {1} in {2} `({3:d.M.yyyy.} at {4:HH:mm})`",
"remind_timely": "I will remind you about your timely reward {0}",
"remind_invalid": "Not a valid remind format. Remind must have a target, timer and a reason. Check the command list.", "remind_invalid": "Not a valid remind format. Remind must have a target, timer and a reason. Check the command list.",
"remind_too_long": "Remind time has exceeded maximum.", "remind_too_long": "Remind time has exceeded maximum.",
"repeater_redundant_no": "Repeater **#{0}** won't post redundant messages anymore.", "repeater_redundant_no": "Repeater **#{0}** won't post redundant messages anymore.",
@@ -883,6 +884,7 @@
"timely_set": "Users will be able to claim {0} every {1}h", "timely_set": "Users will be able to claim {0} every {1}h",
"timely_set_none": "Users will not be able to claim any timely currency.", "timely_set_none": "Users will not be able to claim any timely currency.",
"timely_reset": "All users will be able to claim timely currency again.", "timely_reset": "All users will be able to claim timely currency again.",
"timely_time": "It's time for your timely reward.",
"price": "Price", "price": "Price",
"market_cap": "Market Cap", "market_cap": "Market Cap",
"market_cap_dominance": "Dominance", "market_cap_dominance": "Dominance",