Compare commits

...

36 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
Kwoth
11d9db99ff Draw fixed, version upped 2022-06-29 17:53:15 +02:00
Kwoth
c66e0fb6b7 Possible fix for constant source generator crashes 2022-06-29 15:28:21 +02:00
Kwoth
1517a35ef7 Upped version to 4.2.10 2022-06-29 11:16:32 +02:00
Kwoth
c5179979d7 Fixed an issue with currency generation working only once 2022-06-29 00:18:15 +02:00
Kwoth
6b14c04e37 Merge branch 'v4' of https://gitlab.com/kwoth/nadekobot into v4 2022-06-28 10:55:59 +02:00
Kwoth
4ec3eb7855 Possible fix for the filterservice duplicate key bug 2022-06-28 10:55:35 +02:00
Kwoth
4752c4b7cd Update CHANGELOG.md 2022-06-26 08:54:40 +00:00
Kwoth
dfec2f589e Nuked nsfw from the public bot, it shouldn't show up anymore. Uncommented patron owner check 2022-06-25 21:03:16 +02:00
53 changed files with 10901 additions and 482 deletions

View File

@@ -1,12 +1,60 @@
# Changelog
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [4.2.9] - 25.06.202
## [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
### Fixed
- Fixed `.draw` command
## [4.2.10] - 29.06.2022
- Fixed currency generation working only once
## [4.2.9] - 25.06.2022
### Fixed
- Fixed `creds_example.yml` misssing from output directory
## [4.2.8] - 24.06.2022

View File

@@ -31,7 +31,8 @@
![Create a new bot](https://i.imgur.com/JxtRk9e.png "Create a new bot")
- Click on **`DOWNLOAD`** at the lower right
![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.
- 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).

View File

@@ -64,7 +64,9 @@ public class CmdAttribute : System.Attribute
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// #if DEBUG
// SpinWait.SpinUntil(() => Debugger.IsAttached);
// if (!Debugger.IsAttached)
// Debugger.Launch();
// // SpinWait.SpinUntil(() => Debugger.IsAttached);
// #endif
context.RegisterPostInitializationOutput(static ctx => ctx.AddSource(
"CmdAttribute.g.cs",
@@ -157,7 +159,9 @@ public class CmdAttribute : System.Attribute
.Distinct();
var methodModels = methods
.Select(x => MethodDeclarationToMethodModel(compilation, x!));
.Select(x => MethodDeclarationToMethodModel(compilation, x!))
.Where(static x => x is not null)
.Cast<MethodModel>();
var groups = methodModels
.GroupBy(static x => $"{x.Namespace}.{string.Join(".", x.Classes)}");
@@ -177,7 +181,7 @@ public class CmdAttribute : System.Attribute
var model = new FileModel(
methods: elems,
ns: elems[0].Namespace,
classHierarchy: elems[0].Classes
classHierarchy: elems![0].Classes
);
models.Add(model);
@@ -187,11 +191,21 @@ public class CmdAttribute : System.Attribute
return models;
}
private static MethodModel MethodDeclarationToMethodModel(Compilation comp, MethodDeclarationSyntax decl)
private static MethodModel? MethodDeclarationToMethodModel(Compilation comp, MethodDeclarationSyntax decl)
{
// SpinWait.SpinUntil(static () => Debugger.IsAttached);
var semanticModel = comp.GetSemanticModel(decl.SyntaxTree);
SemanticModel semanticModel;
try
{
semanticModel = comp.GetSemanticModel(decl.SyntaxTree);
}
catch
{
// for some reason this method can throw "Not part of this compilation" argument exception
return null;
}
var methodModel = new MethodModel(
@params: decl.ParameterList.Parameters
.Where(p => p.Type is not null)

View File

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

View File

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

View File

@@ -67,13 +67,13 @@ public abstract class NadekoModule : ModuleBase
// localized replies
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)
=> 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)
=> SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}");
=> SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
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);
}

View File

