Compare commits

...

12 Commits
5.3.5 ... 5.3.9

Author SHA1 Message Date
Kwoth
49ff0dd27a add: added .todo archive done <name>, to create an alternative to .todo archive add <name> in case you want to create an archive of only currently completed todos
docs: Updated CHANGELOG.md, upped version to 5.3.9
2025-01-30 11:52:13 +00:00
Kwoth
2053296154 Merge branch 'v5' of https://gitlab.com/kwoth/nadekobot into v5 2025-01-30 11:42:56 +00:00
Kwoth
42fc0c263d fix: global nadeko captcha patron add will show 12.5% of the time now, down from 20%, and be smaller
change: increased todo and archive limits slightly
2025-01-30 11:42:22 +00:00
Kwoth
cf1d950308 fix: global nadeko captcha patron add will show 12.5% of the time now, down from 20%, and be smaller 2025-01-30 10:52:42 +00:00
Kwoth
0fdccea31c fix: fixed captcha cutting off 2025-01-30 10:50:26 +00:00
Kwoth
2f8f62afcb fix: fixed .stock command, probably 2025-01-30 10:00:00 +00:00
Kwoth
570f39d4f8 change: remind now has a 1 year max timeout, up from 2 months 2025-01-29 07:57:31 +00:00
Kwoth
40f1774655 fix: fixed .temprole not giving the role 2025-01-27 20:03:13 +00:00
Kwoth
fddd0f2340 fix: added missing files from previous commit 2025-01-21 01:22:56 +00:00
Kwoth
86f9d901fe changed: You can now run .prune in dms to delete 100 of bot's messages
docs: Upped version to 5.3.7
docs: Updated commandlist
2025-01-21 01:21:57 +00:00
Kwoth
eaab60898f fix: public bot fix 2025-01-20 00:53:24 +00:00
Kwoth
e40c9335c1 add: Added fishing skill stat
fix: fixed fishing spot calculation
change: patrons no longer have captchas on the public bot
docs: Upped version to 5.3.6
2025-01-20 00:48:37 +00:00
27 changed files with 8094 additions and 215 deletions

View File

@@ -2,6 +2,59 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [5.3.9] - 30.01.2025
## Added
- Added `.todo archive done <name>`
- Creates an archive of only currently completed todos
- An alternative to ".todo archive add <name>" which moves all todos to an archive
## Changed
- Increased todo and archive limits slightly
- Global nadeko captcha patron ad will show 12.5% of the time now, down from 20%, and be smaller
- `.remind` now has a 1 year max timeout, up from 2 months
## Fixed
- Captcha is now slightly bigger, with larger margin, to mitigate phone edge issues
- Fixed `.stock` command, unless there is some ip blocking going on
## [5.3.8] - 27.01.2025
## Fixed
- `.temprole` now correctly adds a role
- `.h temprole` also shows the correct overload now
## [5.3.7] - 21.01.2025
## Changed
- You can now run `.prune` in DMs
- It deletes only bot messages
- You can't specify a number of messages to delete (100 default)
- Updated command list
## [5.3.6] - 20.01.2025
## Added
- Added player skill stat when fishing
- Starts at 0, goes up to 100
- Every time you fish you have a chance to get an extra skill point
- Higher skill gives you more chance to catch fish (and therefore less chance to catch trash)
## Changed
- Patrons no longer have `.timely` and `.fish` captcha on the public bot
## Fixed
- Fixed fishing spots again (Your channels will once again change a spot, last time hopefully)
- There was a mistake in spot calculation for each channel
## [5.3.5] - 17.01.2025
## Fixed

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class fishskill : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "userfishstats",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
skill = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_userfishstats", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_userfishstats_userid",
table: "userfishstats",
column: "userid",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "userfishstats");
}
}
}

View File

@@ -3408,6 +3408,33 @@ namespace NadekoBot.Migrations.PostgreSql
b.ToTable("fishcatch", (string)null);
});
modelBuilder.Entity("NadekoBot.Modules.Games.UserFishStats", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Skill")
.HasColumnType("integer")
.HasColumnName("skill");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.HasKey("Id")
.HasName("pk_userfishstats");
b.HasIndex("UserId")
.IsUnique()
.HasDatabaseName("ix_userfishstats_userid");
b.ToTable("userfishstats", (string)null);
});
modelBuilder.Entity("NadekoBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
/// <inheritdoc />
public partial class fishskill : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserFishStats",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
Skill = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserFishStats", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_UserFishStats_UserId",
table: "UserFishStats",
column: "UserId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserFishStats");
}
}
}

