Compare commits

...

41 Commits
5.3.0 ... 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
Kwoth
d921b6889d docs: Updated changelog, upped version to 5.3.5 2025-01-17 14:35:16 +00:00
Kwoth
aaef365bdc fix: .deletewaifus should now work 2025-01-17 13:14:21 +00:00
Kwoth
a01a646cbf fix: .sar rm will now accept role ids, in the role is deleted 2025-01-17 13:13:28 +00:00
Kwoth
5bee5e63d2 fix: fixed .fish description 2025-01-16 00:38:40 +00:00
Kwoth
815e318610 change: nerfed drops further as they're way too common 2025-01-14 19:39:42 +00:00
Kwoth
634c6c99ee fix: fixed spot calculation, reduced droprates 2025-01-14 03:55:32 +00:00
Kwoth
6b37b49439 Merge branch 'exit-code' into 'v5'
Exit with status code 0 on graceful shutdowns

See merge request Kwoth/nadekobot!334
2025-01-14 03:10:12 +00:00
Kaoticz
f42afa7eae Exit with status code 0 on graceful shutdowns 2025-01-14 03:10:12 +00:00
Kwoth
ccae1c59e9 docs: Updated changelog, upped version to 5.3.4 2025-01-14 03:05:43 +00:00
Kwoth
b0d8137a7a add: Added .fish command 2025-01-14 03:00:31 +00:00
Kwoth
e78a7d0efa change: adding a role to a sar group which already exists in another group will simply move it, instead of reporting success but not doing anything 2024-12-27 07:00:06 +00:00
Kwoth
1da19a51f6 fix: postgresql will skip the sar migration, users will have to re-do them 2024-12-26 17:55:25 +00:00
Kwoth
91eed9dbd8 fix: fixed iam with exclusive roles (in some cases?) being broken 2024-12-20 06:54:56 +00:00
Kwoth
7ba345b0fc docs: updated changelog, updated version 2024-12-15 13:18:51 +00:00
Kwoth
5d775c9589 Merge branch 'v5' of https://gitlab.com/kwoth/nadekobot into v5 2024-12-15 13:14:16 +00:00
Kwoth
2bd8ead10c Merge branch 'v5' of https://gitlab.com/kwoth/nadekobot into v5 2024-12-15 13:13:59 +00:00
Kwoth
1148ba3e6e Merge branch 'v5' of https://gitlab.com/kwoth/nadekobot into v5 2024-12-14 15:33:23 +00:00
Kwoth
5498bec8cc fix: .banner fixed, will now show guild banner if available, otherwise global banner, if available 2024-12-14 15:33:09 +00:00
Kwoth
9eed0c6be5 fix: .banner fixed, will now show guild banner if available, otherwise global banner, if available 2024-12-14 15:31:05 +00:00
Kwoth
acf6b7cf58 docs: updated changelog, updated version 2024-12-13 08:28:55 +00:00
Kwoth
8598419c5f fix: .dmcmd will now correctly block commands in dms, not globally
change: timely will no longer require guild context, as dmcmd .timely will do the same thing
2024-12-13 08:22:13 +00:00
Kwoth
758093eb32 fix: bannersize fixed, honeypot will now put 'Honeypot' as a ban reason 2024-12-12 08:56:45 +00:00
Kwoth
f44dd03f1a fix: .sclr will now correctly show the color without alpha prefixed 2024-12-11 23:06:01 +00:00
Kwoth
8ac5ec9f57 fix: .banner will now show a 'no banner' error, and will use image embed instead of thumbnail to show the banner. Only user server banners will be shown for now, global user banners will still not work. 2024-12-11 23:00:59 +00:00
Kwoth
5209ba802a change: winlb now has 9 items per page to look not broken 2024-12-11 17:21:25 +00:00
Kwoth
adfce6670c change: winlb now has a title 2024-12-11 17:13:59 +00:00
Kwoth
f8fbc71985 change: added role icon to .inrole, .winlb will now show userids when user can't be found 2024-12-10 14:16:24 +00:00
Kwoth
d4e2516a17 change: winlb embed fields are now inline to use less space 2024-12-10 11:24:02 +00:00
Kwoth
f62a67e2e6 docs: Updated commandlist 2024-12-10 07:20:47 +00:00
58 changed files with 16740 additions and 201 deletions

View File

@@ -2,7 +2,109 @@
Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [5.3.0] - 07.12.2024 ## [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
- .sar rm will now accept role ids in case the role was deleted
- `.deletewaifus` should work again
## [5.3.4] - 14.01.2025
## Added
- Added `.fish` commands
- `.fish` - Attempt to catch a fish - different fish live in different places, at different times and during different times of the day
- `.fishlist` - Look at your fish catalogue - shows how many of each fish you caught and what was the highest quality - for each caught fish, it also shows its required spot, time of day and weather
- `.fishspot` - Shows information about the current fish spot, time of day and weather
## Fixed
- `.timely` fixed captcha sometimes generating only 2 characters
## [5.3.3] - 15.12.2024
## Fixed
- `.notify` commands are no longer owner only, they now require Admin permissions
- `.notify` messages can now mention anyone
## [5.3.2] - 14.12.2024
## Fixed
- `.banner` should be working properly now with both server and global user banners
## [5.3.1] - 13.12.2024
## Changed
- `.translate` will now use 2 embeds, to allow for longer messages
- Added role icon to `.inrole`, if it exists
- `.honeypot` will now add a 'Honeypot' as a ban reason.
## Fixed
- `.winlb` looks better, has a title, shows 9 entries now
- `.sar ex` help updated
- `.banner` partially fixed, it still can't show global banners, but it will show guild ones correctly, in a good enough size
- `.sclr` will now show correct color hexes without alpha
- `.dmcmd` will now correctly block commands in dms, not globally
## [5.3.0] - 10.12.2024
## Added ## Added
@@ -35,10 +137,12 @@ Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except da
- `.sclr show` will now show hex code of the current color - `.sclr show` will now show hex code of the current color
- Queueing a song will now restart the playback if the queue is on the last track and stopped (there were no more tracks - Queueing a song will now restart the playback if the queue is on the last track and stopped (there were no more tracks
to play) to play)
- `.translate` will now use 2 embeds instead of 1
## Fixed ## Fixed
- .setstream and .setactivity will now pause .ropl (rotating statuses) - .setstream and .setactivity will now pause .ropl (rotating statuses)
- Fixed `.sar ex` help description
## Removed ## Removed

View File