@@ -55,6 +55,7 @@ public class ReplacementBuilder
_reps.TryAdd("%server%", () => g is null ? "DM" : g.Name);
_reps.TryAdd("%server.id%", () => g is null ? "DM" : g.Id.ToString());
_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.boosters%", () => g.PremiumSubscriptionCount.ToString());
_reps.TryAdd("%server.boost_level%", () => ((int)g.PremiumTier).ToString());

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ namespace NadekoBot.Services.Database.Models;
public class LogSetting : DbEntity
{
public List<IgnoredLogItem> LogIgnores { get; set; } = new();
public ulong GuildId { get; set; }
public ulong? LogOtherId { get; set; }
public ulong? MessageUpdatedId { get; set; }
@@ -29,4 +29,5 @@ public class LogSetting : DbEntity
public ulong? LogVoicePresenceId { 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")
.HasColumnName("logvoicepresencettsid");
b.Property<ulong?>("LogWarnsId")
.HasColumnType("bigint unsigned")
.HasColumnName("logwarnsid");
b.Property<ulong?>("MessageDeletedId")
.HasColumnType("bigint unsigned")
.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)")
.HasColumnName("logvoicepresencettsid");
b.Property<decimal?>("LogWarnsId")
.HasColumnType("numeric(20,0)")
.HasColumnName("logwarnsid");
b.Property<decimal?>("MessageDeletedId")
.HasColumnType("numeric(20,0)")
.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");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
@@ -1100,6 +1099,9 @@ namespace NadekoBot.Migrations
b.Property<ulong?>("LogVoicePresenceTTSId")
.HasColumnType("INTEGER");
b.Property<ulong?>("LogWarnsId")
.HasColumnType("INTEGER");
b.Property<ulong?>("MessageDeletedId")
.HasColumnType("INTEGER");

View File

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

View File

@@ -26,6 +26,7 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
private readonly IMemoryCache _memoryCache;
private readonly ConcurrentHashSet<ulong> _ignoreMessageIds = new();
private readonly UserPunishService _punishService;
public LogCommandService(
DiscordSocketClient client,
@@ -35,7 +36,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
ProtectionService prot,
GuildTimezoneService tz,
IMemoryCache memoryCache,
IEmbedBuilderService eb)
IEmbedBuilderService eb,
UserPunishService punishService)
{
_client = client;
_memoryCache = memoryCache;
@@ -45,6 +47,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
_mute = mute;
_prot = prot;
_tz = tz;
_punishService = punishService;
using (var uow = db.GetDbContext())
{
var guildIds = client.Guilds.Select(x => x.Id).ToList();
@@ -78,6 +82,8 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
_mute.UserUnmuted += MuteCommands_UserUnmuted;
_prot.OnAntiProtectionTriggered += TriggeredAntiProtection;
_punishService.OnUserWarned += PunishServiceOnOnUserWarned;
}
public async Task OnReadyAsync()
@@ -183,6 +189,30 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
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)
{
_ = Task.Run(async () =>
@@ -296,6 +326,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
channelId = logSetting.LogVoicePresenceTTSId =
logSetting.LogVoicePresenceTTSId is null ? cid : default;
break;
case LogType.UserWarned:
channelId = logSetting.LogWarnsId = logSetting.LogWarnsId is null ? cid : default;
break;
}
uow.SaveChanges();
@@ -945,11 +978,25 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
ITextChannel? logChannel;
if ((logChannel = await TryGetLogChannel(guild, logSetting, LogType.UserBanned)) == null)
return;
string? reason = null;
try
{
var ban = await guild.GetBanAsync(usr);
reason = ban?.Reason;
}
catch
{
}
var embed = _eb.Create()
.WithOkColor()
.WithTitle("🚫 " + GetText(logChannel.Guild, strs.user_banned))
.WithDescription(usr.ToString()!)
.AddField("Id", usr.Id.ToString())
.AddField("Reason", string.IsNullOrWhiteSpace(reason) ? "-" : reason)
.WithFooter(CurrentTime(guild));
var avatarUrl = usr.GetAvatarUrl();
@@ -1130,6 +1177,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
case LogType.UserMuted:
id = logSetting.UserMutedId;
break;
case LogType.UserWarned:
id = logSetting.LogWarnsId;
break;
}
if (id is null or 0)
@@ -1200,6 +1250,9 @@ public sealed class LogCommandService : ILogCommandService, IReadyExecutor
case LogType.VoicePresenceTts:
newLogSetting.LogVoicePresenceTTSId = null;
break;
case LogType.UserWarned:
newLogSetting.LogWarnsId = null;
break;
}
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)
{
// 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;
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 DiscordSocketClient _client;
public event Func<Warning, Task> OnUserWarned = static delegate { return Task.CompletedTask; };
public UserPunishService(
MuteService mute,
DbService db,
@@ -93,6 +95,8 @@ public class UserPunishService : INService, IReadyExecutor
await uow.SaveChangesAsync();
}
_ = OnUserWarned(warn);
var totalCount = previousCount + weight;
var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount)
@@ -185,6 +189,9 @@ public class UserPunishService : INService, IReadyExecutor
guild.Id);
}
break;
case PunishmentAction.Warn:
await Warn(guild, user.Id, mod, 1, reason);
break;
}
}