View File

@@ -2533,6 +2533,26 @@ namespace NadekoBot.Migrations
b.ToTable("FishCatch");
});
modelBuilder.Entity("NadekoBot.Modules.Games.UserFishStats", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Skill")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("UserFishStats");
});
modelBuilder.Entity("NadekoBot.Services.GreetSettings", b =>
{
b.Property<int>("Id")

View File

@@ -32,6 +32,25 @@ public partial class Administration
}
}
[Cmd]
[RequireContext(ContextType.DM)]
[NadekoOptions<PruneOptions>]
public async Task Prune()
{
var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync();
var progress = GetProgressTracker(progressMsg);
var result = await _service.PruneWhere(ctx.Channel,
100,
x => x.Author.Id == ctx.Client.CurrentUser.Id,
progress);
ctx.Message.DeleteAfter(3);
await SendResult(result);
await progressMsg.DeleteAsync();
}
//deletes her own messages, no perm required
[Cmd]
[RequireContext(ContextType.Guild)]
@@ -114,9 +133,9 @@ public partial class Administration
await progressMsg.ModifyAsync(props =>
{
props.Embed = CreateEmbed()
.WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build();
.WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build();
});
}
catch

View File

@@ -18,7 +18,7 @@ public class PruneService : INService
}
public async Task<PruneResult> PruneWhere(
ITextChannel channel,
IMessageChannel channel,
int amount,
Func<IMessage, bool> predicate,
IProgress<(int deleted, int total)> progress,
@@ -30,13 +30,14 @@ public class PruneService : INService
var originalAmount = amount;
var gid = (channel as ITextChannel)?.GuildId ?? channel.Id;
using var cancelSource = new CancellationTokenSource();
if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource))
if (!_pruningGuilds.TryAdd(gid, cancelSource))
return PruneResult.AlreadyRunning;
try
{
if (!await _ps.LimitHitAsync(LimitedFeatureName.Prune, channel.Guild.OwnerId))
if (channel is ITextChannel tc && !await _ps.LimitHitAsync(LimitedFeatureName.Prune, tc.Guild.OwnerId))
{
return PruneResult.FeatureLimit;
}
@@ -74,9 +75,9 @@ public class PruneService : INService
singleDeletable.Add(x);
}
if (bulkDeletable.Count > 0)
if (channel is ITextChannel tc2 && bulkDeletable.Count > 0)
{
await channel.DeleteMessagesAsync(bulkDeletable);
await tc2.DeleteMessagesAsync(bulkDeletable);
amount -= msgs.Length;
progress.Report((originalAmount - amount, originalAmount));
await Task.Delay(2000, cancelSource.Token);
@@ -97,7 +98,7 @@ public class PruneService : INService
}
finally
{
_pruningGuilds.TryRemove(channel.GuildId, out _);
_pruningGuilds.TryRemove(gid, out _);
}
return PruneResult.Success;

View File