@@ -0,0 +1,88 @@
// using System;
// using System.Collections.Generic;
// using System.Diagnostics;
// using System.IO;
// using System.Linq;
// using Nadeko.Common;
// using NadekoBot.Modules.Games;
// using NUnit.Framework;
//
// namespace NadekoBot.Tests;
//
// public class FishTests
// {
// [Test]
// public void TestWeather()
// {
// var fs = new FishService(null, null);
//
// var rng = new Random();
//
// // output = @"ro+dD:bN0uVqV3ZOAv6r""EFeA'A]u]uSyz2Qd'r#0Vf:5zOX\VgSsF8LgRCL/uOW";
// while (true)
// {
// var output = "";
// for (var i = 0; i < 64; i++)
// {
// var c = (char)rng.Next(33, 123);
// output += c;
// }
//
// output = "";
// var weathers = new List<FishingWeather>();
// for (var i = 0; i < 1_000_000; i++)
// {
// var w = fs.GetWeather(DateTime.UtcNow.AddHours(6 * i), output);
// weathers.Add(w);
// }
//
// var vals = weathers.GroupBy(x => x)
// .ToDictionary(x => x.Key, x => x.Count());
//
// var str = weathers.Select(x => (int)x).Join("");
// var maxLength = MaxLength(str);
//
// if (maxLength < 12)
// {
// foreach (var v in vals)
// {
// Console.WriteLine($"{v.Key}: {v.Value}");
// }
//
// Console.WriteLine(output);
// Console.WriteLine(maxLength);
//
// File.WriteAllText("data.txt", weathers.Select(x => (int)x).Join(""));
//
// break;
// }
// }
// }
//
// // string with same characters
// static int MaxLength(String s)
// {
// int ans = 1, temp = 1;
//
// // Traverse the string
// for (int i = 1; i < s.Length; i++)
// {
// // If character is same as
// // previous increment temp value
// if (s[i] == s[i - 1])
// {
// ++temp;
// }
// else
// {
// ans = Math.Max(ans, temp);
// temp = 1;
// }
// }
//
// ans = Math.Max(ans, temp);
//
// // Return the required answer
// return ans;
// }
// }

View File

@@ -1,4 +1,5 @@
using Nadeko.Common; using System;
using Nadeko.Common;
using NUnit.Framework; using NUnit.Framework;
namespace NadekoBot.Tests namespace NadekoBot.Tests
@@ -120,5 +121,12 @@ namespace NadekoBot.Tests
num = new kwum(int.MaxValue); num = new kwum(int.MaxValue);
Assert.AreEqual("3zzzzzz", num.ToString()); Assert.AreEqual("3zzzzzz", num.ToString());
} }
[Test]
public void TestPower()
{
var num = new kwum((int)Math.Pow(32, 2));
Assert.AreEqual("322", num.ToString());
}
} }
} }

View File

@@ -218,12 +218,12 @@ public sealed class Bot : IBot
catch (HttpException ex) catch (HttpException ex)
{ {
LoginErrorHandler.Handle(ex); LoginErrorHandler.Handle(ex);
Helpers.ReadErrorAndExit(3); Helpers.ReadErrorAndExit(101);
} }
catch (Exception ex) catch (Exception ex)
{ {
LoginErrorHandler.Handle(ex); LoginErrorHandler.Handle(ex);
Helpers.ReadErrorAndExit(4); Helpers.ReadErrorAndExit(5);
} }
await clientReady.Task.ConfigureAwait(false); await clientReady.Task.ConfigureAwait(false);
@@ -275,7 +275,7 @@ public sealed class Bot : IBot
catch (Exception ex) catch (Exception ex)
{ {
Log.Error(ex, "Error adding services"); Log.Error(ex, "Error adding services");
Helpers.ReadErrorAndExit(9); Helpers.ReadErrorAndExit(103);
} }
Log.Information("Shard {ShardId} connected in {Elapsed:F2}s", Log.Information("Shard {ShardId} connected in {Elapsed:F2}s",

View File

@@ -74,6 +74,9 @@ public abstract class NadekoContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
// load all entities from current assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(NadekoContext).Assembly);
#region Notify #region Notify
modelBuilder.Entity<Notify>(e => modelBuilder.Entity<Notify>(e =>

View File

@@ -17,6 +17,9 @@ public static class MigrationQueries
public static void MigrateSar(MigrationBuilder migrationBuilder) public static void MigrateSar(MigrationBuilder migrationBuilder)
{ {
if (migrationBuilder.IsNpgsql())
return;
migrationBuilder.Sql(""" migrationBuilder.Sql("""
INSERT INTO GroupName (Number, GuildConfigId) INSERT INTO GroupName (Number, GuildConfigId)
SELECT DISTINCT "Group", GC.Id SELECT DISTINCT "Group", GC.Id

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
/// <inheritdoc />
public partial class fishes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "fishcatch",
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),
fishid = table.Column<int>(type: "integer", nullable: false),
count = table.Column<int>(type: "integer", nullable: false),
maxstars = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_fishcatch", x => x.id);
table.UniqueConstraint("ak_fishcatch_userid_fishid", x => new { x.userid, x.fishid });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "fishcatch");
}
}
}

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

@@ -3374,6 +3374,67 @@ namespace NadekoBot.Migrations.PostgreSql
b.ToTable("xpshopowneditem", (string)null); b.ToTable("xpshopowneditem", (string)null);
}); });
modelBuilder.Entity("NadekoBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("Count")
.HasColumnType("integer")
.HasColumnName("count");
b.Property<int>("FishId")
.HasColumnType("integer")
.HasColumnName("fishid");
b.Property<int>("MaxStars")
.HasColumnType("integer")
.HasColumnName("maxstars");
b.Property<decimal>("UserId")
.HasColumnType("numeric(20,0)")
.HasColumnName("userid");
b.HasKey("Id")
.HasName("pk_fishcatch");
b.HasAlternateKey("UserId", "FishId")
.HasName("ak_fishcatch_userid_fishid");
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 => modelBuilder.Entity("NadekoBot.Services.GreetSettings", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
/// <inheritdoc />
public partial class fishes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FishCatch",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
FishId = table.Column<int>(type: "INTEGER", nullable: false),
Count = table.Column<int>(type: "INTEGER", nullable: false),
MaxStars = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FishCatch", x => x.Id);
table.UniqueConstraint("AK_FishCatch_UserId_FishId", x => new { x.UserId, x.FishId });
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FishCatch");
}
}
}

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

@@ -2508,6 +2508,51 @@ namespace NadekoBot.Migrations
b.ToTable("XpShopOwnedItem"); b.ToTable("XpShopOwnedItem");
}); });
modelBuilder.Entity("NadekoBot.Modules.Games.FishCatch", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.Property<int>("FishId")
.HasColumnType("INTEGER");
b.Property<int>("MaxStars")
.HasColumnType("INTEGER");
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasAlternateKey("UserId", "FishId");
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 => modelBuilder.Entity("NadekoBot.Services.GreetSettings", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")

View File

@@ -71,7 +71,7 @@ public sealed class HoneyPotService : IHoneyPotService, IReadyExecutor, IExecNoC
try try
{ {
Log.Information("Honeypot caught user {User} [{UserId}]", user, user.Id); Log.Information("Honeypot caught user {User} [{UserId}]", user, user.Id);
await user.BanAsync(pruneDays: 1); await user.BanAsync(pruneDays: 1, reason: "Honeypot");
await user.Guild.RemoveBanAsync(user.Id); await user.Guild.RemoveBanAsync(user.Id);
} }
catch (Exception e) catch (Exception e)

View File

@@ -8,7 +8,7 @@ public partial class Administration
public class NotifyCommands : NadekoModule<NotifyService> public class NotifyCommands : NadekoModule<NotifyService>
{ {
[Cmd] [Cmd]
[OwnerOnly] [UserPerm(GuildPerm.Administrator)]
public async Task Notify() public async Task Notify()
{ {
await Response() await Response()
@@ -42,7 +42,7 @@ public partial class Administration
}; };
[Cmd] [Cmd]
[OwnerOnly] [UserPerm(GuildPerm.Administrator)]
public async Task Notify(NotifyType nType, [Leftover] string? message = null) public async Task Notify(NotifyType nType, [Leftover] string? message = null)
{ {
if (string.IsNullOrWhiteSpace(message)) if (string.IsNullOrWhiteSpace(message))
@@ -76,7 +76,7 @@ public partial class Administration
} }
[Cmd] [Cmd]
[OwnerOnly] [UserPerm(GuildPerm.Administrator)]
public async Task NotifyList(int page = 1) public async Task NotifyList(int page = 1)
{ {
if (--page < 0) if (--page < 0)
@@ -104,7 +104,7 @@ public partial class Administration
} }
[Cmd] [Cmd]
[OwnerOnly] [UserPerm(GuildPerm.Administrator)]
public async Task NotifyClear(NotifyType nType) public async Task NotifyClear(NotifyType nType)
{ {
await _service.DisableAsync(ctx.Guild.Id, nType); await _service.DisableAsync(ctx.Guild.Id, nType);

View File

@@ -75,7 +75,7 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, INService
data); data);
} }
} }
private async Task OnEvent<T>(T model) private async Task OnEvent<T>(T model)
where T : struct, INotifyModel where T : struct, INotifyModel
{ {
@@ -146,6 +146,7 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, INService
await _mss.Response(channel) await _mss.Response(channel)
.Text(st) .Text(st)
.Sanitize(false)
.SendAsync(); .SendAsync();
} }

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 //deletes her own messages, no perm required
[Cmd] [Cmd]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
@@ -114,9 +133,9 @@ public partial class Administration
await progressMsg.ModifyAsync(props => await progressMsg.ModifyAsync(props =>
{ {
props.Embed = CreateEmbed() props.Embed = CreateEmbed()
.WithPendingColor() .WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total))) .WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build(); .Build();
}); });
} }
catch catch