View File

@@ -44,7 +44,7 @@ public partial class Gambling
var currentCard = cards.Draw();
cardObjects.Add(currentCard);
var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
images.Add(Image.Load(await File.ReadAllBytesAsync($"data/images/cards/{cardName}.png")));
images.Add(Image.Load(await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg")));
}
using var img = images.Merge();

View File

@@ -7,6 +7,7 @@ using NadekoBot.Modules.Utility.Patronage;
using NadekoBot.Modules.Gambling.Bank;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Modules.Utility.Services;
using NadekoBot.Services.Currency;
using NadekoBot.Services.Database.Models;
using System.Collections.Immutable;
@@ -44,6 +45,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly GamblingConfigService _configService;
private readonly IBankService _bank;
private readonly IPatronageService _ps;
private readonly RemindService _remind;
private IUserMessage rdMsg;
@@ -54,7 +56,8 @@ public partial class Gambling : GamblingModule<GamblingService>
DownloadTracker tracker,
GamblingConfigService configService,
IBankService bank,
IPatronageService ps)
IPatronageService ps,
RemindService remind)
: base(configService)
{
_db = db;
@@ -62,6 +65,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_client = client;
_bank = bank;
_ps = ps;
_remind = remind;
_enUsCulture = new CultureInfo("en-US", false).NumberFormat;
_enUsCulture.NumberDecimalDigits = 0;
@@ -109,7 +113,40 @@ public partial class Gambling : GamblingModule<GamblingService>
Key = "timely:extra_percent",
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]
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 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]

View File