@@ -221,7 +221,7 @@ public partial class Administration
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task TempRole(ParsedTimespan timespan, IUser user, [Leftover] IRole role)
public async Task TempRole(ParsedTimespan timespan, IGuildUser user, [Leftover] IRole role)
{
if (!await CheckRoleHierarchy(role))
{
@@ -231,6 +231,7 @@ public partial class Administration
return;
}
await user.AddRoleAsync(role);
await _tempRoleService.AddTempRoleAsync(ctx.Guild.Id, role.Id, user.Id, timespan.Time);

View File

@@ -13,6 +13,7 @@ using System.Globalization;
using System.Text;
using NadekoBot.Modules.Gambling.Rps;
using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Games;
using NadekoBot.Modules.Patronage;
using SixLabors.Fonts;
using SixLabors.Fonts.Unicode;
@@ -40,6 +41,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly IPatronageService _ps;
private readonly RakebackService _rb;
private readonly IBotCache _cache;
private readonly CaptchaService _captchaService;
public Gambling(
IGamblingService gs,
@@ -54,7 +56,8 @@ public partial class Gambling : GamblingModule<GamblingService>
IPatronageService patronage,
GamblingTxTracker gamblingTxTracker,
RakebackService rb,
IBotCache cache)
IBotCache cache,
CaptchaService captchaService)
: base(configService)
{
_gs = gs;
@@ -66,6 +69,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_gamblingTxTracker = gamblingTxTracker;
_rb = rb;
_cache = cache;
_captchaService = captchaService;
_ps = patronage;
_rng = new NadekoRandom();
@@ -154,82 +158,42 @@ public partial class Gambling : GamblingModule<GamblingService>
}
else if (Config.Timely.ProtType == TimelyProt.Captcha)
{
var password = await GetUserTimelyPassword(ctx.User.Id);
var img = GetPasswordImage(password);
using var stream = await img.ToStreamAsync();
var captcha = await Response()
.File(stream, "timely.png")
.SendAsync();
try
{
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
{
return;
}
var password = await _captchaService.GetUserCaptcha(ctx.User.Id);
await ClearUserTimelyPassword(ctx.User.Id);
}
finally
if (password is not null)
{
_ = captcha.DeleteAsync();
var img = _captchaService.GetPasswordImage(password);
await using var stream = await img.ToStreamAsync();
var toSend = Response()
.File(stream, "timely.png");
#if GLOBAL_NADEKO
if (_rng.Next(0, 8) == 0)
toSend = toSend
.Text("*[Sub on Patreon](https://patreon.com/nadekobot) to remove captcha.*");
#endif
var captchaMessage = await toSend.SendAsync();
try
{
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
{
return;
}
await _captchaService.ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captchaMessage.DeleteAsync();
}
}
}
await ClaimTimely();
}
private static TypedKey<string> TimelyPasswordKey(ulong userId)
=> new($"timely_password:{userId}");
private async Task<string> GetUserTimelyPassword(ulong userId)
{
var pw = await _cache.GetOrAddAsync(TimelyPasswordKey(userId),
() =>
{
var password = _service.GeneratePassword();
return Task.FromResult(password);
});
return pw;
}
private ValueTask<bool> ClearUserTimelyPassword(ulong userId)
=> _cache.RemoveAsync(TimelyPasswordKey(userId));
private Image<Rgba32> GetPasswordImage(string password)
{
var img = new Image<Rgba32>(50, 24);
var font = _fonts.NotoSans.CreateFont(22);
var outlinePen = new SolidPen(Color.Black, 0.5f);
var strikeoutRun = new RichTextRun
{
Start = 0,
End = password.GetGraphemeCount(),
Font = font,
StrikeoutPen = new SolidPen(Color.White, 4),
TextDecorations = TextDecorations.Strikeout
};
// draw password on the image
img.Mutate(x =>
{
x.DrawText(new RichTextOptions(font)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = _fonts.FallBackFonts,
Origin = new(25, 12),
TextRuns = [strikeoutRun]
},
password,
Brushes.Solid(Color.White),
outlinePen);
});
return img;
}
private async Task ClaimTimely()
{
var period = Config.Timely.Cooldown;

View File

@@ -1,4 +1,6 @@
using SixLabors.Fonts;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Patronage;
using SixLabors.Fonts;
using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
@@ -9,13 +11,13 @@ using Color = SixLabors.ImageSharp.Color;
namespace NadekoBot.Modules.Games;
public sealed class CaptchaService(FontProvider fonts) : INService
public sealed class CaptchaService(FontProvider fonts, IBotCache cache, IPatronageService ps) : INService
{
private readonly NadekoRandom _rng = new();
public Image<Rgba32> GetPasswordImage(string password)
{
var img = new Image<Rgba32>(50, 24);
var img = new Image<Rgba32>(60, 34);
var font = fonts.NotoSans.CreateFont(22);
var outlinePen = new SolidPen(Color.Black, 0.5f);
@@ -37,7 +39,7 @@ public sealed class CaptchaService(FontProvider fonts) : INService
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = fonts.FallBackFonts,
Origin = new(25, 12),
Origin = new(30, 15),
TextRuns = [strikeoutRun]
},
password,
@@ -53,4 +55,26 @@ public sealed class CaptchaService(FontProvider fonts) : INService
var num = _rng.Next((int)Math.Pow(31, 2), (int)Math.Pow(32, 3));
return new kwum(num).ToString();
}
private static TypedKey<string> CaptchaPasswordKey(ulong userId)
=> new($"timely_password:{userId}");
public async Task<string?> GetUserCaptcha(ulong userId)
{
var patron = await ps.GetPatronAsync(userId);
if (patron is Patron p && !p.ValidThru.IsBeforeToday())
return null;
var pw = await cache.GetOrAddAsync(CaptchaPasswordKey(userId),
() =>
{
var password = GeneratePassword();
return Task.FromResult(password)!;
});
return pw;
}
public ValueTask<bool> ClearUserCaptcha(ulong userId)
=> cache.RemoveAsync(CaptchaPasswordKey(userId));
}

View File