View File

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

View File

@@ -221,7 +221,7 @@ public partial class Administration
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)] [UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)] [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)) if (!await CheckRoleHierarchy(role))
{ {
@@ -231,6 +231,7 @@ public partial class Administration
return; return;
} }
await user.AddRoleAsync(role);
await _tempRoleService.AddTempRoleAsync(ctx.Guild.Id, role.Id, user.Id, timespan.Time); await _tempRoleService.AddTempRoleAsync(ctx.Guild.Id, role.Id, user.Id, timespan.Time);

View File

@@ -192,15 +192,22 @@ public partial class Administration
[Cmd] [Cmd]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)] [UserPerm(GuildPerm.ManageRoles)]
public async Task SarRemove([Leftover] IRole role) [Priority(1)]
public Task SarRemove([Leftover] IRole role)
=> SarRemove(role.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[Priority(0)]
public async Task SarRemove([Leftover] ulong roleId)
{ {
var guser = (IGuildUser)ctx.User; var role = await ctx.Guild.GetRoleAsync(roleId);
var success = await _service.RemoveAsync(ctx.Guild.Id, roleId);
var success = await _service.RemoveAsync(role.Guild.Id, role.Id);
if (!success) if (!success)
await Response().Error(strs.self_assign_not).SendAsync(); await Response().Error(strs.self_assign_not).SendAsync();
else else
await Response().Confirm(strs.self_assign_rem(Format.Bold(role.Name))).SendAsync(); await Response().Confirm(strs.self_assign_rem(Format.Bold(role?.Name ?? roleId.ToString()))).SendAsync();
} }
[Cmd] [Cmd]

View File

@@ -59,10 +59,15 @@ public class SelfAssignedRolesService : INService, IReadyExecutor
}, },
_ => new() _ => new()
{ {
SarGroupId = ctx.GetTable<SarGroup>()
.Where(x => x.GuildId == guildId && x.GroupNumber == groupNumber)
.Select(x => x.Id)
.First()
}, },
() => new() () => new()
{ {
RoleId = roleId, RoleId = roleId,
GuildId = guildId,
}); });
} }
@@ -280,8 +285,12 @@ public sealed class SarAssignerService : INService, IReadyExecutor
if (item.Group.IsExclusive) if (item.Group.IsExclusive)
{ {
var rolesToRemove = item.Group.Roles.Select(x => x.RoleId); var rolesToRemove = item.Group.Roles
await item.User.RemoveRolesAsync(rolesToRemove); .Where(x => item.User.RoleIds.Contains(x.RoleId))
.Select(x => x.RoleId)
.ToArray();
if (rolesToRemove.Length > 0)
await item.User.RemoveRolesAsync(rolesToRemove);
} }
await item.User.AddRoleAsync(item.RoleId); await item.User.AddRoleAsync(item.RoleId);

View File