@@ -16,7 +16,7 @@ namespace NadekoBot.Modules.Gambling.Services;
public class PlantPickService : INService, IExecNoCommand
{
//channelId/last generation
public ConcurrentDictionary<ulong, DateTime> LastGenerations { get; } = new();
public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
private readonly DbService _db;
private readonly IBotStrings _strings;
private readonly IImageCache _images;
@@ -175,15 +175,15 @@ public class PlantPickService : INService, IExecNoCommand
try
{
var config = _gss.Data;
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue);
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
var rng = new NadekoRandom();
if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown)
< lastGeneration) //recently generated in this channel, don't generate again
< DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again
return;
var num = rng.Next(1, 101) + (config.Generation.Chance * 100);
if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow, lastGeneration))
if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration))
{
var dropAmount = config.Generation.MinAmount;
var dropAmountMax = config.Generation.MaxAmount;

View File

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

@@ -4,6 +4,7 @@ using Newtonsoft.Json.Linq;
namespace NadekoBot.Modules.Nsfw;
#if !GLOBAL_NADEKO
[NoPublicBot]
public partial class NSFW : NadekoModule<ISearchImagesService>
{
@@ -403,15 +404,19 @@ public partial class NSFW : NadekoModule<ISearchImagesService>
.Join(" ");
var embed = _eb.Create()
.WithTitle(g.Title)
.WithDescription(g.FullTitle)
.WithImageUrl(g.Thumbnail)
.WithUrl(g.Url)
.AddField(GetText(strs.favorites), g.Likes, true)
.AddField(GetText(strs.pages), g.PageCount, true)
.AddField(GetText(strs.tags), tagString, true)
.WithFooter(g.UploadedAt.ToString("f"))
.WithOkColor();
.WithTitle(g.Title)
.WithDescription(g.FullTitle)
.WithImageUrl(g.Thumbnail)
.WithUrl(g.Url)
.AddField(GetText(strs.favorites), g.Likes, true)
.AddField(GetText(strs.pages), g.PageCount, true)
.AddField(GetText(strs.tags),
string.IsNullOrWhiteSpace(tagString)
? "?"
: tagString,
true)
.WithFooter(g.UploadedAt.ToString("f"))
.WithOkColor();
await ctx.Channel.EmbedAsync(embed);
}
@@ -436,4 +441,5 @@ public partial class NSFW : NadekoModule<ISearchImagesService>
.WithFooter(
$"{data.Rating} ({data.Provider}) | {string.Join(" | ", data.Tags.Where(x => !string.IsNullOrWhiteSpace(x)).Take(5))}"));
}
}
}
#endif

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 NadekoBot.Modules.Nsfw.Common;
using NadekoBot.Modules.Searches.Common;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
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
{
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> AutoBoobTimers { get; } = new();
public ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; } = new();
private readonly Random _rng;
private readonly SearchImageCacher _cache;
private readonly IHttpClientFactory _httpFactory;
private readonly DbService _db;
private readonly INhentaiService _nh;
private readonly object _taglock = new();
public SearchImagesService(
DbService db,
SearchImageCacher cacher,
IHttpClientFactory httpFactory)
IHttpClientFactory httpFactory,
INhentaiService nh)
{
_nh = nh;
_db = db;
_rng = new NadekoRandom();
_cache = cacher;
@@ -284,85 +278,18 @@ public class SearchImagesService : ISearchImagesService, INService
#region Nhentai
private string GetNhentaiExtensionInternal(string s)
=> s switch
{
"j" => "jpg",
"p" => "png",
"g" => "gif",
_ => "jpg"
};
public Task<Gallery?> GetNhentaiByIdAsync(uint id)
=> _nh.GetAsync(id);
private Gallery ModelToGallery(NhentaiApiModel.Gallery model)
public async Task<Gallery?> GetNhentaiBySearchAsync(string search)
{
var thumbnail = $"https://t.nhentai.net/galleries/{model.MediaId}/thumb."
+ GetNhentaiExtensionInternal(model.Images.Thumbnail.T);
var ids = await _nh.GetIdsBySearchAsync(search);
var url = $"https://nhentai.net/g/{model.Id}";
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);
if (ids.Count == 0)
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 ModelToGallery(models[_rng.Next(0, models.Length)]);
var id = ids[_rng.Next(0, ids.Count)];
return await _nh.GetAsync(id);
}
#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

@@ -50,7 +50,7 @@ public sealed class FilterService : IExecOnMessage
new(configs.SelectMany(gc => gc.FilterLinksChannelIds.Select(fci => fci.ChannelId)));
var dict = configs.ToDictionary(gc => gc.GuildId,
gc => new ConcurrentHashSet<string>(gc.FilteredWords.Select(fw => fw.Word)));
gc => new ConcurrentHashSet<string>(gc.FilteredWords.Select(fw => fw.Word).Distinct()));
ServerFilteredWords = new(dict);

View File