@@ -1,4 +1,5 @@
using System.Text;
using System.ComponentModel.DataAnnotations;
using System.Text;
using Format = Discord.Format;
namespace NadekoBot.Modules.Games;
@@ -9,8 +10,10 @@ public partial class Games
FishService fs,
FishConfigService fcs,
IBotCache cache,
CaptchaService service) : NadekoModule
CaptchaService captchaService) : NadekoModule
{
private static readonly NadekoRandom _rng = new();
private TypedKey<bool> FishingWhitelistKey(ulong userId)
=> new($"fishingwhitelist:{userId}");
@@ -20,29 +23,39 @@ public partial class Games
var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
if (cRes.TryPickT1(out _, out _))
{
var password = await GetUserCaptcha(ctx.User.Id);
var img = service.GetPasswordImage(password);
using var stream = await img.ToStreamAsync();
var captcha = await Response()
.File(stream, "timely.png")
.SendAsync();
try
var password = await captchaService.GetUserCaptcha(ctx.User.Id);
if (password is not null)
{
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
var img = captchaService.GetPasswordImage(password);
using var stream = await img.ToStreamAsync();
var toSend = Response()
.File(stream, "timely.png");
#if GLOBAL_NADEKO
if (_rng.Next(0, 8) == 0)
toSend = toSend
.Text("*[Sub on Patreon](https://patreon.com/nadekobot) to remove captcha.*");
#endif
var captcha = await toSend.SendAsync();
try
{
return;
}
var userInput = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
if (userInput?.ToLowerInvariant() != password?.ToLowerInvariant())
{
return;
}
// whitelist the user for 30 minutes
await cache.AddAsync(FishingWhitelistKey(ctx.User.Id), true, TimeSpan.FromMinutes(30));
// reset the password
await ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captcha.DeleteAsync();
// whitelist the user for 30 minutes
await cache.AddAsync(FishingWhitelistKey(ctx.User.Id), true, TimeSpan.FromMinutes(30));
// reset the password
await captchaService.ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captcha.DeleteAsync();
}
}
}
@@ -76,12 +89,18 @@ public partial class Games
return;
}
var desc = GetText(strs.fish_caught(res.Fish.Emoji + " " + Format.Bold(res.Fish.Name)));
if (res.IsSkillUp)
{
desc += "\n" + GetText(strs.fish_skill_up(res.Skill, res.MaxSkill));
}
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.fish_caught(Format.Bold(res.Fish.Name))))
.WithDescription(desc)
.AddField(GetText(strs.fish_quality), GetStarText(res.Stars, res.Fish.Stars), true)
.AddField(GetText(strs.desc), res.Fish.Fluff, true)
.WithThumbnailUrl(res.Fish.Image))
@@ -117,8 +136,8 @@ public partial class Games
var fishes = await fs.GetAllFish();
Log.Information(fishes.Count.ToString());
var catches = await fs.GetUserCatches(ctx.User.Id);
var (skill, maxSkill) = await fs.GetSkill(ctx.User.Id);
var catchDict = catches.ToDictionary(x => x.FishId, x => x);
@@ -130,7 +149,10 @@ public partial class Games
.Page((fs, i) =>
{
var eb = CreateEmbed()
.WithOkColor();
.WithDescription($"🧠 **Skill:** {skill} / {maxSkill}")
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_list_title))
.WithOkColor();
foreach (var f in fs)
{
@@ -224,48 +246,9 @@ public partial class Games
return sb.ToString();
}
private static TypedKey<string> CaptchaPasswordKey(ulong userId)
=> new($"timely_password:{userId}");
private async Task<string> GetUserCaptcha(ulong userId)
{
var pw = await cache.GetOrAddAsync(CaptchaPasswordKey(userId),
() =>
{
var password = service.GeneratePassword();
return Task.FromResult(password)!;
});
return pw!;
}
private ValueTask<bool> ClearUserCaptcha(ulong userId)
=> cache.RemoveAsync(CaptchaPasswordKey(userId));
}
}
//
// public sealed class UserFishStats
// {
// [Key]
// public int Id { get; set; }
//
// public ulong UserId { get; set; }
//
// public ulong CommonCatches { get; set; }
// public ulong RareCatches { get; set; }
// public ulong VeryRareCatches { get; set; }
// public ulong EpicCatches { get; set; }
//
// public ulong CommonMaxCatches { get; set; }
// public ulong RareMaxCatches { get; set; }
// public ulong VeryRareMaxCatches { get; set; }
// public ulong EpicMaxCatches { get; set; }
//
// public int TotalStars { get; set; }
// }
public enum FishingSpot
{
Ocean,

View File

@@ -4,6 +4,9 @@ public sealed class FishResult
{
public required FishData Fish { get; init; }
public int Stars { get; init; }
public bool IsSkillUp { get; set; }
public int Skill { get; set; }
public int MaxSkill { get; set; }
}
public readonly record struct AlreadyFishing;

View File

@@ -1,11 +1,16 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
namespace NadekoBot.Modules.Games;
public sealed class FishService(FishConfigService fcs, IBotCache cache, DbService db) : INService
{
public const double MAX_SKILL = 100;
private Random _rng = new Random();
private static TypedKey<bool> FishingKey(ulong userId)
@@ -28,20 +33,104 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
var conf = fcs.Data;
await Task.Delay(TimeSpan.FromSeconds(duration));
var (playerSkill, _) = await GetSkill(userId);
var fishChanceMultiplier = Math.Clamp((playerSkill + 20) / MAX_SKILL, 0, 1);
var trashChanceMultiplier = Math.Clamp(((2 * MAX_SKILL) - playerSkill) / MAX_SKILL, 1, 2);
var nothingChance = conf.Chance.Nothing;
var fishChance = conf.Chance.Fish * fishChanceMultiplier;
var trashChance = conf.Chance.Trash * trashChanceMultiplier;
// first roll whether it's fish, trash or nothing
var totalChance = conf.Chance.Fish + conf.Chance.Trash + conf.Chance.Nothing;
var totalChance = fishChance + trashChance + conf.Chance.Nothing;
var typeRoll = _rng.NextDouble() * totalChance;
if (typeRoll < conf.Chance.Nothing)
if (typeRoll < nothingChance)
{
return null;
}
var items = typeRoll < conf.Chance.Nothing + conf.Chance.Fish
var items = typeRoll < nothingChance + fishChance
? conf.Fish
: conf.Trash;
return await FishAsyncInternal(userId, channelId, items);
var result = await FishAsyncInternal(userId, channelId, items);
if (result is not null)
{
var isSkillUp = await TrySkillUpAsync(userId, playerSkill);
result.IsSkillUp = isSkillUp;
result.MaxSkill = (int)MAX_SKILL;
result.Skill = playerSkill;
if (isSkillUp)
{
result.Skill += 1;
}
}
return result;
}
private async Task<bool> TrySkillUpAsync(ulong userId, int playerSkill)
{
var skillUpProb = GetSkillUpProb(playerSkill);
var rng = _rng.NextDouble();
if (rng < skillUpProb)
{
await using var ctx = db.GetDbContext();
var maxSkill = (int)MAX_SKILL;
await ctx.GetTable<UserFishStats>()
.InsertOrUpdateAsync(() => new()
{
UserId = userId,
Skill = 1,
},
(old) => new()
{
UserId = userId,
Skill = old.Skill > maxSkill ? maxSkill : old.Skill + 1
},
() => new()
{
UserId = userId,
Skill = playerSkill
});
return true;
}
return false;
}
private double GetSkillUpProb(int playerSkill)
{
if (playerSkill < 0)
playerSkill = 0;
if (playerSkill >= 100)
return 0;
return 1 / (Math.Pow(Math.E, playerSkill / 22d));
}
public async Task<(int skill, int maxSkill)> GetSkill(ulong userId)
{
await using var ctx = db.GetDbContext();
var skill = await ctx.GetTable<UserFishStats>()
.Where(x => x.UserId == userId)
.Select(x => x.Skill)
.FirstOrDefaultAsyncLinqToDB();
return (skill, (int)MAX_SKILL);
}
private async Task<FishResult?> FishAsyncInternal(ulong userId, ulong channelId, List<FishData> items)
@@ -133,7 +222,7 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
public FishingSpot GetSpot(ulong channelId)
{
var cid = (channelId >> 22 >> 8) % 10;
var cid = (channelId >> 22 >> 29) % 10;
return cid switch
{
@@ -159,7 +248,6 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
return FishingTime.Day;
return FishingTime.Dusk;
}
private const int WEATHER_PERIODS_PER_DAY = 12;
@@ -297,7 +385,7 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
public async Task<List<FishData>> GetAllFish()
{
await Task.Yield();
var conf = fcs.Data;
return conf.Fish.Concat(conf.Trash).ToList();
}
@@ -312,4 +400,22 @@ public sealed class FishService(FishConfigService fcs, IBotCache cache, DbServic
return catches;
}
}
public sealed class UserFishStats
{
[Key]
public int Id { get; set; }
public ulong UserId { get; set; }
public int Skill { get; set; }
}
public sealed class UserFishStatsConfiguration : IEntityTypeConfiguration<UserFishStats>
{
public void Configure(EntityTypeBuilder<UserFishStats> builder)
{
builder.HasIndex(x => x.UserId)
.IsUnique();
}
}

View File

@@ -2,6 +2,8 @@
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace NadekoBot.Modules.Searches;
@@ -9,54 +11,57 @@ namespace NadekoBot.Modules.Searches;
public sealed class DefaultStockDataService : IStockDataService, INService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IBotCache _cache;
public DefaultStockDataService(IHttpClientFactory httpClientFactory)
=> _httpClientFactory = httpClientFactory;
public DefaultStockDataService(IHttpClientFactory httpClientFactory, IBotCache cache)
=> (_httpClientFactory, _cache) = (httpClientFactory, cache);
private static TypedKey<StockData> GetStockDataKey(string query)
=> new($"stockdata:{query}");
public async Task<StockData?> GetStockDataAsync(string query)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
return await _cache.GetOrAddAsync(GetStockDataKey(query.Trim().ToLowerInvariant()),
() => GetStockDataInternalAsync(query),
expiry: TimeSpan.FromHours(1));
}
public async Task<StockData?> GetStockDataInternalAsync(string query)
{
try
{
if (!query.IsAlphaNumeric())
return default;
using var http = _httpClientFactory.CreateClient();
var info = await GetNasdaqDataResponse<NasdaqSummaryResponse>(
$"https://api.nasdaq.com/api/quote/{query}/summary?assetclass=stocks");
var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}";
var config = Configuration.Default.WithDefaultLoader();
using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
var tickerName = document.QuerySelector("div.top > .left > .container > h1")
?.TextContent;
if (tickerName is null)
if (info?.Data is not { } d || d.SummaryData is not { } sd)
return default;
var marketcap = document
.QuerySelector("li > span > fin-streamer[data-field='marketCap']")
?.TextContent;
var closePrice = double.Parse(sd.PreviousClose.Value?.Substring(1) ?? "0",
NumberStyles.Any,
CultureInfo.InvariantCulture);
var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']")
?.TextContent;
var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']")
?.TextContent
?? "0";
var price = document.QuerySelector("fin-streamer.livePrice > span")
?.TextContent
?? "0";
var price = d.BidAsk.Bid.Value.IndexOf('*') is var idx and > 0
&& double.TryParse(d.BidAsk.Bid.Value.Substring(1, idx - 1),
NumberStyles.Any,
CultureInfo.InvariantCulture,
out var bid)
? bid
: double.NaN;
return new()
{
Name = tickerName,
Symbol = query,
Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture),
Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture),
MarketCap = marketcap,
DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
Name = query,
Symbol = info.Data.Symbol,
Price = price,
Close = closePrice,
MarketCap = sd.MarketCap.Value,
DailyVolume =
(long)double.Parse(sd.AverageVolume.Value ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
};
}
catch (Exception ex)
@@ -66,6 +71,36 @@ public sealed class DefaultStockDataService : IStockDataService, INService
}
}
private async Task<NasdaqDataResponse<T>?> GetNasdaqDataResponse<T>(string url)
{
using var httpClient = _httpClientFactory.CreateClient("google:search");
var req = new HttpRequestMessage(HttpMethod.Get,
url)
{
Headers =
{
{ "Host", "api.nasdaq.com" },
{ "User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0" },
{ "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" },
{ "Accept-Language", "en-US,en;q=0.5" },
{ "Accept-Encoding", "gzip, deflate, br, zstd" },
{ "Connection", "keep-alive" },
{ "Upgrade-Insecure-Requests", "1" },
{ "Sec-Fetch-Dest", "document" },
{ "Sec-Fetch-Mode", "navigate" },
{ "Sec-Fetch-Site", "none" },
{ "Sec-Fetch-User", "?1" },
{ "Priority", "u=0, i" },
{ "TE", "trailers" }
}
};
var res = await httpClient.SendAsync(req);
var info = await res.Content.ReadFromJsonAsync<NasdaqDataResponse<T>>();
return info;
}
public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
@@ -91,22 +126,37 @@ public sealed class DefaultStockDataService : IStockDataService, INService
.ToList();
}
private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture);
private static TypedKey<IReadOnlyCollection<CandleData>> GetCandleDataKey(string query)
=> new($"candledata:{query}");
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
=> await _cache.GetOrAddAsync(GetCandleDataKey(query),
async () => await GetCandleDataInternalAsync(query),
expiry: TimeSpan.FromHours(4))
?? [];
public async Task<IReadOnlyCollection<CandleData>> GetCandleDataInternalAsync(string query)
{
using var http = _httpClientFactory.CreateClient();
await using var resStream = await http.GetStreamAsync(
$"https://query1.finance.yahoo.com/v7/finance/download/{query}"
+ $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}"
+ $"&period2={DateTime.UtcNow.ToTimestamp()}"
+ "&interval=1d");
using var textReader = new StreamReader(resStream);
using var csv = new CsvReader(textReader, _csvConfig);
var records = csv.GetRecords<YahooFinanceCandleData>().ToArray();
var now = DateTime.UtcNow;
var fromdate = now.Subtract(30.Days()).ToString("yyyy-MM-dd");
var todate = now.ToString("yyyy-MM-dd");
return records
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume));
var res = await GetNasdaqDataResponse<NasdaqChartResponse>(
$"https://api.nasdaq.com/api/quote/{query}/chart?assetclass=stocks"
+ $"&fromdate={fromdate}"
+ $"&todate={todate}");
if (res?.Data?.Chart is not { } chart)
return Array.Empty<CandleData>();
return chart.Select(d => new CandleData(d.Z.Open,
d.Z.Close,
d.Z.High,
d.Z.Low,
(long)double.Parse(d.Z.Volume, NumberStyles.Any, CultureInfo.InvariantCulture)))
.ToList();
}
}