@@ -149,7 +149,10 @@ public partial class Gambling
?? (await _userService.GetUserAsync(x.UserId))?.Username ?? (await _userService.GetUserAsync(x.UserId))?.Username
?? x.UserId.ToString(); ?? x.UserId.ToString();
outputItems.Add(new WinLbStat(i + 1 + (page * 10), user, x.Game, x.MaxWin)); if (user.StartsWith("??"))
user = x.UserId.ToString();
outputItems.Add(new WinLbStat(i + 1 + (page * 9), user, x.Game, x.MaxWin));
} }
return outputItems; return outputItems;
@@ -166,11 +169,12 @@ public partial class Gambling
await Response() await Response()
.Paginated() .Paginated()
.PageItems(p => GetCachedWinLbAsync(p)) .PageItems(p => GetCachedWinLbAsync(p))
.PageSize(10) .PageSize(9)
.Page((items, curPage) => .Page((items, curPage) =>
{ {
var eb = CreateEmbed() var eb = CreateEmbed()
.WithOkColor(); .WithTitle(GetText(strs.winlb))
.WithOkColor();
if (items.Count == 0) if (items.Count == 0)
{ {
@@ -182,7 +186,8 @@ public partial class Gambling
{ {
var item = items[i]; var item = items[i];
eb.AddField($"#{item.Rank} {item.User}", eb.AddField($"#{item.Rank} {item.User}",
$"{N(item.MaxWin)}\n`{item.Game.ToString().ToLower()}`"); $"{N(item.MaxWin)}\n`{item.Game.ToString().ToLower()}`",
true);
} }
return eb; return eb;

View File

@@ -13,6 +13,7 @@ using System.Globalization;
using System.Text; using System.Text;
using NadekoBot.Modules.Gambling.Rps; using NadekoBot.Modules.Gambling.Rps;
using NadekoBot.Common.TypeReaders; using NadekoBot.Common.TypeReaders;
using NadekoBot.Modules.Games;
using NadekoBot.Modules.Patronage; using NadekoBot.Modules.Patronage;
using SixLabors.Fonts; using SixLabors.Fonts;
using SixLabors.Fonts.Unicode; using SixLabors.Fonts.Unicode;
@@ -40,6 +41,7 @@ public partial class Gambling : GamblingModule<GamblingService>
private readonly IPatronageService _ps; private readonly IPatronageService _ps;
private readonly RakebackService _rb; private readonly RakebackService _rb;
private readonly IBotCache _cache; private readonly IBotCache _cache;
private readonly CaptchaService _captchaService;
public Gambling( public Gambling(
IGamblingService gs, IGamblingService gs,
@@ -54,7 +56,8 @@ public partial class Gambling : GamblingModule<GamblingService>
IPatronageService patronage, IPatronageService patronage,
GamblingTxTracker gamblingTxTracker, GamblingTxTracker gamblingTxTracker,
RakebackService rb, RakebackService rb,
IBotCache cache) IBotCache cache,
CaptchaService captchaService)
: base(configService) : base(configService)
{ {
_gs = gs; _gs = gs;
@@ -66,6 +69,7 @@ public partial class Gambling : GamblingModule<GamblingService>
_gamblingTxTracker = gamblingTxTracker; _gamblingTxTracker = gamblingTxTracker;
_rb = rb; _rb = rb;
_cache = cache; _cache = cache;
_captchaService = captchaService;
_ps = patronage; _ps = patronage;
_rng = new NadekoRandom(); _rng = new NadekoRandom();
@@ -135,7 +139,6 @@ public partial class Gambling : GamblingModule<GamblingService>
}); });
[Cmd] [Cmd]
[RequireContext(ContextType.Guild)]
public async Task Timely() public async Task Timely()
{ {
var val = Config.Timely.Amount; var val = Config.Timely.Amount;
@@ -155,82 +158,42 @@ public partial class Gambling : GamblingModule<GamblingService>
} }
else if (Config.Timely.ProtType == TimelyProt.Captcha) else if (Config.Timely.ProtType == TimelyProt.Captcha)
{ {
var password = await GetUserTimelyPassword(ctx.User.Id); var password = await _captchaService.GetUserCaptcha(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;
}
await ClearUserTimelyPassword(ctx.User.Id); if (password is not null)
}
finally
{ {
_ = 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(); 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() private async Task ClaimTimely()
{ {
var period = Config.Timely.Cooldown; var period = Config.Timely.Cooldown;

View File

@@ -60,8 +60,8 @@ public sealed class UserBetStatsService : INService
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
return await ctx.GetTable<UserBetStats>() return await ctx.GetTable<UserBetStats>()
.OrderByDescending(x => x.MaxWin) .OrderByDescending(x => x.MaxWin)
.Skip(page * 10) .Skip(page * 9)
.Take(10) .Take(9)
.ToArrayAsyncLinqToDB(); .ToArrayAsyncLinqToDB();
} }
} }

View File

@@ -16,9 +16,9 @@ public class GamblingCleanupService : IGamblingCleanupService, INService
public async Task DeleteWaifus() public async Task DeleteWaifus()
{ {
await using var ctx = _db.GetDbContext(); await using var ctx = _db.GetDbContext();
await ctx.GetTable<WaifuInfo>().DeleteAsync();
await ctx.GetTable<WaifuItem>().DeleteAsync(); await ctx.GetTable<WaifuItem>().DeleteAsync();
await ctx.GetTable<WaifuUpdate>().DeleteAsync(); await ctx.GetTable<WaifuUpdate>().DeleteAsync();
await ctx.GetTable<WaifuInfo>().DeleteAsync();
} }
public async Task DeleteWaifu(ulong userId) public async Task DeleteWaifu(ulong userId)

View File

@@ -0,0 +1,80 @@
using NadekoBot.Db.Models;
using NadekoBot.Modules.Patronage;
using SixLabors.Fonts;
using SixLabors.Fonts.Unicode;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Color = SixLabors.ImageSharp.Color;
namespace NadekoBot.Modules.Games;
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>(60, 34);
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 =>
{
DrawTextExtensions.DrawText(x,
new RichTextOptions(font)
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
FallbackFontFamilies = fonts.FallBackFonts,
Origin = new(30, 15),
TextRuns = [strikeoutRun]
},
password,
Brushes.Solid(Color.White),
outlinePen);
});
return img;
}
public string GeneratePassword()
{
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

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System.ComponentModel.DataAnnotations;
namespace NadekoBot.Modules.Games;
public sealed class FishCatch
{
[Key]
public int Id { get; set; }
public ulong UserId { get; set; }
public int FishId { get; set; }
public int Count { get; set; }
public int MaxStars { get; set; }
}
public sealed class FishCatchConfiguration : IEntityTypeConfiguration<FishCatch>
{
public void Configure(EntityTypeBuilder<FishCatch> builder)
{
builder.HasAlternateKey(x => new
{
x.UserId,
x.FishId
});
}
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Games;
public sealed class FishChance
{
public int Fish { get; set; } = 75;
public int Trash { get; set; } = 20;
public int Nothing { get; set; } = 0;
}

View File

@@ -0,0 +1,275 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using Format = Discord.Format;
namespace NadekoBot.Modules.Games;
public partial class Games
{
public class FishCommands(
FishService fs,
FishConfigService fcs,
IBotCache cache,
CaptchaService captchaService) : NadekoModule
{
private static readonly NadekoRandom _rng = new();
private TypedKey<bool> FishingWhitelistKey(ulong userId)
=> new($"fishingwhitelist:{userId}");
[Cmd]
public async Task Fish()
{
var cRes = await cache.GetAsync(FishingWhitelistKey(ctx.User.Id));
if (cRes.TryPickT1(out _, out _))
{
var password = await captchaService.GetUserCaptcha(ctx.User.Id);
if (password is not null)
{
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
{
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 captchaService.ClearUserCaptcha(ctx.User.Id);
}
finally
{
_ = captcha.DeleteAsync();
}
}
}
var fishResult = await fs.FishAsync(ctx.User.Id, ctx.Channel.Id);
if (fishResult.TryPickT1(out _, out var fishTask))
{
return;
}
var currentWeather = fs.GetCurrentWeather();
var currentTod = fs.GetTime();
var spot = fs.GetSpot(ctx.Channel.Id);
var msg = await Response()
.Embed(CreateEmbed()
.WithPendingColor()
.WithAuthor(ctx.User)
.WithDescription(GetText(strs.fish_waiting))
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot.ToString(), true)
.AddField(GetText(strs.fish_weather),
GetWeatherEmoji(currentWeather) + " " + currentWeather,
true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(currentTod) + " " + currentTod, true))
.SendAsync();
var res = await fishTask;
if (res is null)
{
await Response().Error(strs.fish_nothing).SendAsync();
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(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))
.SendAsync();
await msg.DeleteAsync();
}
[Cmd]
public async Task FishSpot()
{
var ws = fs.GetWeatherForPeriods(7);
var spot = fs.GetSpot(ctx.Channel.Id);
var time = fs.GetTime();
await Response()
.Embed(CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.fish_weather_duration(fs.GetWeatherPeriodDuration())))
.AddField(GetText(strs.fish_spot), GetSpotEmoji(spot) + " " + spot, true)
.AddField(GetText(strs.fish_tod), GetTodEmoji(time) + " " + time, true)
.AddField(GetText(strs.fish_weather_forecast),
ws.Select(x => GetWeatherEmoji(x)).Join(""),
true))
.SendAsync();
}
[Cmd]
public async Task Fishlist(int page = 1)
{
if (--page < 0)
return;
var fishes = await fs.GetAllFish();
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);
await Response()
.Paginated()
.Items(fishes)
.PageSize(9)
.CurrentPage(page)
.Page((fs, i) =>
{
var eb = CreateEmbed()
.WithDescription($"🧠 **Skill:** {skill} / {maxSkill}")
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.fish_list_title))
.WithOkColor();
foreach (var f in fs)
{
if (catchDict.TryGetValue(f.Id, out var c))
{
eb.AddField(f.Name,
GetFishEmoji(f, c.Count)
+ " "
+ GetSpotEmoji(f.Spot)
+ GetTodEmoji(f.Time)
+ GetWeatherEmoji(f.Weather)
+ "\n"
+ GetStarText(c.MaxStars, f.Stars)
+ "\n"
+ Format.Italics(f.Fluff),
true);
}
else
{
eb.AddField("?", GetFishEmoji(null, 0) + "\n" + GetStarText(0, f.Stars), true);
}
}
return eb;
})
.SendAsync();
}
private string GetFishEmoji(FishData? fish, int count)
{
if (fish is null)
return "";
return fish.Emoji + " x" + count;
}
private string GetSpotEmoji(FishingSpot? spot)
{
if (spot is not FishingSpot fs)
return string.Empty;
var conf = fcs.Data;
return conf.SpotEmojis[(int)fs];
}
private string GetTodEmoji(FishingTime? fishTod)
{
return fishTod switch
{
FishingTime.Night => "🌑",
FishingTime.Dawn => "🌅",
FishingTime.Dusk => "🌆",
FishingTime.Day => "☀️",
_ => ""
};
}
private string GetWeatherEmoji(FishingWeather? w)
=> w switch
{
FishingWeather.Rain => "🌧️",
FishingWeather.Snow => "❄️",
FishingWeather.Storm => "⛈️",
FishingWeather.Clear => "☀️",
_ => ""
};
private string GetStarText(int resStars, int fishStars)
{
if (resStars == fishStars)
{
return MultiplyStars(fcs.Data.StarEmojis[^1], fishStars);
}
var c = fcs.Data;
var starsp1 = MultiplyStars(c.StarEmojis[resStars], resStars);
var starsp2 = MultiplyStars(c.StarEmojis[0], fishStars - resStars);
return starsp1 + starsp2;
}
private string MultiplyStars(string starEmoji, int count)
{
var sb = new StringBuilder();
for (var i = 0; i < count; i++)
{
sb.Append(starEmoji);
}
return sb.ToString();
}
}
}
public enum FishingSpot
{
Ocean,
River,
Lake,
Swamp,
Reef
}
public enum FishingTime
{
Night,
Dawn,
Day,
Dusk
}
public enum FishingWeather
{
Clear,
Rain,
Storm,
Snow
}