@@ -9,7 +9,7 @@ public sealed class Tag
public sealed class Gallery
{
public string Id { get; }
public uint Id { get; }
public string Url { get; }
public string FullTitle { get; }
public string Title { get; }
@@ -21,7 +21,7 @@ public sealed class Gallery
public Gallery(
string id,
uint id,
string url,
string fullTitle,
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,
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;
}
@@ -53,37 +54,72 @@ public class CommandMapService : IInputTransformer, INService
{
if (guild is null || string.IsNullOrWhiteSpace(input))
return null;
if (AliasMaps.TryGetValue(guild.Id, out var maps))
{
var keys = maps.Keys.OrderByDescending(x => x.Length);
foreach (var k in keys)
string newInput = null;
foreach (var (k, v) in maps)
{
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
if (string.Equals(input, k, StringComparison.OrdinalIgnoreCase))
{
var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
_ = Task.Run(async () =>
{
await Task.Delay(1500);
await toDelete.DeleteAsync(new()
{
RetryMode = RetryMode.AlwaysRetry
});
});
newInput = v;
}
else if (input.StartsWith(k + ' ', StringComparison.OrdinalIgnoreCase))
{
newInput = v + ' ' + input[k.Length..];
}
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;

View File

@@ -35,6 +35,7 @@ public sealed class PatronageService
= new($"quota:last_hourly_reset");
private readonly IBotCache _cache;
private readonly IBotCredsProvider _creds;
public PatronageService(
PatronageConfig pConf,
@@ -42,7 +43,8 @@ public sealed class PatronageService
DiscordSocketClient client,
ISubscriptionHandler subsHandler,
IEmbedBuilderService eb,
IBotCache cache)
IBotCache cache,
IBotCredsProvider creds)
{
_pConf = pConf;
_db = db;
@@ -50,6 +52,7 @@ public sealed class PatronageService
_subsHandler = subsHandler;
_eb = eb;
_cache = cache;
_creds = creds;
}
public Task OnReadyAsync()
@@ -495,8 +498,8 @@ public sealed class PatronageService
if (!confData.IsEnabled)
return default;
// if (_creds.IsOwner(userId))
// return default;
if (_creds.GetCreds().IsOwner(userId))
return default;
// get user tier
var patron = await GetPatronAsync(userId);

View File

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

View File

@@ -178,4 +178,27 @@ public class RemindService : INService, IReadyExecutor
public string What { 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)
=> 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)
{
@@ -239,7 +239,7 @@ public class StreamRoleService : IReadyExecutor, INService
&& setting.Blacklist.All(x => x.UserId != user.Id)
&& user.RoleIds.Contains(setting.FromRoleId))
{
await _queueRunner.Enqueue(async () =>
await _queueRunner.EnqueueAsync(async () =>
{
try
{
@@ -277,7 +277,7 @@ public class StreamRoleService : IReadyExecutor, INService
//check if user is in the addrole
if (user.RoleIds.Contains(setting.AddRoleId))
{
await _queueRunner.Enqueue(async () =>
await _queueRunner.EnqueueAsync(async () =>
{
try
{

View File

@@ -1,4 +1,5 @@
#nullable disable
using LinqToDB;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
@@ -12,7 +13,7 @@ using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using StackExchange.Redis;
using System.Threading.Channels;
using Color = SixLabors.ImageSharp.Color;
using Image = SixLabors.ImageSharp.Image;
@@ -37,13 +38,16 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _excludedChannels;
private readonly ConcurrentHashSet<ulong> _excludedServers;
private readonly System.Collections.Concurrent.ConcurrentQueue<UserCacheItem> _addMessageXp = new();
private XpTemplate template;
private readonly DiscordSocketClient _client;
private readonly TypedKey<bool> _xpTemplateReloadKey;
private readonly IPatronageService _ps;
private readonly IBotCache _c;
private readonly QueueRunner _levelUpQueue = new QueueRunner(0, 50);
private readonly Channel<UserXpGainData> _xpGainQueue = Channel.CreateUnbounded<UserXpGainData>();
public XpService(
DiscordSocketClient client,
@@ -122,147 +126,131 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
public async Task OnReadyAsync()
{
_ = Task.Run(() => _levelUpQueue.RunAsync());
using var timer = new PeriodicTimer(5.Seconds());
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
{
var toNotify =
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>();
while (_addMessageXp.TryDequeue(out var usr))
toAddTo.Add(usr);
var group = toAddTo.GroupBy(x => (GuildId: x.Guild.Id, x.User));
if (toAddTo.Count == 0)
return;
await using (var uow = _db.GetDbContext())
var reader = _xpGainQueue.Reader;
// sum up all gains into a single UserCacheItem
var globalToAdd = new Dictionary<ulong, UserXpGainData>();
var guildToAdd = new Dictionary<ulong, Dictionary<ulong, UserXpGainData>>();
while (reader.TryRead(out var item))
{
foreach (var item in group)
// add global xp to these users
if (!globalToAdd.TryGetValue(item.User.Id, out var ci))
globalToAdd[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
// ad guild xp in these guilds to these users
if (!guildToAdd.TryGetValue(item.Guild.Id, out var users))
users = guildToAdd[item.Guild.Id] = new();
if (!users.TryGetValue(item.User.Id, out ci))
users[item.User.Id] = item.Clone();
else
ci.XpAmount += item.XpAmount;
}
await using var ctx = _db.GetDbContext();
await using var tran = await ctx.Database.BeginTransactionAsync();
// update global user xp in batches
// group by xp amount and update the same amounts at the same time
var dus = new List<DiscordUser>(globalToAdd.Count);
foreach (var group in globalToAdd.GroupBy(x => x.Value.XpAmount, x => x.Key))
{
var items = await ctx.DiscordUser
.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 xp = item.Sum(x => x.XpAmount);
var usr = uow.GetOrCreateUserXpStats(item.Key.GuildId, item.Key.User.Id);
var du = uow.GetOrCreateUser(item.Key.User);
var globalXp = du.TotalXp;
var oldGlobalLevelData = new LevelStats(globalXp);
var newGlobalLevelData = new LevelStats(globalXp + xp);
var oldGuildLevelData = new LevelStats(usr.Xp + usr.AwardedXp);
usr.Xp += xp;
du.TotalXp += xp;
if (du.Club is not null)
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 items = await ctx
.UserXpStats
.Where(x => x.GuildId == guildId)
.Where(x => group.Contains(x.UserId))
.UpdateWithOutputAsync(old => new()
{
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);
}
}
Xp = old.Xp + group.Key
},
(_, n) => n);
//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"));
}
}
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));
}
uow.SaveChanges();
}
await toNotify.Select(async x =>
{
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;
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);
await chan.SendConfirmAsync(_eb,
_strings.GetText(strs.level_up_global(x.User.Mention,
Format.Bold(x.Level.ToString())),
x.Guild.Id));
}
})
.WhenAll();
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)
{
@@ -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 void InternalReloadXpTemplate()
{
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. "
+ "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.Version = 1;
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)
await ScanChannelForVoiceXp(after.VoiceChannel);
else if (after.VoiceChannel is null)
{
// 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.
await UserLeftVoiceChannel(user, before.VoiceChannel);
}
});
return Task.CompletedTask;
@@ -546,7 +645,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (actualXp > 0)
{
_addMessageXp.Enqueue(new()
await _xpGainQueue.Writer.WriteAsync(new()
{
Guild = channel.Guild,
User = user,
@@ -593,7 +692,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (!await SetUserRewardedAsync(user.Id))
return;
_addMessageXp.Enqueue(new()
await _xpGainQueue.Writer.WriteAsync(new()
{
Guild = user.Guild,
Channel = arg.Channel,
@@ -604,19 +703,19 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
return Task.CompletedTask;
}
public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount));
_addMessageXp.Enqueue(new()
{
Guild = user.Guild,
Channel = channel,
User = user,
XpAmount = amount
});
}
// public void AddXpDirectly(IGuildUser user, IMessageChannel channel, int amount)
// {
// if (amount <= 0)
// throw new ArgumentOutOfRangeException(nameof(amount));
//
// _xpGainQueue.Writer.WriteAsync(new()
// {
// Guild = user.Guild,
// Channel = channel,
// User = user,
// XpAmount = amount
// });
// }
public void AddXp(ulong userId, ulong guildId, int amount)
{
@@ -649,7 +748,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
private static TypedKey<bool> GetUserRewKey(ulong userId)
=> new($"xp:user_gain:{userId}");
private async Task<bool> SetUserRewardedAsync(ulong userId)
=> await _c.AddAsync(GetUserRewKey(userId),
true,
@@ -829,8 +928,8 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
return font;
}
if (template.User.GlobalLevel.Show)
{
// up to 83 width
@@ -841,7 +940,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
FontStyle.Bold,
stats.Global.Level.ToString(),
75);
img.Mutate(x =>
{
x.DrawText(stats.Global.Level.ToString(),
@@ -859,7 +958,7 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
FontStyle.Bold,
stats.Guild.Level.ToString(),
75);
img.Mutate(x =>
{
x.DrawText(stats.Guild.Level.ToString(),
@@ -936,14 +1035,14 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (template.User.GlobalRank.Show)
{
var globalRankStr = stats.GlobalRanking.ToString();
var globalRankFont = GetTruncatedFont(
_fonts.UniSans,
template.User.GlobalRank.FontSize,
FontStyle.Bold,
globalRankStr,
68);
img.Mutate(x => x.DrawText(globalRankStr,
globalRankFont,
template.User.GlobalRank.Color,
@@ -953,20 +1052,20 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
if (template.User.GuildRank.Show)
{
var guildRankStr = stats.GuildRanking.ToString();
var guildRankFont = GetTruncatedFont(
_fonts.UniSans,
template.User.GuildRank.FontSize,
FontStyle.Bold,
guildRankStr,
43);
img.Mutate(x => x.DrawText(guildRankStr,
guildRankFont,
template.User.GuildRank.Color,
new(template.User.GuildRank.Pos.X, template.User.GuildRank.Pos.Y)));
}
//avatar
if (stats.User.AvatarId is not null && template.User.Icon.Show)
{
@@ -1018,13 +1117,13 @@ public class XpService : INService, IReadyExecutor, IExecNoCommand
// #if GLOBAL_NADEKO
await DrawFrame(img, stats.User.UserId);
// #endif
var outputSize = template.OutputSize;
if (outputSize.X != img.Width || outputSize.Y != img.Height)
img.Mutate(x => x.Resize(template.OutputSize.X, template.OutputSize.Y));
var output = ((Stream)await img.ToStreamAsync(imageFormat), imageFormat);
return output;
});

View File

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

View File

@@ -1,16 +1,13 @@
#nullable disable
using Cloneable;
namespace NadekoBot.Modules.Xp.Services;
public class UserCacheItem
[Cloneable]
public sealed partial class UserXpGainData : ICloneable<UserXpGainData>
{
public IGuildUser User { get; set; }
public IGuild Guild { get; set; }
public IMessageChannel Channel { 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, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
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);
private static TypedKey<PokemonNameId[]> _pokeMapKey
= new("pokemon:ab_map");
private static TypedKey<IReadOnlyDictionary<int, string>> _pokeMapKey
= new("pokemon:ab_map2"); // 2 because ab_map was storing arrays
public async Task<PokemonNameId[]?> GetPokemonMapAsync()
=> await GetOrCreateCachedDataAsync(_pokeMapKey, POKEMON_MAP_PATH);
public async Task<IReadOnlyDictionary<int, string>?> GetPokemonMapAsync()
=> 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -601,9 +601,12 @@ quoteshow:
args:
- "123"
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:
- "keyword text"
- "\"find this long text\""
- "AuthorName"
- "keyword some text"
- "keyword AuthorName"
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."
args:

View File

@@ -596,6 +596,7 @@
"quote_deleted": "Quote #{0} deleted.",
"region": "Region",
"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_too_long": "Remind time has exceeded maximum.",
"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_none": "Users will not be able to claim any timely currency.",
"timely_reset": "All users will be able to claim timely currency again.",
"timely_time": "It's time for your timely reward.",
"price": "Price",
"market_cap": "Market Cap",
"market_cap_dominance": "Dominance",