View File

@@ -0,0 +1,20 @@
namespace NadekoBot.Modules.Searches;
public sealed class NasdaqChartResponse
{
public required NasdaqChartResponseData[] Chart { get; init; }
public sealed class NasdaqChartResponseData
{
public required CandleData Z { get; init; }
public sealed class CandleData
{
public required decimal High { get; init; }
public required decimal Low { get; init; }
public required decimal Open { get; init; }
public required decimal Close { get; init; }
public required string Volume { get; init; }
}
}
}

View File

@@ -0,0 +1,6 @@
namespace NadekoBot.Modules.Searches;
public sealed class NasdaqDataResponse<T>
{
public required T? Data { get; init; }
}

View File

@@ -0,0 +1,44 @@
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches;
public sealed class NasdaqSummaryResponse
{
public required string Symbol { get; init; }
public required NasdaqSummaryResponseData SummaryData { get; init; }
public required NasdaqSummaryBidAsk BidAsk { get; init; }
public sealed class NasdaqSummaryBidAsk
{
[JsonPropertyName("Bid * Size")]
public required NasdaqBid Bid { get; init; }
public sealed class NasdaqBid
{
public required string Value { get; init; }
}
}
public sealed class NasdaqSummaryResponseData
{
public required PreviousCloseData PreviousClose { get; init; }
public required MarketCapData MarketCap { get; init; }
public required AverageVolumeData AverageVolume { get; init; }
public sealed class PreviousCloseData
{
public required string Value { get; init; }
}
public sealed class MarketCapData
{
public required string Value { get; init; }
}
public sealed class AverageVolumeData
{
public required string Value { get; init; }
}
}
}