View File

@@ -0,0 +1,19 @@
using Cloneable;
using NadekoBot.Common.Yml;
namespace NadekoBot.Modules.Games;
[Cloneable]
public sealed partial class FishConfig : ICloneable<FishConfig>
{
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 1;
public string WeatherSeed { get; set; } = string.Empty;
public List<string> StarEmojis { get; set; } = new();
public List<string> SpotEmojis { get; set; } = new();
public FishChance Chance { get; set; } = new FishChance();
public List<FishData> Fish { get; set; } = new();
public List<FishData> Trash { get; set; } = new();
}

View File

@@ -0,0 +1,19 @@
using NadekoBot.Common.Configs;
namespace NadekoBot.Modules.Games;
public sealed class FishConfigService : ConfigServiceBase<FishConfig>
{
private static string FILE_PATH = "data/fish.yml";
private static readonly TypedKey<FishConfig> _changeKey = new("config.fish.updated");
public override string Name
=> "fishing";
public FishConfigService(
IConfigSeria serializer,
IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
}
}

View File

@@ -0,0 +1,16 @@
namespace NadekoBot.Modules.Games;
public class FishData
{
public required int Id { get; set; }
public required string Name { get; set; }
public FishingWeather? Weather { get; set; }
public FishingSpot? Spot { get; set; }
public FishingTime? Time { get; set; }
public required double Chance { get; set; }
public required int Stars { get; set; }
public required string Fluff { get; set; }
public List<string>? Condition { get; set; }
public string? Image { get; init; }
public string? Emoji { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace NadekoBot.Modules.Games;
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

@@ -0,0 +1,421 @@
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)
=> new($"fishing:{userId}");
public async Task<OneOf.OneOf<Task<FishResult?>, AlreadyFishing>> FishAsync(ulong userId, ulong channelId)
{
var duration = _rng.Next(5, 9);
if (!await cache.AddAsync(FishingKey(userId), true, TimeSpan.FromSeconds(duration), overwrite: false))
{
return new AlreadyFishing();
}
return TryFishAsync(userId, channelId, duration);
}
private async Task<FishResult?> TryFishAsync(ulong userId, ulong channelId, int duration)
{
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 = fishChance + trashChance + conf.Chance.Nothing;
var typeRoll = _rng.NextDouble() * totalChance;
if (typeRoll < nothingChance)
{
return null;
}
var items = typeRoll < nothingChance + fishChance
? conf.Fish
: conf.Trash;
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)
{
var filteredItems = new List<FishData>();
var loc = GetSpot(channelId);
var time = GetTime();
var w = GetWeather(DateTime.UtcNow);
foreach (var item in items)
{
if (item.Condition is { Count: > 0 })
{
if (!item.Condition.Any(x => channelId.ToString().EndsWith(x)))
{
continue;
}
}
if (item.Spot is not null && item.Spot != loc)
continue;
if (item.Time is not null && item.Time != time)
continue;
if (item.Weather is not null && item.Weather != w)
continue;
filteredItems.Add(item);
}
var maxSum = filteredItems.Sum(x => x.Chance * 100);
var roll = _rng.NextDouble() * maxSum;
FishResult? caught = null;
var curSum = 0d;
foreach (var i in filteredItems)
{
curSum += i.Chance * 100;
if (roll < curSum)
{
caught = new FishResult()
{
Fish = i,
Stars = GetRandomStars(i.Stars),
};
break;
}
}
if (caught is not null)
{
await using var uow = db.GetDbContext();
await uow.GetTable<FishCatch>()
.InsertOrUpdateAsync(() => new FishCatch()
{
UserId = userId,
FishId = caught.Fish.Id,
MaxStars = caught.Stars,
Count = 1
},
(old) => new()
{
Count = old.Count + 1,
MaxStars = Math.Max((int)old.MaxStars, caught.Stars),
},
() => new()
{
FishId = caught.Fish.Id,
UserId = userId
});
return caught;
}
Log.Error(
"Something went wrong in the fish command, no fish with sufficient chance was found, Roll: {Roll}, MaxSum: {MaxSum}",
roll,
maxSum);
return null;
}
public FishingSpot GetSpot(ulong channelId)
{
var cid = (channelId >> 22 >> 29) % 10;
return cid switch
{
< 1 => FishingSpot.Reef,
< 3 => FishingSpot.River,
< 5 => FishingSpot.Lake,
< 7 => FishingSpot.Swamp,
_ => FishingSpot.Ocean,
};
}
public FishingTime GetTime()
{
var hour = DateTime.UtcNow.Hour % 12;
if (hour < 3)
return FishingTime.Night;
if (hour < 4)
return FishingTime.Dawn;
if (hour < 11)
return FishingTime.Day;
return FishingTime.Dusk;
}
private const int WEATHER_PERIODS_PER_DAY = 12;
public IReadOnlyList<FishingWeather> GetWeatherForPeriods(int periods)
{
var now = DateTime.UtcNow;
var result = new FishingWeather[periods];
for (var i = 0; i < periods; i++)
{
result[i] = GetWeather(now.AddHours(i * GetWeatherPeriodDuration()));
}
return result;
}
public FishingWeather GetCurrentWeather()
=> GetWeather(DateTime.UtcNow);
public FishingWeather GetWeather(DateTime time)
=> GetWeather(time, fcs.Data.WeatherSeed);
private FishingWeather GetWeather(DateTime time, string seed)
{
var year = time.Year;
var dayOfYear = time.DayOfYear;
var hour = time.Hour;
var num = (year * 100_000) + (dayOfYear * 100) + (hour / GetWeatherPeriodDuration());
Span<byte> dataArray = stackalloc byte[4];
BitConverter.TryWriteBytes(dataArray, num);
Span<byte> seedArray = stackalloc byte[seed.Length];
for (var index = 0; index < seed.Length; index++)
{
var c = seed[index];
seedArray[index] = (byte)c;
}
Span<byte> arr = stackalloc byte[dataArray.Length + seedArray.Length];
dataArray.CopyTo(arr);
seedArray.CopyTo(arr[dataArray.Length..]);
using var algo = SHA512.Create();
Span<byte> hash = stackalloc byte[64];
algo.TryComputeHash(arr, hash, out _);
byte reduced = 0;
foreach (var u in hash)
reduced ^= u;
var r = reduced % 16;
// return (FishingWeather)r;
return r switch
{
< 5 => FishingWeather.Clear,
< 9 => FishingWeather.Rain,
< 13 => FishingWeather.Storm,
_ => FishingWeather.Snow
};
}
/// <summary>
/// Returns a random number of stars between 1 and maxStars
/// if maxStars == 1, returns 1
/// if maxStars == 2, returns 1 (66%) or 2 (33%)
/// if maxStars == 3, returns 1 (65%) or 2 (25%) or 3 (10%)
/// if maxStars == 5, returns 1 (40%) or 2 (30%) or 3 (15%) or 4 (10%) or 5 (5%)
/// </summary>
/// <param name="maxStars">Max Number of stars to generate</param>
/// <returns>Random number of stars</returns>
private int GetRandomStars(int maxStars)
{
if (maxStars == 1)
return 1;
if (maxStars == 2)
{
// 15% chance of 1 star, 85% chance of 2 stars
return _rng.NextDouble() < 0.85 ? 1 : 2;
}
if (maxStars == 3)
{
// 65% chance of 1 star, 30% chance of 2 stars, 5% chance of 3 stars
var r = _rng.NextDouble();
if (r < 0.65)
return 1;
if (r < 0.95)
return 2;
return 3;
}
if (maxStars == 4)
{
// this should never happen
// 50% chance of 1 star, 25% chance of 2 stars, 18% chance of 3 stars, 7% chance of 4 stars
var r = _rng.NextDouble();
if (r < 0.55)
return 1;
if (r < 0.80)
return 2;
if (r < 0.98)
return 3;
return 4;
}
if (maxStars == 5)
{
// 40% chance of 1 star, 30% chance of 2 stars, 15% chance of 3 stars, 10% chance of 4 stars, 5% chance of 5 stars
var r = _rng.NextDouble();
if (r < 0.4)
return 1;
if (r < 0.7)
return 2;
if (r < 0.9)
return 3;
if (r < 0.98)
return 4;
return 5;
}
return 1;
}
public int GetWeatherPeriodDuration()
=> 24 / WEATHER_PERIODS_PER_DAY;
public async Task<List<FishData>> GetAllFish()
{
await Task.Yield();
var conf = fcs.Data;
return conf.Fish.Concat(conf.Trash).ToList();
}
public async Task<List<FishCatch>> GetUserCatches(ulong userId)
{
await using var ctx = db.GetDbContext();
var catches = await ctx.GetTable<FishCatch>()
.Where(x => x.UserId == userId)
.ToListAsyncLinqToDB();
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

@@ -92,13 +92,13 @@ public class GlobalPermissionService : IExecPreCommand, INService
{ {
if (priv) if (priv)
{ {
if (bs.Blocked.Commands.Add(commandName)) if (bs.DmBlocked.Commands.Add(commandName))
{ {
added = true; added = true;
} }
else else
{ {
bs.Blocked.Commands.Remove(commandName); bs.DmBlocked.Commands.Remove(commandName);
added = false; added = false;
} }

View File

@@ -2,6 +2,8 @@
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration; using CsvHelper.Configuration;
using System.Globalization; using System.Globalization;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
namespace NadekoBot.Modules.Searches; namespace NadekoBot.Modules.Searches;
@@ -9,54 +11,57 @@ namespace NadekoBot.Modules.Searches;
public sealed class DefaultStockDataService : IStockDataService, INService public sealed class DefaultStockDataService : IStockDataService, INService
{ {
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IBotCache _cache;
public DefaultStockDataService(IHttpClientFactory httpClientFactory) public DefaultStockDataService(IHttpClientFactory httpClientFactory, IBotCache cache)
=> _httpClientFactory = httpClientFactory; => (_httpClientFactory, _cache) = (httpClientFactory, cache);
private static TypedKey<StockData> GetStockDataKey(string query)
=> new($"stockdata:{query}");
public async Task<StockData?> GetStockDataAsync(string 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 try
{ {
if (!query.IsAlphaNumeric()) if (!query.IsAlphaNumeric())
return default; 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()}"; if (info?.Data is not { } d || d.SummaryData is not { } sd)
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)
return default; 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']") var price = d.BidAsk.Bid.Value.IndexOf('*') is var idx and > 0
?.TextContent; && double.TryParse(d.BidAsk.Bid.Value.Substring(1, idx - 1),
NumberStyles.Any,
var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']") CultureInfo.InvariantCulture,
?.TextContent out var bid)
?? "0"; ? bid
: double.NaN;
var price = document.QuerySelector("fin-streamer.livePrice > span")
?.TextContent
?? "0";
return new() return new()
{ {
Name = tickerName, Name = query,
Symbol = query, Symbol = info.Data.Symbol,
Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture), Price = price,
Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture), Close = closePrice,
MarketCap = marketcap, MarketCap = sd.MarketCap.Value,
DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture), DailyVolume =
(long)double.Parse(sd.AverageVolume.Value ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
}; };
} }
catch (Exception ex) 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) public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
@@ -91,22 +126,37 @@ public sealed class DefaultStockDataService : IStockDataService, INService
.ToList(); .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) 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(); 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); var now = DateTime.UtcNow;
using var csv = new CsvReader(textReader, _csvConfig); var fromdate = now.Subtract(30.Days()).ToString("yyyy-MM-dd");
var records = csv.GetRecords<YahooFinanceCandleData>().ToArray(); var todate = now.ToString("yyyy-MM-dd");
return records var res = await GetNasdaqDataResponse<NasdaqChartResponse>(
.Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume)); $"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

@@ -417,15 +417,25 @@ public partial class Searches : NadekoModule<SearchesService>
{ {
usr ??= (IGuildUser)ctx.User; usr ??= (IGuildUser)ctx.User;
var bannerUrl = usr.GetGuildBannerUrl(); var bannerUrl = usr.GetGuildBannerUrl(size: 2048)
?? (await ((DiscordSocketClient)ctx.Client).Rest.GetUserAsync(usr.Id))?.GetBannerUrl();
if (bannerUrl is null)
{
await Response()
.Error(strs.no_banner)
.SendAsync();
return;
}
await Response() await Response()
.Embed( .Embed(
CreateEmbed() CreateEmbed()
.WithOkColor() .WithOkColor()
.AddField("Username", usr.ToString()) .AddField("Username", usr.ToString(), true)
.AddField("Banner Url", bannerUrl) .AddField("Banner Url", bannerUrl, true)
.WithThumbnailUrl(bannerUrl)) .WithImageUrl(bannerUrl))
.SendAsync(); .SendAsync();
} }

View File

@@ -13,9 +13,9 @@ public partial class Utility
public async Task ServerColorsShow() public async Task ServerColorsShow()
{ {
var colors = _service.GetColors(ctx.Guild.Id); var colors = _service.GetColors(ctx.Guild.Id);
var okHex = colors?.Ok?.RawValue.ToString("X8"); var okHex = colors?.Ok?.RawValue.ToString("x6");
var warnHex = colors?.Warn?.RawValue.ToString("X8"); var warnHex = colors?.Warn?.RawValue.ToString("x6");
var errHex = colors?.Error?.RawValue.ToString("X8"); var errHex = colors?.Error?.RawValue.ToString("x6");
EmbedBuilder[] ebs = EmbedBuilder[] ebs =
[ [
CreateEmbed() CreateEmbed()

View File

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

View File

@@ -150,7 +150,26 @@ public partial class Utility
[Cmd] [Cmd]
public async Task TodoArchiveAdd([Leftover] string name) 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) if (result == ArchiveTodoResult.NoTodos)
{ {
await Response().Error(strs.todo_no_todos).SendAsync(); await Response().Error(strs.todo_no_todos).SendAsync();
@@ -193,7 +212,7 @@ public partial class Utility
foreach (var archivedList in items) 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; return eb;
@@ -202,7 +221,7 @@ public partial class Utility
} }
[Cmd] [Cmd]
public async Task TodoArchiveShow(int id) public async Task TodoArchiveShow(kwum id)
{ {
var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id); var list = await _service.GetArchivedTodoListAsync(ctx.User.Id, id);
if (list == null || list.Items.Count == 0) if (list == null || list.Items.Count == 0)
@@ -234,7 +253,7 @@ public partial class Utility
} }
[Cmd] [Cmd]
public async Task TodoArchiveDelete(int id) public async Task TodoArchiveDelete(kwum id)
{ {
if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id)) if (!await _service.ArchiveDeleteAsync(ctx.User.Id, id))
{ {

View File

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

View File

@@ -186,7 +186,7 @@ public partial class Utility : NadekoModule
return CreateEmbed() return CreateEmbed()
.WithOkColor() .WithOkColor()
.WithTitle(GetText(strs.inrole_list(roleName, roleUsers.Count))) .WithTitle(GetText(strs.inrole_list(role?.GetIconUrl() + roleName, roleUsers.Count)))
.WithDescription(string.Join("\n", pageUsers)); .WithDescription(string.Join("\n", pageUsers));
}) })
.SendAsync(); .SendAsync();

View File

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

View File

@@ -16,7 +16,7 @@ public sealed class NadekoRandom : Random
_rng.GetBytes(bytes); _rng.GetBytes(bytes);
return Math.Abs(BitConverter.ToInt32(bytes, 0)); return Math.Abs(BitConverter.ToInt32(bytes, 0));
} }
/// <summary> /// <summary>
/// Generates a random integer between 0 (inclusive) and /// Generates a random integer between 0 (inclusive) and
/// a specified exclusive upper bound using a cryptographically strong random number generator. /// a specified exclusive upper bound using a cryptographically strong random number generator.
@@ -54,13 +54,9 @@ public sealed class NadekoRandom : Random
{ {
var bytes = new byte[sizeof(double)]; var bytes = new byte[sizeof(double)];
_rng.GetBytes(bytes); _rng.GetBytes(bytes);
return Math.Abs((BitConverter.ToDouble(bytes, 0) / double.MaxValue) + 1); return Math.Abs((BitConverter.ToDouble(bytes, 0) / (double.MaxValue + 1)));
} }
public override double NextDouble() public override double NextDouble()
{ => Sample();
var bytes = new byte[sizeof(double)];
_rng.GetBytes(bytes);
return BitConverter.ToDouble(bytes, 0);
}
} }