View File

@@ -183,7 +183,7 @@ public partial class Utility
{
var time = DateTime.UtcNow + ts;
if (ts > TimeSpan.FromDays(60))
if (ts > TimeSpan.FromDays(366))
return false;
if (ctx.Guild is not null)

View File

@@ -150,7 +150,26 @@ public partial class Utility
[Cmd]
public async Task TodoArchiveAdd([Leftover] string name)
{
var result = await _service.ArchiveTodosAsync(ctx.User.Id, name);
var result = await _service.ArchiveTodosAsync(ctx.User.Id, name, false);
if (result == ArchiveTodoResult.NoTodos)
{
await Response().Error(strs.todo_no_todos).SendAsync();
return;
}
if (result == ArchiveTodoResult.MaxLimitReached)
{
await Response().Error(strs.todo_archive_max_limit).SendAsync();
return;
}
await ctx.OkAsync();
}
[Cmd]
public async Task TodoArchiveDone([Leftover] string name)
{
var result = await _service.ArchiveTodosAsync(ctx.User.Id, name, true);
if (result == ArchiveTodoResult.NoTodos)
{
await Response().Error(strs.todo_no_todos).SendAsync();
@@ -193,7 +212,7 @@ public partial class Utility
foreach (var archivedList in items)
{
eb.AddField($"id: {archivedList.Id.ToString()}", archivedList.Name, true);
eb.AddField($"id: {new kwum(archivedList.Id)}", archivedList.Name, true);
}
return eb;
@@ -202,7 +221,7 @@ public partial class Utility
}
[Cmd]
public async Task TodoArchiveShow(int id)
public async Task TodoArchiveShow(kwum id)
{
var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id);
if (list == null || list.Items.Count == 0)
@@ -234,7 +253,7 @@ public partial class Utility
}
[Cmd]
public async Task TodoArchiveDelete(int id)
public async Task TodoArchiveDelete(kwum id)
{
if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id))
{

View File

@@ -6,8 +6,8 @@ namespace NadekoBot.Modules.Utility;
public sealed class TodoService : INService
{
private const int ARCHIVE_MAX_COUNT = 9;
private const int TODO_MAX_COUNT = 27;
private const int ARCHIVE_MAX_COUNT = 18;
private const int TODO_MAX_COUNT = 36;
private readonly DbService _db;
@@ -111,7 +111,7 @@ public sealed class TodoService : INService
.DeleteAsync();
}
public async Task<ArchiveTodoResult> ArchiveTodosAsync(ulong userId, string name)
public async Task<ArchiveTodoResult> ArchiveTodosAsync(ulong userId, string name, bool onlyDone)
{
// create a new archive
@@ -140,7 +140,7 @@ public sealed class TodoService : INService
var updated = await ctx
.GetTable<TodoModel>()
.Where(x => x.UserId == userId && x.ArchiveId == null)
.Where(x => x.UserId == userId && (!onlyDone || x.IsDone) && x.ArchiveId == null)
.Set(x => x.ArchiveId, inserted.Id)
.UpdateAsync();
@@ -204,4 +204,5 @@ public sealed class TodoService : INService
.Where(x => x.UserId == userId && x.Id == todoId)
.FirstOrDefaultAsyncLinqToDB();
}
}