View File

@@ -82,7 +82,7 @@ public sealed class BotCredsProvider : IBotCredsProvider
if (string.IsNullOrWhiteSpace(_creds.Token)) if (string.IsNullOrWhiteSpace(_creds.Token))
{ {
Log.Error("Token is missing from creds.yml or Environment variables.\nAdd it and restart the program"); Log.Error("Token is missing from creds.yml or Environment variables.\nAdd it and restart the program");
Helpers.ReadErrorAndExit(5); Helpers.ReadErrorAndExit(1);
return; return;
} }

View File

@@ -107,7 +107,7 @@ public class RemoteGrpcCoordinator : ICoordinator, IReadyExecutor
await Task.Delay(7500); await Task.Delay(7500);
} }
Environment.Exit(5); Environment.Exit(0);
}); });
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -33,7 +33,7 @@ public class SingleProcessCoordinator : ICoordinator
} }
public void Die(bool graceful = false) public void Die(bool graceful = false)
=> Environment.Exit(5); => Environment.Exit(0);
public bool RestartShard(int shardId) public bool RestartShard(int shardId)
=> RestartBot(); => RestartBot();

View File

@@ -1441,6 +1441,11 @@ todoarchivedelete:
- del - del
- remove - remove
- rm - rm
todoarchivedone:
- done
- compelete
- finish
- completed
todoedit: todoedit:
- edit - edit
- change - change
@@ -1559,4 +1564,17 @@ notifyclear:
- notifclr - notifclr
winlb: winlb:
- winlb - winlb
- wins - wins
fish:
- fish
- fi
fishlist:
- fishlist
- fili
- fishes
- fil
- fishlist
fishspot:
- fishspot
- fisp
- fish?

View File

@@ -961,6 +961,56 @@
"MuteMembers Server Permission" "MuteMembers Server Permission"
] ]
}, },
{
"Aliases": [
".notify",
".nfy"
],
"Description": "Sends a message to the current channel once the specified event occurs.\nProvide no parameters to see all available events.",
"Usage": [
".notify levelup Congratulations to user %user.name% for reaching level %event.level%"
],
"Submodule": "NotifyCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{
"Aliases": [
".notifylist",
".notifyl"
],
"Description": "Lists all active notifications in this server.",
"Usage": [
".notifylist"
],
"Submodule": "NotifyCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{
"Aliases": [
".notifyclear",
".notifyremove",
".notifyrm",
".notifclr"
],
"Description": "Removes the specified notify event.",
"Usage": [
".notifyclear levelup"
],
"Submodule": "NotifyCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{ {
"Aliases": [ "Aliases": [
".dpo" ".dpo"
@@ -1523,6 +1573,22 @@
"Administrator Server Permission" "Administrator Server Permission"
] ]
}, },
{
"Aliases": [
".temprole"
],
"Description": "Grants a user a temporary role for the specified number of time.\nThe role must exist and be lower in the role hierarchy than your highest role.",
"Usage": [
".temprole 15m @User Jail",
".temprole 7d @Newbie Trial Member"
],
"Submodule": "RoleCommands",
"Module": "Administration",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{ {
"Aliases": [ "Aliases": [
".iam" ".iam"
@@ -1648,9 +1714,9 @@
".sar excl", ".sar excl",
".sar tesar" ".sar tesar"
], ],
"Description": "Toggles whether self-assigned roles are exclusive. While enabled, users can only have one self-assignable role per group.", "Description": "Toggles the sar group as exclusive.\nWhile enabled, users can only have one self-assignable role from that group.",
"Usage": [ "Usage": [
".sar exclusive" ".sar exclusive 1"
], ],
"Submodule": "sar", "Submodule": "sar",
"Module": "Administration", "Module": "Administration",
@@ -3274,6 +3340,21 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".winlb",
".wins"
],
"Description": "Shows the biggest wins leaderboard",
"Usage": [
".winlb",
".winlb 5"
],
"Submodule": "BetStatsCommands",
"Module": "Gambling",
"Options": null,
"Requirements": []
},
{ {
"Aliases": [ "Aliases": [
".gamblestats", ".gamblestats",
@@ -3915,6 +3996,20 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".minesweeper",
".mw"
],
"Description": "Creates a spoiler-based minesweeper mini game.\nYou may specify the number of mines.",
"Usage": [
".minesweeper 15"
],
"Submodule": "Games",
"Module": "Games",
"Options": null,
"Requirements": []
},
{ {
"Aliases": [ "Aliases": [
".acrophobia", ".acrophobia",
@@ -3950,6 +4045,51 @@
"ManageMessages Server Permission" "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": [ "Aliases": [
".hangmanlist" ".hangmanlist"
@@ -5522,6 +5662,38 @@
"Bot Owner Only" "Bot Owner Only"
] ]
}, },
{
"Aliases": [
".dmmodule",
".dmmod"
],
"Description": "Toggles whether a module can be used in DMs.",
"Usage": [
".dmmodule Gambling"
],
"Submodule": "GlobalPermissionCommands",
"Module": "Permissions",
"Options": null,
"Requirements": [
"Bot Owner Only"
]
},
{
"Aliases": [
".dmcommand",
".dmcmd"
],
"Description": "Toggles whether a command can be used in DMs.",
"Usage": [
".dmcommand .stats"
],
"Submodule": "GlobalPermissionCommands",
"Module": "Permissions",
"Options": null,
"Requirements": [
"Bot Owner Only"
]
},
{ {
"Aliases": [ "Aliases": [
".resetperms" ".resetperms"
@@ -5791,6 +5963,19 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".banner"
],
"Description": "Shows a mentioned person's banner.",
"Usage": [
".banner @Someone"
],
"Submodule": "Searches",
"Module": "Searches",
"Options": null,
"Requirements": []
},
{ {
"Aliases": [ "Aliases": [
".wikia", ".wikia",
@@ -7778,21 +7963,6 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".xpnotify",
".xpn"
],
"Description": "Sets how the bot should notify you when you get a `server` or `global` level. This is a personal setting and affects only how you receive Global or Server level-up notifications. You can set `dm` (for the bot to send you a direct message), `channel` (to get notified in the channel you sent the last message in) or `none` to disable.",
"Usage": [
".xpnotify global dm",
".xpnotify server channel"
],
"Submodule": "Xp",
"Module": "Xp",
"Options": null,
"Requirements": []
},
{ {
"Aliases": [ "Aliases": [
".xpexclude", ".xpexclude",
@@ -7854,6 +8024,21 @@
"Options": null, "Options": null,
"Requirements": [] "Requirements": []
}, },
{
"Aliases": [
".xplevelset"
],
"Description": "Sets the level of the user you specify.",
"Usage": [
".xplevelset 10 @User"
],
"Submodule": "Xp",
"Module": "Xp",
"Options": null,
"Requirements": [
"Administrator Server Permission"
]
},
{ {
"Aliases": [ "Aliases": [
".xpadd" ".xpadd"

View File

@@ -0,0 +1,43 @@
# DO NOT CHANGE
version: 1
weatherSeed: "w%29';^eGE)9oWHM(aI9I;%1[.r^z2ZS7ShV,l')o(e%#\"hVzb>oxQq^`.&/7srh"
chance:
fish: 80
trash: 15
nothing: 5
starEmojis:
- <:emptystar:1326838565786877962>
- <:onestar:1326838456739168361>
- <:twostar:1326838508198957107>
- <:threestar:1326838525601251429>
- <:fourstar:1326838552520294462>
spotEmojis:
- <:ocean:1328519734953771120>
- <:river:1328519754620862504>
- <:lake:1328315260561788989>
- <:swamp:1328519766083633224>
- <:reef:1328519744646545421>
fish:
- name: Bass
id: 0
weather:
spot:
time:
chance: 100
stars: 4
fluff: Very common.
condition:
image: https://cdn.nadeko.bot/fish/bass.png
emoji: "<:bass:1328520376892002386>"
trash:
- name: Plastic Bag
id: 1002
weather:
spot:
time:
chance: 50
stars: 4
fluff: "Trophy of your contribution to the environment."
condition:
image: https://cdn.nadeko.bot/fish/plasticbag.png
emoji: "<:plasticbag:1328520895454515211>"

View File

@@ -4524,6 +4524,13 @@ todoarchiveadd:
params: params:
- name: - name:
desc: "The name of the archive to be created." 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: todoarchivelist:
desc: Lists all archived todo lists. desc: Lists all archived todo lists.
ex: ex:
@@ -4852,11 +4859,11 @@ temprole:
- '15m @User Jail' - '15m @User Jail'
- '7d @Newbie Trial Member' - '7d @Newbie Trial Member'
params: params:
- days: - time:
desc: "The time after which the role is automatically removed." desc: "The time after which the role is automatically removed."
- user: user:
desc: "The user to give the role to." desc: "The user to give the role to."
- role: role:
desc: "The role to give to the user." desc: "The role to give to the user."
minesweeper: minesweeper:
desc: |- desc: |-
@@ -4904,4 +4911,30 @@ winlb:
- '5' - '5'
params: params:
- page: - page:
desc: "The optional page to display." desc: "The optional page to display."
fish:
desc: |-
Attempt to catch a fish.
Different fish live in different places, at different times of day and in different weather.
ex:
- ''
params:
- { }
fishlist:
desc: |-
Look at your fish catalogue.
Shows how many of each fish you caught and what was the highest quality.
For each caught fish, it also shows its required spot, time of day and weather.
ex:
- ''
params:
- { }
- page:
desc: "The optional page to display."
fishspot:
desc: |-
Shows information about the current fish spot, weather and time.
ex:
- ''
params:
- { }

View File

@@ -1157,5 +1157,19 @@
"notify_desc_protection": "Triggers when antialt, antispam or antiraid is triggered.", "notify_desc_protection": "Triggers when antialt, antispam or antiraid is triggered.",
"notify_desc_addrolerew": "Triggers when a user gets a role as a reward for reaching a level (xprew).", "notify_desc_addrolerew": "Triggers when a user gets a role as a reward for reaching a level (xprew).",
"notify_desc_removerolerew": "Triggers when a user loses a role as a reward for reaching a level (xprew).", "notify_desc_removerolerew": "Triggers when a user loses a role as a reward for reaching a level (xprew).",
"notify_desc_not_found": "No description found for this notify event. Please report this." "notify_desc_not_found": "No description found for this notify event. Please report this.",
"winlb": "Biggest Wins Leaderboard",
"no_banner": "No banner set.",
"fish_nothing": "You caught nothing, try again.",
"fish_caught": "You caught a {0}!",
"fish_quality": "Quality",
"fish_spot": "Spot",
"fish_waiting": "Fishing...",
"fish_weather": "Weather",
"fish_weather_duration": "Each weather period lasts for {0} hours.",
"fish_weather_current": "Current",
"fish_weather_forecast": "Forecast",
"fish_tod": "Time of Day",
"fish_skill_up": "Fishing skill increased to **{0} / {1}**",
"fish_list_title": "Fishing"
} }