View File

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

View File

@@ -1441,6 +1441,11 @@ todoarchivedelete:
- del
- remove
- rm
todoarchivedone:
- done
- compelete
- finish
- completed
todoedit:
- edit
- change

View File

@@ -974,7 +974,7 @@
"Module": "Administration",
"Options": null,
"Requirements": [
"Bot Owner Only"
"Administrator Server Permission"
]
},
{
@@ -990,7 +990,7 @@
"Module": "Administration",
"Options": null,
"Requirements": [
"Bot Owner Only"
"Administrator Server Permission"
]
},
{
@@ -1008,7 +1008,7 @@
"Module": "Administration",
"Options": null,
"Requirements": [
"Bot Owner Only"
"Administrator Server Permission"
]
},
{
@@ -4045,6 +4045,51 @@
"ManageMessages Server Permission"
]
},
{
"Aliases": [
".fish",
".fi"
],
"Description": "Attempt to catch a fish.\nDifferent fish live in different places, at different times of day and in different weather.",
"Usage": [
".fish"
],
"Submodule": "FishCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".fishspot",
".fisp",
".fish?"
],
"Description": "Shows information about the current fish spot, weather and time.",
"Usage": [
".fishspot"
],
"Submodule": "FishCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".fishlist",
".fili",
".fishes",
".fil"
],
"Description": "Look at your fish catalogue.\nShows how many of each fish you caught and what was the highest quality.\nFor each caught fish, it also shows its required spot, time of day and weather.",
"Usage": [
".fishlist"
],
"Submodule": "FishCommands",
"Module": "Games",
"Options": null,
"Requirements": []
},
{
"Aliases": [
".hangmanlist"

View File

@@ -4524,6 +4524,13 @@ todoarchiveadd:
params:
- name:
desc: "The name of the archive to be created."
todoarchivedone:
desc: Creates a new archive with the specified name using only completed current todos.
ex:
- Success!
params:
- name:
desc: "The name of the archive to be created."
todoarchivelist:
desc: Lists all archived todo lists.
ex:
@@ -4852,11 +4859,11 @@ temprole:
- '15m @User Jail'
- '7d @Newbie Trial Member'
params:
- days:
- time:
desc: "The time after which the role is automatically removed."
- user:
user:
desc: "The user to give the role to."
- role:
role:
desc: "The role to give to the user."
minesweeper:
desc: |-

View File

@@ -1169,5 +1169,7 @@
"fish_weather_duration": "Each weather period lasts for {0} hours.",
"fish_weather_current": "Current",
"fish_weather_forecast": "Forecast",
"fish_tod": "Time of Day"
"fish_tod": "Time of Day",
"fish_skill_up": "Fishing skill increased to **{0} / {1}**",
"fish_list_title": "Fishing"
}