Compare commits

...

64 Commits

Author SHA1 Message Date
Kwoth
71f1e43272 .xprewsreset now has correct permissions 2021-12-01 05:41:03 +01:00
Kwoth
8499e1da70 Updated changelog, upped version in the stats to 3.0.9 2021-11-24 01:51:34 +01:00
Kwoth
a2ea806bed Removed slot.numbers from images.yml as they're no longer used anywhere, thx ala 2021-11-24 01:49:03 +01:00
Kwoth
732b5dfeed Merge branch 'v3' of https://gitlab.com/kwoth/nadekobot into v3 2021-11-21 03:01:18 +01:00
Kwoth
d4dcdc761a .economy should not overflow so easily anymore, and big numbers look nicer 2021-11-21 03:01:04 +01:00
Kwoth
57996ba290 Merge branch 'take-award-patch' into 'v3'
Change award and take to not use ShmartNumber

See merge request Kwoth/nadekobot!192
2021-11-21 01:37:30 +00:00
Alan Beatty
4b29b3a239 Change award and take to not use ShmartNumber 2021-11-21 01:37:30 +00:00
Kwoth
54ac955395 Merge branch 'xpservicepatch' into 'v3'
Remove deprecated method from XpService.

See merge request Kwoth/nadekobot!191
2021-11-21 01:09:45 +00:00
Kwoth
f4fa298866 Merge branch 'plantpatch' into 'v3'
ShmartNumber for .plant

See merge request Kwoth/nadekobot!193
2021-11-21 01:09:14 +00:00
Kwoth
b2fafc964f Merge branch 'v3' into 'v3'
Move plant/pick where they belong

See merge request Kwoth/nadekobot!178
2021-11-21 01:08:46 +00:00
Kwoth
22b452e449 Added .warn weights, improved .warnlog 2021-11-21 02:01:21 +01:00
Alan Beatty
fda385a5e4 ShmartNumber for .plant 2021-11-20 18:36:05 -06:00
Alan Beatty
c28f7cfa07 Remove deprecated method from XpService. 2021-11-20 17:55:33 -06:00
Kwoth
0a029a7847 Added image attachment support for .ea if you omit imageUrl 2021-11-21 00:22:10 +01:00
Kwoth
c050ce2123 Added .emojiadd command 2021-11-21 00:07:19 +01:00
Kwoth
27613410dd Another migration fix for users who manually edited their databasea and are unable to update to v3 due to invalid db state. 2021-11-18 18:01:02 +01:00
Kwoth
1513008b4b Merge branch 'v3' of https://gitlab.com/kwoth/nadekobot into v3 2021-11-18 16:41:40 +01:00
Kwoth
bf97cffd84 Fixed cleanup migration if there are waifus which don't have a corresponding entry in DiscordUser 2021-11-18 16:41:27 +01:00
Kwoth
e37d1c46db Merge branch 'patreon-token-refresh' into 'v3'
Patreon Access and Refresh Tokens should now be automatically updated

See merge request Kwoth/nadekobot!189
2021-11-17 18:45:50 +00:00
Kwoth
06c20c6fa4 Patreon Access and Refresh Tokens should now be automatically updated 2021-11-17 18:45:49 +00:00
Kwoth
aa518d60a5 Merge branch 'v3' of https://gitlab.com/kwoth/nadekobot into v3 2021-11-17 16:27:54 +01:00
Kwoth
d55ce7accc Merge branch 'massban2' into 'v3'
Add an audit log reason to massban

See merge request Kwoth/nadekobot!188
2021-11-14 23:41:04 +00:00
Alan Beatty
502c5cec07 Add an audit log reason to massban 2021-11-13 14:43:30 -06:00
Kwoth
ee5c13607b Add support for hackbans on massban
Closes #307

See merge request Kwoth/nadekobot!187
2021-11-13 20:38:01 +00:00
Alan Beatty
5a681a5194 Add support for hackbans on massban
Closes #307
2021-11-13 20:38:01 +00:00
Kwoth
68395372f0 Merge branch 'v3' of https://gitlab.com/kwoth/nadekobot into v3 2021-11-09 16:08:58 +01:00
Kwoth
c8e01bd158 Merge branch 'rerorace' into 'v3'
Fix possible race condition for reaction roles

### Description  
This MR aims to fix a possible race condition on the addition of exclusive reaction roles when the reactions are spammed by the user.

### Changes Proposed  
- Add a `ConcurrentHashSet<(ulong, ulong)>` to keep track of exclusive reaction roles that are being processed.
- Added logic that takes the collection above in consideration.
    - If entry is present, quit silently.
    - Else, perform the reaction role stuff then remove the entry.

### Details  
Exclusive reaction roles are meant to be exactly that - exclusive.

Normally, when a user selects an exclusive role they receive that role. If they select another role, their previous role is removed and the new one is added. There is a bug where if the user spams the reactions for a short period of time, Nadeko will eventually assign them multiple roles that are meant to be exclusive with each other. This happens because the events that handle the addition and removal work in a weird way - first they offload the removal of the roles to a `Task.Run()`, which also happens to have a `Task.Delay()` in it (possibly to avoid Discord ratelimits).

Concurrently, it proceeds to add the role that the user picked. The problem with this approach is that the Task that handles the role removal takes long enough for another reaction event to trigger and start the same work for a different reaction role. Then mayhem ensues, with different events concomitantly adding and removing the roles that previous events have removed or added. In the end, the user ends up with multiple exclusive roles they are not supposed to.

This MR fixes this by having a local field that keeps track of the reaction roles that are being currently processed (a `ConcurrentHashSet<T>` where T is a tuple `(ulong, ulong)` - (message ID, user ID)). When a reaction event runs, it adds itself to the concurrent hashset. If another event triggers and tries to add itself while the previous event still hasn't finished, it silently quits so it doesn't interfere with the current event. I was not entirely satisfied with the way this works, so I tried another system that cancels the old events (with a CancellationToken) instead of just making the new events quit, but that resulted in multiple exclusive roles being temporarily assigned to the user (just for a few seconds, but still).

If another approach is preferred, then please do let me know.

### Notes  
- Methods in that entire file need to be broken down into smaller methods.
- The loop that removes old reactions is **very slow**. Using `Task.WhenAll()` instead of awaiting the removals could help improve performance (but could also trigger ratelimits).

See merge request Kwoth/nadekobot!183
2021-11-09 10:41:49 +00:00
Kaoticz
1d57191700 Fix possible race condition for reaction roles 2021-11-09 10:41:49 +00:00
Kwoth
02c7ded457 Merge branch 'hokutochen-v3-patch-18181' into 'v3'
Fixed broken link

See merge request Kwoth/nadekobot!185
2021-11-09 08:32:37 +00:00
Hokuto Chen
12c483d222 fixed broken "Create a Discord Bot application and invite the bot to your server" link 2021-11-09 08:17:15 +00:00
Kwoth
c80898a7bf Fixed an error that would show up in the console when a club image couldn't be drawn in certain circumstances 2021-11-04 17:01:18 +01:00
Kwoth
aae2805785 Updated changelog.md - Fixed 3.0.7 notes title 2021-11-04 09:05:22 +01:00
Kwoth
fc3695d090 Updated changelog.md for 3.0.8 2021-11-04 09:04:49 +01:00
Kwoth
428429ff44 Fixed creating new users in the database when awarding currency 2021-11-03 16:27:48 +01:00
Kwoth
dc344caec6 Version upped to 3.0.8 2021-11-03 15:23:45 +01:00
Kwoth
2a4d55f81d Merge branch 'v3-dev' into 'v3'
Slots redesign nad images moved to images.yml

See merge request Kwoth/nadekobot!181
2021-11-03 14:22:51 +00:00
Kwoth
d090aa23ee Slots redesign nad images moved to images.yml 2021-11-03 14:22:51 +00:00
Kwoth
65062306c6 Merge branch 'tbodt-v3-patch-66982' into 'v3'
Reduce required permissions for deleting quotes

See merge request Kwoth/nadekobot!182
2021-10-30 11:54:03 +00:00
tbodt
9ae3b66fc2 Reduce required permissions for deleting quotes 2021-10-27 01:25:40 +00:00
Kwoth
c4ba43ec6d Merge branch 'v3-dev' into 'v3'
backport of public nsfw module

See merge request Kwoth/nadekobot!176
2021-10-21 23:35:58 +00:00
Kwoth
1141791ce5 backport of public nsfw module 2021-10-21 23:35:58 +00:00
Kwoth
49f1ef7db0 Merge branch 'hokutochen-v3-patch-79292' into 'v3'
Updated images for V3 updater

See merge request Kwoth/nadekobot!179
2021-10-21 23:35:11 +00:00
Hokuto Chen
a70c35e101 Updated images for V3 updater 2021-10-21 23:35:11 +00:00
Yuno Gasai
717543f6c2 Move plant/pick where they belong 2021-10-19 09:28:55 -04:00
Kwoth
b61b1dbfaa Merge branch 'memfix' into 'v3'
Fixed memory counter not refreshing over time

See merge request Kwoth/nadekobot!177
2021-10-17 22:56:10 +00:00
Kaoticz
92365fd22d Fixed memory counter not refreshing over time 2021-10-17 22:56:10 +00:00
Kwoth
24a4745193 Merge branch 'v3-dev' into 'v3'
Created VotesApi project nad re-worked vote rewards handling

See merge request Kwoth/nadekobot!172
2021-10-15 22:06:30 +00:00
Kwoth
1af75fd813 Created VotesApi project nad re-worked vote rewards handling 2021-10-15 22:06:30 +00:00
Kwoth
18160164eb Merge branch 'memsize' into 'v3'
Corrected memory usage on StatusService

See merge request Kwoth/nadekobot!175
2021-10-15 22:04:30 +00:00
Kaoticz
2fd7d97025 Corrected memory usage on StatusService 2021-10-15 22:04:30 +00:00
Kwoth
6ada15049d Merge branch 'ban-kick-patch' into 'v3'
TrimTo to avoid length limit for ban/kick reasons

See merge request Kwoth/nadekobot!174
2021-10-13 17:51:08 +00:00
Alan Beatty
0ebc40b95c TrimTo to avoid length limit for ban/kick reasons 2021-10-12 17:07:46 -05:00
Kwoth
02de25a931 Typo 2021-10-09 19:55:19 +02:00
Kwoth
0b395e9176 Merge branch 'v3' of https://gitlab.com/kwoth/nadekobot into v3 2021-10-09 19:49:26 +02:00
Kwoth
4532f992cd Fixed field not preserving inline after string replacements. closes #308 2021-10-09 19:48:54 +02:00
Kwoth
34201f0558 Merge branch 'v3' into 'v3'
Docs fix

See merge request Kwoth/nadekobot!168
2021-10-09 17:43:18 +00:00
Hunter T
d2f4d63183 Docs fix 2021-10-09 17:43:18 +00:00
Kwoth
b41c014869 Merge branch 'Kieteyuku-v3-patch-34025' into 'v3'
Use … in TrimTo to decrease necessary deletions

See merge request Kwoth/nadekobot!171
2021-10-09 17:42:24 +00:00
Ene
d348347762 Update StringExtensions.cs 2021-10-08 20:20:50 +00:00
Kwoth
db7cf3d757 Upped version to 3.0.7, Updated changelog 2021-10-05 13:12:28 +02:00
Kwoth
83ea046d5f Fixed %users% and %shard.usercount% placeholders not showing correct member count 2021-10-05 13:09:52 +02:00
Kwoth
d5c94424e9 Merge branch 'v3-docker' into 'v3'
install youtube-dl through pip for a better availability of cdn

See merge request Kwoth/nadekobot!165
2021-10-01 08:26:15 +00:00
Kwoth
ff95b3d00f - .streamsclear re-added. It will remove all followed streams on the server.
- .gifts now have 3 new ✂️ Haircut 🧻 ToiletPaper and 🥀 WiltedRose which **reduce** waifu's value
- Added a new multiplier (waifu.multi.negative_gift_effect default 0.5, changeable via .config gambling or data/gambling.yml)
2021-09-30 08:53:15 +02:00
Veovis
4b5fa3bb04 install youtube-dl through pip for a better availability of cdn 2021-09-27 21:21:44 +02:00
135 changed files with 9516 additions and 1992 deletions

View File

@@ -2,7 +2,69 @@
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## Unreleased
## [3.0.10] - 01.12.2021
### Changed
- `.warn` now supports weighted warnings
- `.warnlog` will now show current amount and total amount of warnings
### Fixed
- `.xprewsreset` now has correct permissions
### Removed
- Removed slot.numbers from `images.yml` as they're no longer used
## [3.0.9] - 21.11.2021
### Changed
- `.ea` will now use an image attachments if you omit imageUrl
### Added
- Added `.emojiadd` with 3 overloads
- `.ea :customEmoji:` which copies another server's emoji
- `.ea newName :customEmoji:` which copies emoji under a different name
- `.ea emojiName <imagelink.png>` which creates a new emoji from the specified image
- Patreon Access and Refresh Tokens should now be automatically updated once a month as long as the user has provided the necessary credentials in creds.yml file:
- `Patreon.ClientId`
- `Patreon.RefreshToken` (will also get updated once a month but needs an initial value)
- `Patreon.ClientSecret`
- `Patreon.CampaignId`
### Fixed
- Fixed an error that would show up in the console when a club image couldn't be drawn in certain circumstances
## [3.0.8] - 03.11.2021
### Added
- Created VotesApi project nad re-worked vote rewards handling
- Updated votes entries in creds.yml with explanations on how to set up vote links
### Fixed
- Fixed adding currency to users who don't exist in the database
- Memory used by the bot is now correct (thanks to kotz)
- Ban/kick will no longer fail due to too long reasons
- Fixed some fields not preserving inline after string replacements
### Changed
- `images.json` moved to `images.yml`
- Links will use the new cdn url
- Heads and Tails images will be updated if you haven't changed them already
- `.slot` redesigned (and updated entries in `images.yml`)
- Reduced required permissions for .qdel (thanks to tbodt)
## [3.0.7] - 05.10.2021
### Added
- `.streamsclear` re-added. It will remove all followed streams on the server.
- `.gifts` now have 3 new ✂️ Haircut 🧻 ToiletPaper and 🥀 WiltedRose which **reduce** waifu's value
- They are called negative gifts
- They show up at the end of the `.gifts` page and are marked with a broken heart
- They have a separate multiplier (`waifu.multi.negative_gift_effect` default 0.5, changeable via `.config gambling` or `data/gambling.yml`)
- When gifted, the waifu's price will be reduced by the `price * multiplier`
- Negative gifts don't show up in `.waifuinfo` nor is the record of them kept in the database
### Fixed
- Fixed `%users%` and `%shard.usercount%` placeholders not showing correct values
## [3.0.6] - 27.09.2021

View File

@@ -24,10 +24,11 @@ WORKDIR /app
RUN set -xe; \
useradd -m nadeko; \
apt-get update; \
apt-get install -y libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 sudo; \
apt-get install -y libopus0 libsodium23 libsqlite3-0 curl ffmpeg python3 python3-pip sudo; \
update-alternatives --install /usr/bin/python python /usr/bin/python3.7 1; \
echo 'Defaults>nadeko env_keep+="ASPNETCORE_* DOTNET_* NadekoBot_* shard_id total_shards TZ"' > /etc/sudoers.d/nadeko; \
curl -L https://yt-dl.org/downloads/latest/youtube-dl -o /usr/local/bin/youtube-dl; \
pip3 install --upgrade youtube-dl; \
apt-get remove -y python3-pip; \
chmod +x /usr/local/bin/youtube-dl
COPY --from=build /app ./

View File

@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.Coordinator", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.Generators", "src\NadekoBot.Generators\NadekoBot.Generators.csproj", "{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NadekoBot.VotesApi", "src\NadekoBot.VotesApi\NadekoBot.VotesApi.csproj", "{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -62,6 +64,12 @@ Global
{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU
{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8}.Release|Any CPU.Build.0 = Release|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.GlobalNadeko|Any CPU.ActiveCfg = Debug|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.GlobalNadeko|Any CPU.Build.0 = Debug|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -73,6 +81,7 @@ Global
{DB448DD4-C97F-40E9-8BD3-F605FF1FF833} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2}
{AE9B7F8C-81D7-4401-83A3-643B38258374} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2}
{3BC3BDF8-1A0B-45EB-AB2B-C0891D4D37B8} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2}
{3BC82CFE-BEE7-451F-986B-17EDD1570C4F} = {04929013-5BAB-42B0-B9B2-8F2BB8F16AF2}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {5F3F555C-855F-4BE8-B526-D062D3E8ACA4}

View File

@@ -134,6 +134,8 @@ Compared to using tmux, this method requires a little bit more work to set up, b
echo "[Unit]
Description=NadekoBot service
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=2
[Service]
Type=simple
@@ -144,10 +146,11 @@ Compared to using tmux, this method requires a little bit more work to set up, b
# source code.
#ExecStartPre=/usr/bin/dotnet build ../src/NadekoBot/NadekoBot.csproj -c Release -o output/
ExecStart=/usr/bin/dotnet NadekoBot.dll
Restart=on-failure
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=NadekoBot
Restart=always
[Install]
WantedBy=multi-user.target" | sudo tee /etc/systemd/system/nadeko.service
@@ -172,12 +175,16 @@ This method is similar to the one above, but requires one extra step, with the a
echo "[Unit]
Description=NadekoBot service
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=2
[Service]
Type=simple
User=$USER
WorkingDirectory=$PWD
WorkingDirectory=$_WORKING_DIR
ExecStart=/bin/bash NadekoRun.sh
Restart=on-failure
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=NadekoBot
@@ -191,14 +198,14 @@ This method is similar to the one above, but requires one extra step, with the a
4. Use the following command to create a script that will be used to start Nadeko:
```bash
echo "#\!/bin/bash
echo \"\"
echo \"Running NadekoBot in the background with auto restart\"
{
echo '#!/bin/bash'
echo ""
echo "echo \"Running NadekoBot in the background with auto restart\"
youtube-dl -U
# If you want Nadeko to be compiled prior to every startup, uncomment the lines
# below. Note that it's not neccessary unless you are personally modifying the
# below. Note that it's not necessary unless you are personally modifying the
# source code.
#echo \"Compiling NadekoBot...\"
#cd \"$PWD\"/nadekobot
@@ -207,23 +214,33 @@ This method is similar to the one above, but requires one extra step, with the a
echo \"Starting NadekoBot...\"
while true; do
{
cd \"$PWD\"/nadekobot/output
dotnet NadekoBot.dll
## If a non-zero exit code is produced, exit this script.
} || {
error_code=\"\$?\"
if [[ -d $PWD/nadekobot/output ]]; then
cd $PWD/nadekobot/output || {
echo \"Failed to change working directory to $PWD/nadekobot/output\" >&2
echo \"Ensure that the working directory inside of '/etc/systemd/system/nadeko.service' is correct\"
echo \"Exiting...\"
exit 1
}
else
echo \"$PWD/nadekobot/output doesn't exist\"
exit 1
fi
dotnet NadekoBot.dll || {
echo \"An error occurred when trying to start NadekBot\"
echo \"EXIT CODE: \$?\"
exit \"\$error_code\"
echo \"Exiting...\"
exit 1
}
echo \"Waiting for 5 seconds...\"
sleep 5
youtube-dl -U
echo \"Restarting NadekoBot...\"
done
echo \"Stopping NadekoBot...\"" > NadekoRun.sh
echo \"Stopping NadekoBot...\""
} > NadekoRun.sh
```
5. Start Nadeko:
- `sudo systemctl start nadeko.service && sudo systemctl enable nadeko.service`
- `sudo systemctl start nadeko.service && sudo systemctl enable nadeko.service`

View File

@@ -21,7 +21,7 @@
#### Prerequisites
- Windows 8 or later (64-bit)
- [Create a Discord Bot application and invite the bot to your server](../../creds-guide.md)
- [Create a Discord Bot application and invite the bot to your server](../creds-guide.md)
**Optional**
@@ -32,12 +32,13 @@
- Download and run the [NadekoBot v3 Updater][Updater].
- Click on the + at the top left to create a new bot.
![NadekoBot Updater](https://i.imgur.com/KZV49uf.png "NadekoBot Updater")
![NadekoBot Updater](https://i.imgur.com/FmR7F7o.png "NadekoBot Updater")
- Give your bot a name and then click **`Go to setup`** at the lower right.
![Create a new bot](https://i.imgur.com/Xnp7iQL.png "Create a new bot")
![Create a new bot](https://i.imgur.com/JxtRk9e.png "Create a new bot")
- Click on **`DOWNLOAD`** at the lower right
![Bot Setup](https://i.imgur.com/6RMXNqw.png "Bot Setup")
![Bot Setup](https://i.imgur.com/HqAl36p.png "Bot Setup")
- Click on **`Install`** next to **`Redis`**.
- **Note: If Redis fails to install, install Redis manually here: [Redis Installer](https://github.com/MicrosoftArchive/redis/releases/tag/win-3.0.504) Download and run the **`.msi`** file.
- If you will use the music module, click on **`Install`** next to **`FFMPEG`** and **`Youtube-DL`**.
- If any dependencies fail to install, you can temporarily disable your Windows Defender/AV until you install them. If you don't want to, then read [the last section of this guide](#Manual-Prerequisite-Installation).
- When installation is finished, click on **`CREDS`** to the left of **`RUN`** at the lower right.

View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

1
src/NadekoBot.VotesApi/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
store/

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NadekoBot.VotesApi.Controllers;
namespace NadekoBot.VotesApi
{
public class AuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "AUTHORIZATION_SCHEME";
public const string DiscordsClaim = "DISCORDS_CLAIM";
public const string TopggClaim = "TOPGG_CLAIM";
private readonly IConfiguration _conf;
public AuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IConfiguration conf)
: base(options, logger, encoder, clock)
{
_conf = conf;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>();
if (_conf[ConfKeys.DISCORDS_KEY].Trim() == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new(DiscordsClaim, "true"));
if (_conf[ConfKeys.TOPGG_KEY] == Request.Headers["Authorization"].ToString().Trim())
claims.Add(new Claim(TopggClaim, "true"));
return Task.FromResult(AuthenticateResult.Success(new(new(new ClaimsIdentity(claims)), SchemeName)));
}
}
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.VotesApi
{
public static class ConfKeys
{
public const string DISCORDS_KEY = "DiscordsKey";
public const string TOPGG_KEY = "TopGGKey";
}
}

View File

@@ -0,0 +1,26 @@
namespace NadekoBot.VotesApi
{
public class DiscordsVoteWebhookModel
{
/// <summary>
/// The ID of the user who voted
/// </summary>
public string User { get; set; }
/// <summary>
/// The ID of the bot which recieved the vote
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Contains totalVotes, votesMonth, votes24, hasVoted - a list of IDs of users who have voted this month, and
/// Voted24 - a list of IDs of users who have voted today
/// </summary>
public string Votes { get; set; }
/// <summary>
/// The type of event, whether it is a vote event or test event
/// </summary>
public string Type { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.VotesApi
{
public static class Policies
{
public const string DiscordsAuth = "DiscordsAuth";
public const string TopggAuth = "TopggAuth";
}
}

View File

@@ -0,0 +1,30 @@
namespace NadekoBot.VotesApi
{
public class TopggVoteWebhookModel
{
/// <summary>
/// Discord ID of the bot that received a vote.
/// </summary>
public string Bot { get; set; }
/// <summary>
/// Discord ID of the user who voted.
/// </summary>
public string User { get; set; }
/// <summary>
/// The type of the vote (should always be "upvote" except when using the test button it's "test").
/// </summary>
public string Type { get; set; }
/// <summary>
/// Whether the weekend multiplier is in effect, meaning users votes count as two.
/// </summary>
public bool Weekend { get; set; }
/// <summary>
/// Query string params found on the /bot/:ID/vote page. Example: ?a=1&b=2.
/// </summary>
public string Query { get; set; }
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NadekoBot.VotesApi.Services;
namespace NadekoBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class DiscordsController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public DiscordsController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewDiscordsVotesAsync();
if(votes.Count > 0)
_logger.LogInformation("Sending {NewDiscordsVotes} new discords votes.", votes.Count);
return votes;
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NadekoBot.VotesApi.Services;
namespace NadekoBot.VotesApi.Controllers
{
[ApiController]
[Route("[controller]")]
public class TopGgController : ControllerBase
{
private readonly ILogger<TopGgController> _logger;
private readonly IVotesCache _cache;
public TopGgController(ILogger<TopGgController> logger, IVotesCache cache)
{
_logger = logger;
_cache = cache;
}
[HttpGet("new")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IEnumerable<Vote>> New()
{
var votes = await _cache.GetNewTopGgVotesAsync();
if(votes.Count > 0)
_logger.LogInformation("Sending {NewTopggVotes} new topgg votes.", votes.Count);
return votes;
}
}
}

View File

@@ -0,0 +1,52 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using NadekoBot.VotesApi.Services;
namespace NadekoBot.VotesApi.Controllers
{
[ApiController]
public class WebhookController : ControllerBase
{
private readonly ILogger<WebhookController> _logger;
private readonly IVotesCache _votesCache;
private readonly IConfiguration _conf;
public WebhookController(ILogger<WebhookController> logger, IVotesCache votesCache, IConfiguration conf)
{
_logger = logger;
_votesCache = votesCache;
_conf = conf;
}
[HttpPost("/discordswebhook")]
[Authorize(Policy = Policies.DiscordsAuth)]
public async Task<IActionResult> DiscordsWebhook([FromBody]DiscordsVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"discords.com");
await _votesCache.AddNewDiscordsVote(data.User);
return Ok();
}
[HttpPost("/topggwebhook")]
[Authorize(Policy = Policies.TopggAuth)]
public async Task<IActionResult> TopggWebhook([FromBody] TopggVoteWebhookModel data)
{
_logger.LogInformation("User {UserId} has voted for Bot {BotId} on {Platform}",
data.User,
data.Bot,
"top.gg");
await _votesCache.AddNewTopggVote(data.User);
return Ok();
}
}
}

View File

@@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
COPY ["src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj", "NadekoBot.VotesApi/"]
RUN dotnet restore "src/NadekoBot.VotesApi/NadekoBot.VotesApi.csproj"
COPY . .
WORKDIR "/src/NadekoBot.VotesApi"
RUN dotnet build "NadekoBot.VotesApi.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "NadekoBot.VotesApi.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "NadekoBot.VotesApi.dll"]

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MorseCode.ITask" Version="2.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.6.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace NadekoBot.VotesApi
{
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
}
}

View File

@@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:16451",
"sslPort": 44323
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"NadekoBot.VotesApi": {
"commandName": "Project",
"dotnetRunMessages": "true",
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,46 @@
## Votes Api
This api is used if you want your bot to be able to reward users who vote for it on discords.com or top.gg
#### [GET] `/discords/new`
Get the discords votes received after previous call to this endpoint.
Input full url of this endpoint in your creds.yml file under Discords url field.
For example "https://api.my.cool.bot/discords/new"
#### [GET] `/topgg/new`
Get the topgg votes received after previous call to this endpoint.
Input full url of this endpoint in your creds.yml file under Topgg url field.
For example "https://api.my.cool.bot/topgg/new"
#### [POST] `/discordswebhook`
Input this endpoint as the webhook on discords.com bot edit page
model: https://docs.botsfordiscord.com/methods/receiving-votes
For example "https://api.my.cool.bot/topggwebhook"
#### [POST] `/topggwebhook`
Input this endpoint as the webhook https://top.gg/bot/:your-bot-id/webhooks (replace :your-bot-id with your bot's id)
model: https://docs.top.gg/resources/webhooks/#schema
For example "https://api.my.cool.bot/discordswebhook"
Input your super-secret header value in appsettings.json's DiscordsKey and TopGGKey fields
They must match your DiscordsKey and TopGG key respectively, as well as your secrets in the discords.com and top.gg webhook setup pages
Full Example:
⚠ Change TopggKey and DiscordsKey to a secure long string
⚠ You can use https://www.random.org/strings/?num=1&len=20&digits=on&upperalpha=on&loweralpha=on&unique=on&format=html&rnd=new to generate it
`creds.yml`
```yml
votes:
TopggServiceUrl: "https://api.my.cool.bot/topgg"
TopggKey: "my_topgg_key"
DiscordsServiceUrl: "https://api.my.cool.bot/discords"
DiscordsKey: "my_discords_key"
```
`appsettings.json`
```json
...
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
...
```

View File

@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using MorseCode.ITask;
namespace NadekoBot.VotesApi.Services
{
public class FileVotesCache : IVotesCache
{
private const string statsFile = "store/stats.json";
private const string topggFile = "store/topgg.json";
private const string discordsFile = "store/discords.json";
private readonly SemaphoreSlim locker = new SemaphoreSlim(1, 1);
public FileVotesCache()
{
if (!Directory.Exists("store"))
Directory.CreateDirectory("store");
if(!File.Exists(topggFile))
File.WriteAllText(topggFile, "[]");
if(!File.Exists(discordsFile))
File.WriteAllText(discordsFile, "[]");
}
public ITask AddNewTopggVote(string userId)
{
return AddNewVote(topggFile, userId);
}
public ITask AddNewDiscordsVote(string userId)
{
return AddNewVote(discordsFile, userId);
}
private async ITask AddNewVote(string file, string userId)
{
await locker.WaitAsync();
try
{
var votes = await GetVotesAsync(file);
votes.Add(userId);
await File.WriteAllTextAsync(file , JsonSerializer.Serialize(votes));
}
finally
{
locker.Release();
}
}
public async ITask<IList<Vote>> GetNewTopGgVotesAsync()
{
var votes = await EvictTopggVotes();
return votes;
}
public async ITask<IList<Vote>> GetNewDiscordsVotesAsync()
{
var votes = await EvictDiscordsVotes();
return votes;
}
private ITask<List<Vote>> EvictTopggVotes()
=> EvictVotes(topggFile);
private ITask<List<Vote>> EvictDiscordsVotes()
=> EvictVotes(discordsFile);
private async ITask<List<Vote>> EvictVotes(string file)
{
await locker.WaitAsync();
try
{
var ids = await GetVotesAsync(file);
await File.WriteAllTextAsync(file, "[]");
return ids?
.Select(x => (Ok: ulong.TryParse(x, out var r), Id: r))
.Where(x => x.Ok)
.Select(x => new Vote
{
UserId = x.Id
})
.ToList();
}
finally
{
locker.Release();
}
}
private async ITask<IList<string>> GetVotesAsync(string file)
{
await using var fs = File.Open(file, FileMode.Open);
var votes = await JsonSerializer.DeserializeAsync<List<string>>(fs);
return votes;
}
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using MorseCode.ITask;
namespace NadekoBot.VotesApi.Services
{
public interface IVotesCache
{
ITask<IList<Vote>> GetNewTopGgVotesAsync();
ITask<IList<Vote>> GetNewDiscordsVotesAsync();
ITask AddNewTopggVote(string userId);
ITask AddNewDiscordsVote(string userId);
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
using NadekoBot.VotesApi.Services;
namespace NadekoBot.VotesApi
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSingleton<IVotesCache, FileVotesCache>();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "NadekoBot.VotesApi", Version = "v1" });
});
services
.AddAuthentication(opts =>
{
opts.DefaultScheme = AuthHandler.SchemeName;
opts.AddScheme<AuthHandler>(AuthHandler.SchemeName, AuthHandler.SchemeName);
});
services
.AddAuthorization(opts =>
{
opts.DefaultPolicy = new AuthorizationPolicyBuilder(AuthHandler.SchemeName)
.RequireAssertion(x => false)
.Build();
opts.AddPolicy(Policies.DiscordsAuth, policy => policy.RequireClaim(AuthHandler.DiscordsClaim));
opts.AddPolicy(Policies.TopggAuth, policy => policy.RequireClaim(AuthHandler.TopggClaim));
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "NadekoBot.VotesApi v1"));
}
app.UseHttpsRedirection();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace NadekoBot.VotesApi
{
public class Vote
{
public ulong UserId { get; set; }
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"DiscordsKey": "my_discords_key",
"TopGGKey": "my_topgg_key",
"AllowedHosts": "*"
}

View File

@@ -28,7 +28,7 @@ namespace NadekoBot
private readonly IBotCredentials _creds;
private readonly CommandService _commandService;
private readonly DbService _db;
private readonly BotCredsProvider _credsProvider;
private readonly IBotCredsProvider _credsProvider;
public event Func<GuildConfig, Task> JoinedGuild = delegate { return Task.CompletedTask; };
@@ -95,8 +95,8 @@ namespace NadekoBot
}
var svcs = new ServiceCollection()
.AddTransient<IBotCredentials>(_ => _creds) // bot creds
.AddSingleton(_credsProvider)
.AddTransient<IBotCredentials>(_ => _credsProvider.GetCreds()) // bot creds
.AddSingleton<IBotCredsProvider>(_credsProvider)
.AddSingleton(_db) // database
.AddRedis(_creds.RedisOptions) // redis
.AddSingleton(Client) // discord socket client
@@ -145,7 +145,8 @@ namespace NadekoBot
svcs.Scan(scan => scan
.FromAssemblyOf<IReadyExecutor>()
.AddClasses(classes => classes.AssignableToAny(
.AddClasses(classes => classes
.AssignableToAny(
// services
typeof(INService),

View File

@@ -12,7 +12,7 @@ namespace NadekoBot.Common.Attributes
{
public override Task<PreconditionResult> CheckPermissionsAsync(ICommandContext context, CommandInfo executingCommand, IServiceProvider services)
{
var creds = services.GetRequiredService<BotCredsProvider>().GetCreds();
var creds = services.GetRequiredService<IBotCredsProvider>().GetCreds();
return Task.FromResult((creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id ? PreconditionResult.FromSuccess() : PreconditionResult.FromError("Not owner")));
}

View File

@@ -13,7 +13,7 @@ namespace NadekoBot.Common
OwnerIds = new List<ulong>();
TotalShards = 1;
GoogleApiKey = string.Empty;
Votes = new(string.Empty, string.Empty);
Votes = new(string.Empty, string.Empty, string.Empty, string.Empty);
Patreon = new(string.Empty, string.Empty, string.Empty, string.Empty);
BotListToken = string.Empty;
CleverbotApiKey = string.Empty;
@@ -73,16 +73,6 @@ go to https://www.patreon.com/portal -> my clients -> create client")]
Change only if you've changed the coordinator address or port.")]
public string CoordinatorUrl { get; set; }
[YamlIgnore]
public string PatreonCampaignId => Patreon?.CampaignId;
[YamlIgnore]
public string PatreonAccessToken => Patreon?.AccessToken;
[YamlIgnore]
public string VotesUrl => Votes?.Url;
[YamlIgnore]
public string VotesToken => Votes.Key;
[Comment(@"Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)")]
public string RapidApiKey { get; set; }
@@ -126,11 +116,9 @@ Windows default
// todo fixup patreon
public sealed record PatreonSettings
{
[Comment(@"Access token. You have to manually update this 1st of each month by refreshing the token on https://patreon.com/portal")]
public string ClientId { get; set; }
public string AccessToken { get; set; }
[Comment(@"Unused atm")]
public string RefreshToken { get; set; }
[Comment(@"Unused atm")]
public string ClientSecret { get; set; }
[Comment(@"Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type ""prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);"" in the console. (ctrl + shift + i)")]
@@ -143,19 +131,44 @@ Windows default
ClientSecret = clientSecret;
CampaignId = campaignId;
}
public PatreonSettings()
{
}
}
public sealed record VotesSettings
{
[Comment(@"")]
public string Url { get; set; }
[Comment(@"")]
public string Key { get; set; }
[Comment(@"top.gg votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com")]
public string TopggServiceUrl { get; set; }
[Comment(@"Authorization header value sent to the TopGG service url with each request
This should be equivalent to the TopggKey in your NadekoBot.Votes api appsettings.json file")]
public string TopggKey { get; set; }
[Comment(@"discords.com votes service url
This is the url of your instance of the NadekoBot.Votes api
Example: https://votes.my.cool.bot.com")]
public string DiscordsServiceUrl { get; set; }
[Comment(@"Authorization header value sent to the Discords service url with each request
This should be equivalent to the DiscordsKey in your NadekoBot.Votes api appsettings.json file")]
public string DiscordsKey { get; set; }
public VotesSettings(string url, string key)
public VotesSettings()
{
Url = url;
Key = key;
}
public VotesSettings(string topggServiceUrl, string topggKey, string discordsServiceUrl, string discordsKey)
{
TopggServiceUrl = topggServiceUrl;
TopggKey = topggKey;
DiscordsServiceUrl = discordsServiceUrl;
DiscordsKey = discordsKey;
}
}

View File

@@ -12,16 +12,14 @@ namespace NadekoBot
string GoogleApiKey { get; }
ICollection<ulong> OwnerIds { get; }
string RapidApiKey { get; }
string PatreonAccessToken { get; }
Creds.DbOptions Db { get; }
string OsuApiKey { get; }
int TotalShards { get; }
string PatreonCampaignId { get; }
Creds.PatreonSettings Patreon { get; }
string CleverbotApiKey { get; }
RestartConfig RestartCommand { get; }
string VotesUrl { get; }
string VotesToken { get; }
Creds.VotesSettings Votes { get; }
string BotListToken { get; }
string RedisOptions { get; }
string LocationIqApiKey { get; }

View File

@@ -1,10 +1,12 @@
using System;
using NadekoBot.Common.Yml;
namespace NadekoBot.Common
{
public class ImageUrls
{
public int Version { get; set; } = 2;
[Comment("DO NOT CHANGE")]
public int Version { get; set; } = 3;
public CoinData Coins { get; set; }
public Uri[] Currency { get; set; }
@@ -25,7 +27,6 @@ namespace NadekoBot.Common
public class SlotData
{
public Uri[] Emojis { get; set; }
public Uri[] Numbers { get; set; }
public Uri Bg { get; set; }
}

View File

@@ -0,0 +1,49 @@
using System;
namespace NadekoBot.Common
{
public class OldImageUrls
{
public int Version { get; set; } = 2;
public CoinData Coins { get; set; }
public Uri[] Currency { get; set; }
public Uri[] Dice { get; set; }
public RategirlData Rategirl { get; set; }
public XpData Xp { get; set; }
//new
public RipData Rip { get; set; }
public SlotData Slots { get; set; }
public class RipData
{
public Uri Bg { get; set; }
public Uri Overlay { get; set; }
}
public class SlotData
{
public Uri[] Emojis { get; set; }
public Uri[] Numbers { get; set; }
public Uri Bg { get; set; }
}
public class CoinData
{
public Uri[] Heads { get; set; }
public Uri[] Tails { get; set; }
}
public class RategirlData
{
public Uri Matrix { get; set; }
public Uri Dot { get; set; }
}
public class XpData
{
public Uri Bg { get; set; }
}
}
}

View File

@@ -177,13 +177,13 @@ namespace NadekoBot.Common.Replacements
/*OBSOLETE*/
_reps.TryAdd("%servers%", () => c.Guilds.Count.ToString());
#if !GLOBAL_NADEKO
_reps.TryAdd("%users%", () => c.Guilds.Sum(s => s.Users.Count).ToString());
_reps.TryAdd("%users%", () => c.Guilds.Sum(g => g.MemberCount).ToString());
#endif
/*NEW*/
_reps.TryAdd("%shard.servercount%", () => c.Guilds.Count.ToString());
#if !GLOBAL_NADEKO
_reps.TryAdd("%shard.usercount%", () => c.Guilds.Sum(s => s.Users.Count).ToString());
_reps.TryAdd("%shard.usercount%", () => c.Guilds.Sum(g => g.MemberCount).ToString());
#endif
_reps.TryAdd("%shard.id%", () => c.ShardId.ToString());
return this;

View File

@@ -69,6 +69,7 @@ namespace NadekoBot.Common.Replacements
var newF = new SmartTextEmbedField();
newF.Name = Replace(f.Name);
newF.Value = Replace(f.Value);
newF.Inline = f.Inline;
fields.Add(newF);
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using Discord;
using Discord.Commands;
namespace NadekoBot.Common.TypeReaders
{
public sealed class EmoteTypeReader : NadekoTypeReader<Emote>
{
public override Task<TypeReaderResult> ReadAsync(ICommandContext ctx, string input)
{
if (!Emote.TryParse(input, out var emote))
return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid emote"));
return Task.FromResult(TypeReaderResult.FromSuccess(emote));
}
}
}

View File

@@ -20,6 +20,7 @@ namespace NadekoBot.Common.Yml
.WithTypeConverter(new Rgba32Converter())
.WithTypeConverter(new CultureInfoConverter())
.WithTypeConverter(new UriConverter())
.IgnoreUnmatchedProperties()
.Build();
}
}

View File

@@ -1,4 +1,5 @@
using NadekoBot.Db.Models;
using System;
using NadekoBot.Db.Models;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Discord;
@@ -138,14 +139,15 @@ WHERE UserId={userId};");
// just update the amount, there is no new user data
if (!updatedUserData)
{
ctx.Database.ExecuteSqlInterpolated($@"
var rows = ctx.Database.ExecuteSqlInterpolated($@"
UPDATE OR IGNORE DiscordUser
SET CurrencyAmount=CurrencyAmount+{amount}
WHERE UserId={userId};
INSERT OR IGNORE INTO DiscordUser (UserId, Username, Discriminator, AvatarId, CurrencyAmount)
VALUES ({userId}, {name}, {discrim}, {avatarId}, {amount});
INSERT OR IGNORE INTO DiscordUser (UserId, Username, Discriminator, AvatarId, CurrencyAmount, TotalXp)
VALUES ({userId}, {name}, {discrim}, {avatarId}, {amount}, 0);
");
}
else
{
@@ -157,8 +159,8 @@ SET CurrencyAmount=CurrencyAmount+{amount},
AvatarId={avatarId}
WHERE UserId={userId};
INSERT OR IGNORE INTO DiscordUser (UserId, Username, Discriminator, AvatarId, CurrencyAmount)
VALUES ({userId}, {name}, {discrim}, {avatarId}, {amount});
INSERT OR IGNORE INTO DiscordUser (UserId, Username, Discriminator, AvatarId, CurrencyAmount, TotalXp)
VALUES ({userId}, {name}, {discrim}, {avatarId}, {amount}, 0);
");
}
return true;
@@ -167,7 +169,7 @@ VALUES ({userId}, {name}, {discrim}, {avatarId}, {amount});
public static decimal GetTotalCurrency(this DbSet<DiscordUser> users)
{
return users
.Sum(x => x.CurrencyAmount);
.Sum((Func<DiscordUser, decimal>)(x => x.CurrencyAmount));
}
public static decimal GetTopOnePercentCurrency(this DbSet<DiscordUser> users, ulong botId)

View File

@@ -54,7 +54,6 @@ namespace NadekoBot.Db
.Include(gc => gc.CommandCooldowns)
.Include(gc => gc.FollowedStreams)
.Include(gc => gc.StreamRole)
.Include(gc => gc.NsfwBlacklistedTags)
.Include(gc => gc.XpSettings)
.ThenInclude(x => x.ExclusionList)
.Include(gc => gc.DelMsgOnCmdChannels)

View File

@@ -91,7 +91,6 @@ namespace NadekoBot.Services.Database.Models
public bool WarningsInitialized { get; set; }
public HashSet<SlowmodeIgnoredUser> SlowmodeIgnoredUsers { get; set; }
public HashSet<SlowmodeIgnoredRole> SlowmodeIgnoredRoles { get; set; }
public HashSet<NsfwBlacklitedTag> NsfwBlacklistedTags { get; set; } = new HashSet<NsfwBlacklitedTag>();
public List<ShopEntry> ShopEntries { get; set; }
public ulong? GameVoiceChannel { get; set; } = null;

View File

@@ -0,0 +1,16 @@
using System;
namespace NadekoBot.Services.Database.Models
{
public class NsfwBlacklistedTag : DbEntity
{
public ulong GuildId { get; set; }
public string Tag { get; set; }
public override int GetHashCode()
=> Tag.GetHashCode(StringComparison.InvariantCulture);
public override bool Equals(object obj)
=> obj is NsfwBlacklistedTag x && x.Tag == Tag;
}
}

View File

@@ -1,21 +0,0 @@
using System;
namespace NadekoBot.Services.Database.Models
{
public class NsfwBlacklitedTag : DbEntity
{
public string Tag { get; set; }
public override int GetHashCode()
{
return Tag.GetHashCode(StringComparison.InvariantCulture);
}
public override bool Equals(object obj)
{
return obj is NsfwBlacklitedTag x
? x.Tag == Tag
: false;
}
}
}

View File

@@ -8,5 +8,6 @@
public bool Forgiven { get; set; }
public string ForgivenBy { get; set; }
public string Moderator { get; set; }
public int Weight { get; set; }
}
}

View File

@@ -59,6 +59,7 @@ namespace NadekoBot.Services.Database
public DbSet<Poll> Poll { get; set; }
public DbSet<WaifuInfo> WaifuInfo { get; set; }
public DbSet<ImageOnlyChannel> ImageOnlyChannels { get; set; }
public DbSet<NsfwBlacklistedTag> NsfwBlacklistedTags { get; set; }
public NadekoContext(DbContextOptions<NadekoContext> options) : base(options)
{
@@ -195,10 +196,16 @@ namespace NadekoBot.Services.Database
#endregion
#region Warnings
var warn = modelBuilder.Entity<Warning>();
warn.HasIndex(x => x.GuildId);
warn.HasIndex(x => x.UserId);
warn.HasIndex(x => x.DateAdded);
modelBuilder.Entity<Warning>(warn =>
{
warn.HasIndex(x => x.GuildId);
warn.HasIndex(x => x.UserId);
warn.HasIndex(x => x.DateAdded);
warn.Property(x => x.Weight)
.HasDefaultValue(1);
});
#endregion
#region PatreonRewards
@@ -357,6 +364,10 @@ namespace NadekoBot.Services.Database
modelBuilder.Entity<ImageOnlyChannel>(ioc => ioc
.HasIndex(x => x.ChannelId)
.IsUnique());
modelBuilder.Entity<NsfwBlacklistedTag>(nbt => nbt
.HasIndex(x => x.GuildId)
.IsUnique(false));
}
}
}

View File

@@ -17,6 +17,12 @@ namespace NadekoBot.Migrations
migrationBuilder.Sql("DELETE FROM FilterChannelId WHERE GuildConfigId NOT IN (SELECT Id from GuildConfigs)");
migrationBuilder.Sql("DELETE FROM CommandCooldown WHERE GuildConfigId NOT IN (SELECT Id from GuildConfigs)");
// fix for users who edited their waifuinfo table manually and are unable to update
migrationBuilder.Sql("DELETE FROM WaifuInfo where WaifuId not in (SELECT Id from DiscordUser);");
// fix for users who deleted clubs manually and are unable to update now
migrationBuilder.Sql("UPDATE DiscordUser SET ClubId = null WHERE ClubId is not null and ClubId not in (SELECT Id from Clubs);");
migrationBuilder.DropColumn(
name: "ChannelCreated",
table: "LogSettings");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace NadekoBot.Migrations
{
public partial class nsfwblacklisttags : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "NsfwBlacklistedTags",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
Tag = table.Column<string>(type: "TEXT", nullable: true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NsfwBlacklistedTags", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_NsfwBlacklistedTags_GuildId",
table: "NsfwBlacklistedTags",
column: "GuildId");
migrationBuilder.Sql(@"INSERT INTO NsfwBlacklistedTags(Id, GuildId, Tag, DateAdded)
SELECT
Id,
(SELECT GuildId From GuildConfigs WHERE Id=GuildConfigId),
Tag,
DateAdded
FROM NsfwBlacklitedTag
WHERE GuildConfigId in (SELECT Id from GuildConfigs);");
migrationBuilder.DropTable(
name: "NsfwBlacklitedTag");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NsfwBlacklistedTags");
migrationBuilder.CreateTable(
name: "NsfwBlacklitedTag",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: true),
GuildConfigId = table.Column<int>(type: "INTEGER", nullable: true),
Tag = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NsfwBlacklitedTag", x => x.Id);
table.ForeignKey(
name: "FK_NsfwBlacklitedTag_GuildConfigs_GuildConfigId",
column: x => x.GuildConfigId,
principalTable: "GuildConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NsfwBlacklitedTag_GuildConfigId",
table: "NsfwBlacklitedTag",
column: "GuildConfigId");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace NadekoBot.Migrations
{
public partial class weightedwarnings : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "Warnings",
type: "INTEGER",
nullable: false,
defaultValue: 1);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Weight",
table: "Warnings");
}
}
}

View File

@@ -1062,7 +1062,7 @@ namespace NadekoBot.Migrations
b.ToTable("MutedUserId");
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.NsfwBlacklitedTag", b =>
modelBuilder.Entity("NadekoBot.Services.Database.Models.NsfwBlacklistedTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@@ -1071,7 +1071,7 @@ namespace NadekoBot.Migrations
b.Property<DateTime?>("DateAdded")
.HasColumnType("TEXT");
b.Property<int?>("GuildConfigId")
b.Property<ulong>("GuildId")
.HasColumnType("INTEGER");
b.Property<string>("Tag")
@@ -1079,9 +1079,9 @@ namespace NadekoBot.Migrations
b.HasKey("Id");
b.HasIndex("GuildConfigId");
b.HasIndex("GuildId");
b.ToTable("NsfwBlacklitedTag");
b.ToTable("NsfwBlacklistedTags");
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.Permissionv2", b =>
@@ -1967,6 +1967,11 @@ namespace NadekoBot.Migrations
b.Property<ulong>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("Weight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.HasKey("Id");
b.HasIndex("DateAdded");
@@ -2300,13 +2305,6 @@ namespace NadekoBot.Migrations
.HasForeignKey("GuildConfigId");
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.NsfwBlacklitedTag", b =>
{
b.HasOne("NadekoBot.Services.Database.Models.GuildConfig", null)
.WithMany("NsfwBlacklistedTags")
.HasForeignKey("GuildConfigId");
});
modelBuilder.Entity("NadekoBot.Services.Database.Models.Permissionv2", b =>
{
b.HasOne("NadekoBot.Services.Database.Models.GuildConfig", null)
@@ -2573,8 +2571,6 @@ namespace NadekoBot.Migrations
b.Navigation("MutedUsers");
b.Navigation("NsfwBlacklistedTags");
b.Navigation("Permissions");
b.Navigation("ReactionRoleMessages");

View File

@@ -12,6 +12,8 @@ using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Db;
using Serilog;
using System.Threading;
using System;
namespace NadekoBot.Modules.Administration.Services
{
@@ -21,6 +23,11 @@ namespace NadekoBot.Modules.Administration.Services
private readonly DiscordSocketClient _client;
private readonly ConcurrentDictionary<ulong, IndexedCollection<ReactionRoleMessage>> _models;
/// <summary>
/// Contains the (Message ID, User ID) of reaction roles that are currently being processed.
/// </summary>
private readonly ConcurrentHashSet<(ulong, ulong)> _reacting = new();
public RoleCommandsService(DiscordSocketClient client, DbService db,
Bot bot)
{
@@ -38,75 +45,58 @@ namespace NadekoBot.Modules.Administration.Services
private Task _client_ReactionAdded(Cacheable<IUserMessage, ulong> msg, ISocketMessageChannel chan, SocketReaction reaction)
{
var _ = Task.Run(async () =>
_ = Task.Run(async () =>
{
try
if (!reaction.User.IsSpecified ||
reaction.User.Value.IsBot ||
reaction.User.Value is not SocketGuildUser gusr ||
chan is not SocketGuildChannel gch ||
!_models.TryGetValue(gch.Guild.Id, out var confs))
return;
var conf = confs.FirstOrDefault(x => x.MessageId == msg.Id);
if (conf is null)
return;
// compare emote names for backwards compatibility :facepalm:
var reactionRole = conf.ReactionRoles.FirstOrDefault(x => x.EmoteName == reaction.Emote.Name || x.EmoteName == reaction.Emote.ToString());
if (reactionRole != null)
{
if (!reaction.User.IsSpecified ||
reaction.User.Value.IsBot ||
!(reaction.User.Value is SocketGuildUser gusr))
return;
if (!(chan is SocketGuildChannel gch))
return;
if (!_models.TryGetValue(gch.Guild.Id, out var confs))
return;
var conf = confs.FirstOrDefault(x => x.MessageId == msg.Id);
if (conf is null)
return;
// compare emote names for backwards compatibility :facepalm:
var reactionRole = conf.ReactionRoles.FirstOrDefault(x => x.EmoteName == reaction.Emote.Name || x.EmoteName == reaction.Emote.ToString());
if (reactionRole != null)
if (!conf.Exclusive)
{
if (conf.Exclusive)
{
var roleIds = conf.ReactionRoles.Select(x => x.RoleId)
.Where(x => x != reactionRole.RoleId)
.Select(x => gusr.Guild.GetRole(x))
.Where(x => x != null);
var __ = Task.Run(async () =>
{
try
{
//if the role is exclusive,
// remove all other reactions user added to the message
var dl = await msg.GetOrDownloadAsync().ConfigureAwait(false);
foreach (var r in dl.Reactions)
{
if (r.Key.Name == reaction.Emote.Name)
continue;
try { await dl.RemoveReactionAsync(r.Key, gusr).ConfigureAwait(false); } catch { }
await Task.Delay(100).ConfigureAwait(false);
}
}
catch { }
});
await gusr.RemoveRolesAsync(roleIds).ConfigureAwait(false);
}
var toAdd = gusr.Guild.GetRole(reactionRole.RoleId);
if (toAdd != null && !gusr.Roles.Contains(toAdd))
{
await gusr.AddRolesAsync(new[] { toAdd }).ConfigureAwait(false);
}
await AddReactionRoleAsync(gusr, reactionRole);
return;
}
else
// If same (message, user) are being processed in an exclusive rero, quit
if (!_reacting.Add((msg.Id, reaction.UserId)))
return;
try
{
var dl = await msg.GetOrDownloadAsync().ConfigureAwait(false);
await dl.RemoveReactionAsync(reaction.Emote, dl.Author,
new RequestOptions()
{
RetryMode = RetryMode.RetryRatelimit | RetryMode.Retry502
}).ConfigureAwait(false);
Log.Warning("User {0} is adding unrelated reactions to the reaction roles message.", dl.Author);
var removeExclusiveTask = RemoveExclusiveReactionRoleAsync(msg, gusr, reaction, conf, reactionRole, CancellationToken.None);
var addRoleTask = AddReactionRoleAsync(gusr, reactionRole);
await Task.WhenAll(removeExclusiveTask, addRoleTask).ConfigureAwait(false);
}
finally
{
// Free (message/user) for another exclusive rero
_reacting.TryRemove((msg.Id, reaction.UserId));
}
}
catch { }
else
{
var dl = await msg.GetOrDownloadAsync().ConfigureAwait(false);
await dl.RemoveReactionAsync(reaction.Emote, dl.Author,
new RequestOptions()
{
RetryMode = RetryMode.RetryRatelimit | RetryMode.Retry502
}).ConfigureAwait(false);
Log.Warning("User {0} is adding unrelated reactions to the reaction roles message.", dl.Author);
}
});
return Task.CompletedTask;
@@ -114,16 +104,16 @@ namespace NadekoBot.Modules.Administration.Services
private Task _client_ReactionRemoved(Cacheable<IUserMessage, ulong> msg, ISocketMessageChannel chan, SocketReaction reaction)
{
var _ = Task.Run(async () =>
_ = Task.Run(async () =>
{
try
{
if (!reaction.User.IsSpecified ||
reaction.User.Value.IsBot ||
!(reaction.User.Value is SocketGuildUser gusr))
reaction.User.Value is not SocketGuildUser gusr)
return;
if (!(chan is SocketGuildChannel gch))
if (chan is not SocketGuildChannel gch)
return;
if (!_models.TryGetValue(gch.Guild.Id, out var confs))
@@ -193,5 +183,71 @@ namespace NadekoBot.Modules.Administration.Services
uow.SaveChanges();
}
}
/// <summary>
/// Adds a reaction role to the specified user.
/// </summary>
/// <param name="user">A Discord guild user.</param>
/// <param name="dbRero">The database settings of this reaction role.</param>
private Task AddReactionRoleAsync(SocketGuildUser user, ReactionRole dbRero)
{
var toAdd = user.Guild.GetRole(dbRero.RoleId);
return (toAdd != null && !user.Roles.Contains(toAdd))
? user.AddRoleAsync(toAdd)
: Task.CompletedTask;
}
/// <summary>
/// Removes the exclusive reaction roles and reactions from the specified user.
/// </summary>
/// <param name="reactionMessage">The Discord message that contains the reaction roles.</param>
/// <param name="user">A Discord guild user.</param>
/// <param name="reaction">The Discord reaction of the user.</param>
/// <param name="dbReroMsg">The database entry of the reaction role message.</param>
/// <param name="dbRero">The database settings of this reaction role.</param>
/// <param name="cToken">A cancellation token to cancel the operation.</param>
/// <exception cref="OperationCanceledException">Occurs when the operation is cancelled before it began.</exception>
/// <exception cref="TaskCanceledException">Occurs when the operation is cancelled while it's still executing.</exception>
private Task RemoveExclusiveReactionRoleAsync(Cacheable<IUserMessage, ulong> reactionMessage, SocketGuildUser user, SocketReaction reaction, ReactionRoleMessage dbReroMsg, ReactionRole dbRero, CancellationToken cToken = default)
{
cToken.ThrowIfCancellationRequested();
var roleIds = dbReroMsg.ReactionRoles.Select(x => x.RoleId)
.Where(x => x != dbRero.RoleId)
.Select(x => user.Guild.GetRole(x))
.Where(x => x != null);
var removeReactionsTask = RemoveOldReactionsAsync(reactionMessage, user, reaction, cToken);
var removeRolesTask = user.RemoveRolesAsync(roleIds);
return Task.WhenAll(removeReactionsTask, removeRolesTask);
}
/// <summary>
/// Removes old reactions from an exclusive reaction role.
/// </summary>
/// <param name="reactionMessage">The Discord message that contains the reaction roles.</param>
/// <param name="user">A Discord guild user.</param>
/// <param name="reaction">The Discord reaction of the user.</param>
/// <param name="cToken">A cancellation token to cancel the operation.</param>
/// <exception cref="OperationCanceledException">Occurs when the operation is cancelled before it began.</exception>
/// <exception cref="TaskCanceledException">Occurs when the operation is cancelled while it's still executing.</exception>
private async Task RemoveOldReactionsAsync(Cacheable<IUserMessage, ulong> reactionMessage, SocketGuildUser user, SocketReaction reaction, CancellationToken cToken = default)
{
cToken.ThrowIfCancellationRequested();
//if the role is exclusive,
// remove all other reactions user added to the message
var dl = await reactionMessage.GetOrDownloadAsync().ConfigureAwait(false);
foreach (var r in dl.Reactions)
{
if (r.Key.Name == reaction.Emote.Name)
continue;
try { await dl.RemoveReactionAsync(r.Key, user).ConfigureAwait(false); } catch { }
await Task.Delay(100, cToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -41,8 +41,11 @@ namespace NadekoBot.Modules.Administration.Services
}, null, TimeSpan.FromSeconds(0), TimeSpan.FromHours(12));
}
public async Task<WarningPunishment> Warn(IGuild guild, ulong userId, IUser mod, string reason)
public async Task<WarningPunishment> Warn(IGuild guild, ulong userId, IUser mod, int weight, string reason)
{
if (weight <= 0)
throw new ArgumentOutOfRangeException(nameof(weight));
var modName = mod.ToString();
if (string.IsNullOrWhiteSpace(reason))
@@ -57,6 +60,7 @@ namespace NadekoBot.Modules.Administration.Services
Forgiven = false,
Reason = reason,
Moderator = modName,
Weight = weight,
};
int warnings = 1;
@@ -70,7 +74,7 @@ namespace NadekoBot.Modules.Administration.Services
.Warnings
.ForId(guildId, userId)
.Where(w => !w.Forgiven && w.UserId == userId)
.Count();
.Sum(x => x.Weight);
uow.Warnings.Add(warn);

View File

@@ -54,8 +54,17 @@ namespace NadekoBot.Modules.Administration
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task Warn(IGuildUser user, [Leftover] string reason = null)
public Task Warn(IGuildUser user, [Leftover] string reason = null)
=> Warn(1, user, reason);
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task Warn(int weight, IGuildUser user, [Leftover] string reason = null)
{
if (weight <= 0)
return;
if (!await CheckRoleHierarchy(user))
return;
@@ -76,7 +85,7 @@ namespace NadekoBot.Modules.Administration
WarningPunishment punishment;
try
{
punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, reason).ConfigureAwait(false);
punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -230,19 +239,29 @@ namespace NadekoBot.Modules.Administration
}
else
{
var descText = GetText(strs.warn_count(
Format.Bold(warnings.Where(x => !x.Forgiven).Sum(x => x.Weight).ToString()),
Format.Bold(warnings.Sum(x => x.Weight).ToString())));
embed.WithDescription(descText);
var i = page * 9;
foreach (var w in warnings)
{
i++;
var name = GetText(strs.warned_on_by(
w.DateAdded.Value.ToString("dd.MM.yyy"),
w.DateAdded.Value.ToString("HH:mm"),
w.DateAdded?.ToString("dd.MM.yyy"),
w.DateAdded?.ToString("HH:mm"),
w.Moderator));
if (w.Forgiven)
name = $"{Format.Strikethrough(name)} {GetText(strs.warn_cleared_by(w.ForgivenBy))}";
embed.AddField($"#`{i}` " + name, w.Reason.TrimTo(1020));
embed.AddField($"#`{i}` " + name,
Format.Code(GetText(strs.warn_weight(w.Weight))) +
'\n' +
w.Reason.TrimTo(1000));
}
}
@@ -450,7 +469,7 @@ namespace NadekoBot.Modules.Administration
}
}
await _mute.TimedBan(ctx.Guild, user, time.Time, ctx.User.ToString() + " | " + msg).ConfigureAwait(false);
await _mute.TimedBan(ctx.Guild, user, time.Time, (ctx.User.ToString() + " | " + msg).TrimTo(512)).ConfigureAwait(false);
var toSend = _eb.Create().WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user.ToString(), true)
@@ -476,7 +495,7 @@ namespace NadekoBot.Modules.Administration
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (user is null)
{
await ctx.Guild.AddBanAsync(userId, 7, ctx.User.ToString() + " | " + msg);
await ctx.Guild.AddBanAsync(userId, 7, (ctx.User.ToString() + " | " + msg).TrimTo(512));
await ctx.Channel.EmbedAsync(_eb.Create().WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
@@ -516,7 +535,7 @@ namespace NadekoBot.Modules.Administration
dmFailed = true;
}
await ctx.Guild.AddBanAsync(user, 7, ctx.User.ToString() + " | " + msg).ConfigureAwait(false);
await ctx.Guild.AddBanAsync(user, 7, (ctx.User.ToString() + " | " + msg).TrimTo(512)).ConfigureAwait(false);
var toSend = _eb.Create().WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
@@ -692,7 +711,7 @@ namespace NadekoBot.Modules.Administration
dmFailed = true;
}
await ctx.Guild.AddBanAsync(user, 7, "Softban | " + ctx.User.ToString() + " | " + msg).ConfigureAwait(false);
await ctx.Guild.AddBanAsync(user, 7, ("Softban | " + ctx.User.ToString() + " | " + msg).TrimTo(512)).ConfigureAwait(false);
try { await ctx.Guild.RemoveBanAsync(user).ConfigureAwait(false); }
catch { await ctx.Guild.RemoveBanAsync(user).ConfigureAwait(false); }
@@ -749,7 +768,7 @@ namespace NadekoBot.Modules.Administration
dmFailed = true;
}
await user.KickAsync(ctx.User.ToString() + " | " + msg).ConfigureAwait(false);
await user.KickAsync((ctx.User.ToString() + " | " + msg).TrimTo(512)).ConfigureAwait(false);
var toSend = _eb.Create().WithOkColor()
.WithTitle(GetText(strs.kicked_user))
@@ -776,23 +795,32 @@ namespace NadekoBot.Modules.Administration
return;
var missing = new List<string>();
var banning = new HashSet<IGuildUser>();
var banning = new HashSet<IUser>();
await ctx.Channel.TriggerTypingAsync();
foreach (var userStr in userStrings)
{
if (ulong.TryParse(userStr, out var userId))
{
var user = await ctx.Guild.GetUserAsync(userId) ??
IUser user = await ctx.Guild.GetUserAsync(userId) ??
await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (user is null)
{
missing.Add(userStr);
continue;
// if IGuildUser is null, try to get IUser
user = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(userId);
// only add to missing if *still* null
if (user is null)
{
missing.Add(userStr);
continue;
}
}
if (!await CheckRoleHierarchy(user))
//Hierachy checks only if the user is in the guild
if (user is IGuildUser gu && !await CheckRoleHierarchy(gu))
{
return;
}
@@ -820,7 +848,7 @@ namespace NadekoBot.Modules.Administration
{
try
{
await toBan.BanAsync(7);
await ctx.Guild.AddBanAsync(toBan.Id, 7, $"{ctx.User} | Massban");
}
catch (Exception ex)
{

View File

@@ -149,7 +149,7 @@ namespace NadekoBot.Modules.CustomReactions
await ctx.Channel.EmbedAsync(_eb.Create().WithOkColor()
.WithDescription($"#{id}")
.AddField(GetText(strs.trigger), found.Trigger.TrimTo(1024))
.AddField(GetText(strs.response), (found.Response + "\n```css\n" + found.Response).TrimTo(1020) + "```")
.AddField(GetText(strs.response), found.Response.TrimTo(1000).Replace("](", "]\\("))
).ConfigureAwait(false);
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using Cloneable;
using NadekoBot.Common;
using NadekoBot.Common.Yml;
using YamlDotNet.Serialization;
namespace NadekoBot.Modules.Gambling.Common
{
@@ -22,7 +23,7 @@ namespace NadekoBot.Modules.Gambling.Common
}
[Comment(@"DO NOT CHANGE")]
public int Version { get; set; } = 1;
public int Version { get; set; } = 2;
[Comment(@"Currency settings")]
public CurrencyConfig Currency { get; set; }
@@ -59,6 +60,10 @@ Set 0 for unlimited")]
[Comment(@"Amount of currency selfhosters will get PER pledged dollar CENT.
1 = 100 currency per $. Used almost exclusively on public nadeko.")]
public decimal PatreonCurrencyPerCent { get; set; } = 1;
[Comment(@"Currency reward per vote.
This will work only if you've set up VotesApi and correct credentials for topgg and/or discords voting")]
public long VoteReward { get; set; } = 100;
}
public class CurrencyConfig
@@ -179,7 +184,8 @@ default is 0.02, which is 2%")]
public MultipliersData Multipliers { get; set; } = new MultipliersData();
[Comment(@"List of items available for gifting.")]
[Comment(@"List of items available for gifting.
If negative is true, gift will instead reduce waifu value.")]
public List<WaifuItemModel> Items { get; set; } = new List<WaifuItemModel>();
public WaifuConfig()
@@ -260,6 +266,11 @@ Default 1 (meaning no effect)")]
Default 0.95 (meaning 95%)
Example: If a waifu is worth 1000, and she receives a gift worth 100, her new value will be 1095)")]
public decimal GiftEffect { get; set; } = 0.95M;
[Comment(@"What percentage of the value of the gift will a waifu lose when she's gifted a gift marked as 'negative'.
Default 0.5 (meaning 50%)
Example: If a waifu is worth 1000, and she receives a negative gift worth 100, her new value will be 950)")]
public decimal NegativeGiftEffect { get; set; } = 0.50M;
}
[Cloneable]
@@ -268,19 +279,24 @@ Example: If a waifu is worth 1000, and she receives a gift worth 100, her new va
public string ItemEmoji { get; set; }
public int Price { get; set; }
public string Name { get; set; }
[YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
public bool Negative { get; set; }
public WaifuItemModel()
{
}
public WaifuItemModel(string itemEmoji, int price, string name)
public WaifuItemModel(string itemEmoji, int price, string name, bool negative = false)
{
ItemEmoji = itemEmoji;
Price = price;
Name = name;
Negative = negative;
}
public override string ToString() => Name;
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Gambling
{
public enum GamblingError
{
None,
NotEnough
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Linq;
using NadekoBot.Common;
namespace NadekoBot.Modules.Gambling.Common.Slot
{
public class SlotGame
{
public class Result
{
public float Multiplier { get; }
public int[] Rolls { get; }
public Result(float multiplier, int[] rolls)
{
Multiplier = multiplier;
Rolls = rolls;
}
}
private static readonly Random _rng = new NadekoRandom();
public SlotGame()
{
}
public Result Spin()
{
var rolls = new int[] { _rng.Next(0, 6), _rng.Next(0, 6), _rng.Next(0, 6) };
var multi = 0;
if (rolls.All(x => x == 5))
multi = 30;
else if (rolls.All(x => x == rolls[0]))
multi = 10;
else if (rolls.Count(x => x == 5) == 2)
multi = 4;
else if (rolls.Any(x => x == 5))
multi = 1;
return new Result(multi, rolls);
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace NadekoBot.Modules.Gambling
{
public class SlotResponse
{
public float Multiplier { get; set; }
public long Won { get; set; }
public List<int> Rolls { get; set; } = new List<int>();
public GamblingError Error { get; set; }
}
}

View File

@@ -22,15 +22,12 @@ namespace NadekoBot.Modules.Gambling
{
private readonly IImageCache _images;
private readonly ICurrencyService _cs;
private readonly DbService _db;
private static readonly NadekoRandom rng = new NadekoRandom();
public FlipCoinCommands(IDataCache data, ICurrencyService cs, DbService db,
GamblingConfigService gss) : base(gss)
public FlipCoinCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gss) : base(gss)
{
_images = data.LocalImages;
_cs = cs;
_db = db;
}
[NadekoCommand, Aliases]

View File

@@ -66,11 +66,11 @@ namespace NadekoBot.Modules.Gambling
}
var embed = _eb.Create()
.WithTitle(GetText(strs.economy_state))
.AddField(GetText(strs.currency_owned), ((BigInteger)(ec.Cash - ec.Bot)) + CurrencySign)
.AddField(GetText(strs.currency_owned), ((BigInteger)(ec.Cash - ec.Bot)).ToString("N", _enUsCulture) + CurrencySign)
.AddField(GetText(strs.currency_one_percent), (onePercent * 100).ToString("F2") + "%")
.AddField(GetText(strs.currency_planted), ((BigInteger)ec.Planted) + CurrencySign)
.AddField(GetText(strs.owned_waifus_total), ((BigInteger)ec.Waifus) + CurrencySign)
.AddField(GetText(strs.bot_currency), ec.Bot + CurrencySign)
.AddField(GetText(strs.bot_currency), ec.Bot.ToString("N", _enUsCulture) + CurrencySign)
.AddField(GetText(strs.total), ((BigInteger)(ec.Cash + ec.Planted + ec.Waifus)).ToString("N", _enUsCulture) + CurrencySign)
.WithOkColor();
// ec.Cash already contains ec.Bot as it's the total of all values in the CurrencyAmount column of the DiscordUser table
@@ -247,20 +247,20 @@ namespace NadekoBot.Modules.Gambling
[RequireContext(ContextType.Guild)]
[OwnerOnly]
[Priority(0)]
public Task Award(ShmartNumber amount, IGuildUser usr, [Leftover] string msg) =>
public Task Award(long amount, IGuildUser usr, [Leftover] string msg) =>
Award(amount, usr.Id, msg);
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
[Priority(1)]
public Task Award(ShmartNumber amount, [Leftover] IGuildUser usr) =>
public Task Award(long amount, [Leftover] IGuildUser usr) =>
Award(amount, usr.Id);
[NadekoCommand, Aliases]
[OwnerOnly]
[Priority(2)]
public async Task Award(ShmartNumber amount, ulong usrId, [Leftover] string msg = null)
public async Task Award(long amount, ulong usrId, [Leftover] string msg = null)
{
if (amount <= 0)
return;
@@ -276,7 +276,7 @@ namespace NadekoBot.Modules.Gambling
[RequireContext(ContextType.Guild)]
[OwnerOnly]
[Priority(2)]
public async Task Award(ShmartNumber amount, [Leftover] IRole role)
public async Task Award(long amount, [Leftover] IRole role)
{
var users = (await ctx.Guild.GetUsersAsync().ConfigureAwait(false))
.Where(u => u.GetRoles().Contains(role))
@@ -284,7 +284,7 @@ namespace NadekoBot.Modules.Gambling
await _cs.AddBulkAsync(users.Select(x => x.Id),
users.Select(x => $"Awarded by bot owner to **{role.Name}** role. ({ctx.User.Username}/{ctx.User.Id})"),
users.Select(x => amount.Value),
users.Select(x => amount),
gamble: true)
.ConfigureAwait(false);
@@ -298,13 +298,13 @@ namespace NadekoBot.Modules.Gambling
[RequireContext(ContextType.Guild)]
[OwnerOnly]
[Priority(0)]
public async Task Take(ShmartNumber amount, [Leftover] IRole role)
public async Task Take(long amount, [Leftover] IRole role)
{
var users = (await role.GetMembersAsync()).ToList();
await _cs.RemoveBulkAsync(users.Select(x => x.Id),
users.Select(x => $"Taken by bot owner from **{role.Name}** role. ({ctx.User.Username}/{ctx.User.Id})"),
users.Select(x => amount.Value),
users.Select(x => amount),
gamble: true)
.ConfigureAwait(false);
@@ -318,7 +318,7 @@ namespace NadekoBot.Modules.Gambling
[RequireContext(ContextType.Guild)]
[OwnerOnly]
[Priority(1)]
public async Task Take(ShmartNumber amount, [Leftover] IGuildUser user)
public async Task Take(long amount, [Leftover] IGuildUser user)
{
if (amount <= 0)
return;
@@ -333,7 +333,7 @@ namespace NadekoBot.Modules.Gambling
[NadekoCommand, Aliases]
[OwnerOnly]
public async Task Take(ShmartNumber amount, [Leftover] ulong usrId)
public async Task Take(long amount, [Leftover] ulong usrId)
{
if (amount <= 0)
return;

View File

@@ -8,10 +8,11 @@ using NadekoBot.Modules.Gambling.Services;
using System.Linq;
using System.Threading.Tasks;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Common;
namespace NadekoBot.Modules.Games
namespace NadekoBot.Modules.Gambling
{
public partial class Games
public partial class Gambling
{
[Group]
public class PlantPickCommands : GamblingSubmodule<PlantPickService>
@@ -53,7 +54,7 @@ namespace NadekoBot.Modules.Games
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Plant(int amount = 1, string pass = null)
public async Task Plant(ShmartNumber amount, string pass = null)
{
if (amount < 1)
return;
@@ -63,18 +64,17 @@ namespace NadekoBot.Modules.Games
return;
}
var success = await _service.PlantAsync(ctx.Guild.Id, ctx.Channel, ctx.User.Id, ctx.User.ToString(), amount, pass);
if (!success)
{
await ReplyErrorLocalizedAsync(strs.not_enough( CurrencySign));
return;
}
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
{
logService.AddDeleteIgnore(ctx.Message.Id);
await ctx.Message.DeleteAsync().ConfigureAwait(false);
}
var success = await _service.PlantAsync(ctx.Guild.Id, ctx.Channel, ctx.User.Id, ctx.User.ToString(), amount, pass);
if (!success)
{
await ReplyErrorLocalizedAsync(strs.not_enough( CurrencySign));
}
}
[NadekoCommand, Aliases]

View File

@@ -8,8 +8,6 @@ using System.Threading.Tasks;
using System;
using NadekoBot.Services.Database.Models;
using System.Net.Http;
using Newtonsoft.Json;
using System.Linq;
using NadekoBot.Modules.Gambling.Services;
using Serilog;
@@ -17,76 +15,22 @@ namespace NadekoBot.Modules.Gambling.Services
{
public class CurrencyEventsService : INService
{
public class VoteModel
{
public ulong User { get; set; }
public long Date { get; set; }
}
private readonly DiscordSocketClient _client;
private readonly ICurrencyService _cs;
private readonly IBotCredentials _creds;
private readonly IHttpClientFactory _http;
private readonly GamblingConfigService _configService;
private readonly ConcurrentDictionary<ulong, ICurrencyEvent> _events =
new ConcurrentDictionary<ulong, ICurrencyEvent>();
public CurrencyEventsService(DiscordSocketClient client,
IBotCredentials creds, ICurrencyService cs,
IHttpClientFactory http, GamblingConfigService configService)
public CurrencyEventsService(
DiscordSocketClient client,
ICurrencyService cs,
GamblingConfigService configService)
{
_client = client;
_cs = cs;
_creds = creds;
_http = http;
_configService = configService;
if (_client.ShardId == 0)
{
Task t = BotlistUpvoteLoop();
}
}
// todo future use votes api directly?
private async Task BotlistUpvoteLoop()
{
if (string.IsNullOrWhiteSpace(_creds.VotesUrl))
return;
while (true)
{
await Task.Delay(TimeSpan.FromHours(1)).ConfigureAwait(false);
await TriggerVoteCheck().ConfigureAwait(false);
}
}
private async Task TriggerVoteCheck()
{
try
{
using (var req = new HttpRequestMessage(HttpMethod.Get, _creds.VotesUrl))
{
if (!string.IsNullOrWhiteSpace(_creds.VotesToken))
req.Headers.Add("Authorization", _creds.VotesToken);
using (var http = _http.CreateClient())
using (var res = await http.SendAsync(req).ConfigureAwait(false))
{
if (!res.IsSuccessStatusCode)
{
Log.Warning("Botlist API not reached.");
return;
}
var resStr = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
var ids = JsonConvert.DeserializeObject<VoteModel[]>(resStr)
.Select(x => x.User)
.Distinct();
await _cs.AddBulkAsync(ids, ids.Select(x => "Voted - <https://discordbots.org/bot/nadeko/vote>"), ids.Select(x => 10L), true).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error in TriggerVoteCheck");
}
}
public async Task<bool> TryCreateEventAsync(ulong guildId, ulong channelId, CurrencyEvent.Type type,
@@ -127,6 +71,7 @@ namespace NadekoBot.Modules.Gambling.Services
return false;
}
}
return added;
}
@@ -136,4 +81,4 @@ namespace NadekoBot.Modules.Gambling.Services
return Task.CompletedTask;
}
}
}
}

View File

@@ -1,7 +1,11 @@
using NadekoBot.Common;
using System;
using System.Collections.Generic;
using System.Linq;
using NadekoBot.Common;
using NadekoBot.Common.Configs;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling.Services
{
@@ -34,9 +38,39 @@ namespace NadekoBot.Modules.Gambling.Services
AddParsedProp("waifu.multi.divorce_value", gs => gs.Waifu.Multipliers.DivorceNewValue, decimal.TryParse, ConfigPrinters.ToString, val => val > 0);
AddParsedProp("waifu.multi.all_gifts", gs => gs.Waifu.Multipliers.AllGiftPrices, decimal.TryParse, ConfigPrinters.ToString, val => val > 0);
AddParsedProp("waifu.multi.gift_effect", gs => gs.Waifu.Multipliers.GiftEffect, decimal.TryParse, ConfigPrinters.ToString, val => val >= 0);
AddParsedProp("waifu.multi.negative_gift_effect", gs => gs.Waifu.Multipliers.NegativeGiftEffect, decimal.TryParse, ConfigPrinters.ToString, val => val >= 0);
AddParsedProp("decay.percent", gs => gs.Decay.Percent, decimal.TryParse, ConfigPrinters.ToString, val => val >= 0 && val <= 1);
AddParsedProp("decay.maxdecay", gs => gs.Decay.MaxDecay, int.TryParse, ConfigPrinters.ToString, val => val >= 0);
AddParsedProp("decay.threshold", gs => gs.Decay.MinThreshold, int.TryParse, ConfigPrinters.ToString, val => val >= 0);
Migrate();
}
private readonly IEnumerable<WaifuItemModel> antiGiftSeed = new[]
{
new WaifuItemModel("🥀", 100, "WiltedRose", true),
new WaifuItemModel("✂️", 1000, "Haircut", true),
new WaifuItemModel("🧻", 10000, "ToiletPaper", true),
};
public void Migrate()
{
if (_data.Version < 2)
{
ModifyConfig(c =>
{
c.Waifu.Items = c.Waifu.Items.Concat(antiGiftSeed).ToList();
c.Version = 2;
});
}
if (_data.Version < 3)
{
ModifyConfig(c =>
{
c.VoteReward = 100;
});
}
}
}
}

View File

@@ -6,12 +6,14 @@ using NadekoBot.Modules.Gambling.Common.WheelOfFortune;
using Newtonsoft.Json;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common;
using NadekoBot.Db;
using NadekoBot.Modules.Gambling.Common.Slot;
using NadekoBot.Modules.Gambling.Services;
using Serilog;
@@ -82,6 +84,41 @@ WHERE CurrencyAmount > {config.Decay.MinThreshold} AND UserId!={_client.CurrentU
}, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
}
}
public async Task<SlotResponse> SlotAsync(ulong userId, long amount)
{
var takeRes = await _cs.RemoveAsync(userId, "Slot Machine", amount, true);
if (!takeRes)
{
return new SlotResponse
{
Error = GamblingError.NotEnough
};
}
var game = new SlotGame();
var result = game.Spin();
long won = 0;
if (result.Multiplier > 0)
{
won = (long)(result.Multiplier * amount);
await _cs.AddAsync(userId, $"Slot Machine x{result.Multiplier}", won, true);
}
var toReturn = new SlotResponse
{
Multiplier = result.Multiplier,
Won = won,
};
toReturn.Rolls.AddRange(result.Rolls);
return toReturn;
}
public struct EconomyResult
{

View File

@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Services;
using Discord.WebSocket;
using Serilog;
namespace NadekoBot.Modules.Gambling.Services
{
public class VoteModel
{
[JsonPropertyName("userId")]
public ulong UserId { get; set; }
}
public class VoteRewardService : INService, IReadyExecutor
{
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ICurrencyService _currencyService;
private readonly GamblingConfigService _gamb;
private HttpClient _http;
public VoteRewardService(
DiscordSocketClient client,
IBotCredentials creds,
IHttpClientFactory httpClientFactory,
ICurrencyService currencyService,
GamblingConfigService gamb)
{
_client = client;
_creds = creds;
_httpClientFactory = httpClientFactory;
_currencyService = currencyService;
_gamb = gamb;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
_http = new HttpClient(new HttpClientHandler()
{
AllowAutoRedirect = false,
ServerCertificateCustomValidationCallback = delegate { return true; }
});
while (true)
{
await Task.Delay(30000);
var topggKey = _creds.Votes?.TopggKey;
var topggServiceUrl = _creds.Votes?.TopggServiceUrl;
try
{
if (!string.IsNullOrWhiteSpace(topggKey)
&& !string.IsNullOrWhiteSpace(topggServiceUrl))
{
_http.DefaultRequestHeaders.Authorization = new(topggKey);
var uri = new Uri(new(topggServiceUrl), "topgg/new");
var res = await _http.GetStringAsync(uri);
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
await _currencyService.AddBulkAsync(ids,
data.Select(_ => "top.gg vote reward"),
data.Select(x => _gamb.Data.VoteReward),
true);
Log.Information("Rewarding {Count} top.gg voters", ids.Count());
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading top.gg vote rewards.");
}
var discordsKey = _creds.Votes?.DiscordsKey;
var discordsServiceUrl = _creds.Votes?.DiscordsServiceUrl;
try
{
if (!string.IsNullOrWhiteSpace(discordsKey)
&& !string.IsNullOrWhiteSpace(discordsServiceUrl))
{
_http.DefaultRequestHeaders.Authorization = new(discordsKey);
var res = await _http.GetStringAsync(new Uri(new(discordsServiceUrl), "discords/new"));
var data = JsonSerializer.Deserialize<List<VoteModel>>(res);
if (data is { Count: > 0 })
{
var ids = data.Select(x => x.UserId).ToList();
await _currencyService.AddBulkAsync(ids,
data.Select(_ => "discords.com vote reward"),
data.Select(x => _gamb.Data.VoteReward),
true);
Log.Information("Rewarding {Count} discords.com voters", ids.Count());
}
}
}
catch (Exception ex)
{
Log.Error(ex, "Critical error loading discords.com vote rewards.");
}
}
}
}
}

View File

@@ -394,19 +394,28 @@ namespace NadekoBot.Modules.Gambling.Services
});
}
w.Items.Add(new WaifuItem()
if (!itemObj.Negative)
{
Name = itemObj.Name.ToLowerInvariant(),
ItemEmoji = itemObj.ItemEmoji,
});
if (w.Claimer?.UserId == from.Id)
{
w.Price += (int) (itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect);
w.Items.Add(new WaifuItem()
{
Name = itemObj.Name.ToLowerInvariant(),
ItemEmoji = itemObj.ItemEmoji,
});
if (w.Claimer?.UserId == from.Id)
{
w.Price += (int)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect);
}
else
{
w.Price += itemObj.Price / 2;
}
}
else
{
w.Price += itemObj.Price / 2;
w.Price -= (int)(itemObj.Price * _gss.Data.Waifu.Multipliers.NegativeGiftEffect);
if (w.Price < 1)
w.Price = 1;
}
await uow.SaveChangesAsync();
@@ -512,7 +521,7 @@ namespace NadekoBot.Modules.Gambling.Services
{
var conf = _gss.Data;
return conf.Waifu.Items
.Select(x => new WaifuItemModel(x.ItemEmoji, (int)(x.Price * conf.Waifu.Multipliers.AllGiftPrices), x.Name))
.Select(x => new WaifuItemModel(x.ItemEmoji, (int)(x.Price * conf.Waifu.Multipliers.AllGiftPrices), x.Name, x.Negative))
.ToList();
}
}

View File

@@ -9,12 +9,16 @@ using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Common;
using NadekoBot.Common.Attributes;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Gambling.Services;
using NadekoBot.Modules.Gambling.Common;
using NadekoBot.Services;
using SixLabors.Fonts;
using Image = SixLabors.ImageSharp.Image;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.PixelFormats;
namespace NadekoBot.Modules.Gambling
{
@@ -33,12 +37,16 @@ namespace NadekoBot.Modules.Gambling
//thanks to judge for helping me with this
private readonly IImageCache _images;
private readonly ICurrencyService _cs;
private FontProvider _fonts;
private readonly DbService _db;
public SlotCommands(IDataCache data, ICurrencyService cs, GamblingConfigService gamb) : base(gamb)
public SlotCommands(IDataCache data,
FontProvider fonts, DbService db,
GamblingConfigService gamb) : base(gamb)
{
_images = data.LocalImages;
_cs = cs;
_fonts = fonts;
_db = db;
}
public sealed class SlotMachine
@@ -140,92 +148,115 @@ namespace NadekoBot.Modules.Gambling
[NadekoCommand, Aliases]
public async Task Slot(ShmartNumber amount)
{
if (!_runningUsers.Add(ctx.User.Id))
if (!_runningUsers.Add(ctx.User.Id))
return;
try
{
if (!await CheckBetMandatory(amount).ConfigureAwait(false))
return;
const int maxAmount = 9999;
if (amount > maxAmount)
{
await ReplyErrorLocalizedAsync(strs.max_bet_limit(maxAmount + CurrencySign));
return;
}
try
{
if (!await CheckBetMandatory(amount).ConfigureAwait(false))
return;
await ctx.Channel.TriggerTypingAsync().ConfigureAwait(false);
if (!await _cs.RemoveAsync(ctx.User, "Slot Machine", amount, false, gamble: true).ConfigureAwait(false))
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
return;
}
Interlocked.Add(ref _totalBet, amount.Value);
using (var bgImage = Image.Load(_images.SlotBackground))
{
var result = SlotMachine.Pull();
int[] numbers = result.Numbers;
var result = await _service.SlotAsync(ctx.User.Id, amount);
for (int i = 0; i < 3; i++)
{
using (var randomImage = Image.Load(_images.SlotEmojis[numbers[i]]))
{
bgImage.Mutate(x => x.DrawImage(randomImage, new Point(95 + 142 * i, 330), new GraphicsOptions()));
}
}
if (result.Error != GamblingError.None)
{
if (result.Error == GamblingError.NotEnough)
{
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
}
var won = amount * result.Multiplier;
var printWon = won;
var n = 0;
do
{
var digit = (int)(printWon % 10);
using (var img = Image.Load(_images.SlotNumbers[digit]))
{
bgImage.Mutate(x => x.DrawImage(img, new Point(230 - n * 16, 462), new GraphicsOptions()));
}
n++;
} while ((printWon /= 10) != 0);
return;
}
var printAmount = amount;
n = 0;
do
{
var digit = (int)(printAmount % 10);
using (var img = Image.Load(_images.SlotNumbers[digit]))
{
bgImage.Mutate(x => x.DrawImage(img, new Point(395 - n * 16, 462), new GraphicsOptions()));
}
n++;
} while ((printAmount /= 10) != 0);
Interlocked.Add(ref _totalBet, amount);
Interlocked.Add(ref _totalPaidOut, result.Won);
var msg = GetText(strs.better_luck);
if (result.Multiplier != 0)
{
await _cs.AddAsync(ctx.User, $"Slot Machine x{result.Multiplier}", amount * result.Multiplier, false, gamble: true).ConfigureAwait(false);
Interlocked.Add(ref _totalPaidOut, amount * result.Multiplier);
if (result.Multiplier == 1)
msg = GetText(strs.slot_single(CurrencySign, 1));
else if (result.Multiplier == 4)
msg = GetText(strs.slot_two(CurrencySign, 4));
else if (result.Multiplier == 10)
msg = GetText(strs.slot_three(10));
else if (result.Multiplier == 30)
msg = GetText(strs.slot_jackpot(30));
}
long ownedAmount;
using (var uow = _db.GetDbContext())
{
ownedAmount = uow.Set<DiscordUser>()
.FirstOrDefault(x => x.UserId == ctx.User.Id)
?.CurrencyAmount ?? 0;
}
using (var imgStream = bgImage.ToStream())
{
await ctx.Channel.SendFileAsync(imgStream, "result.png", ctx.User.Mention + " " + msg + $"\n`{GetText(strs.slot_bet)}:`{amount} `{GetText(strs.won)}:` {amount * result.Multiplier}{CurrencySign}").ConfigureAwait(false);
}
}
}
finally
{
var _ = Task.Run(async () =>
{
await Task.Delay(1000).ConfigureAwait(false);
_runningUsers.Remove(ctx.User.Id);
});
}
using (var bgImage = Image.Load<Rgba32>(_images.SlotBackground, out var format))
{
var numbers = new int[3];
result.Rolls.CopyTo(numbers, 0);
bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions
{
TextOptions = new TextOptions()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
WrapTextWidth = 140,
}
}, result.Won.ToString(), _fonts.DottyFont.CreateFont(65), SixLabors.ImageSharp.Color.Red,
new PointF(227, 92)));
bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions
{
TextOptions = new TextOptions()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
WrapTextWidth = 135,
}
}, amount.ToString(), _fonts.DottyFont.CreateFont(50), SixLabors.ImageSharp.Color.Red,
new PointF(129, 472)));
bgImage.Mutate(x => x.DrawText(new TextGraphicsOptions
{
TextOptions = new TextOptions()
{
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
WrapTextWidth = 135,
}
}, ownedAmount.ToString(), _fonts.DottyFont.CreateFont(50), SixLabors.ImageSharp.Color.Red,
new PointF(325, 472)));
//sw.PrintLap("drew red text");
for (var i = 0; i < 3; i++)
{
using (var img = Image.Load(_images.SlotEmojis[numbers[i]]))
{
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + 105 * i, 217), 1f));
}
}
var msg = GetText(strs.better_luck);
if (result.Multiplier > 0)
{
if (result.Multiplier == 1f)
msg = GetText(strs.slot_single(CurrencySign, 1));
else if (result.Multiplier == 4f)
msg = GetText(strs.slot_two(CurrencySign, 4));
else if (result.Multiplier == 10f)
msg = GetText(strs.slot_three(10));
else if (result.Multiplier == 30f)
msg = GetText(strs.slot_jackpot(30));
}
using (var imgStream = bgImage.ToStream())
{
await ctx.Channel.SendFileAsync(imgStream,
filename: "result.png",
text: Format.Bold(ctx.User.ToString()) + " " + msg).ConfigureAwait(false);
}
}
}
finally
{
var _ = Task.Run(async () =>
{
await Task.Delay(1000).ConfigureAwait(false);
_runningUsers.Remove(ctx.User.Id);
});
}
}
}
}

View File

@@ -317,10 +317,14 @@ namespace NadekoBot.Modules.Gambling
.WithOkColor();
waifuItems
.OrderBy(x => x.Price)
.OrderBy(x => x.Negative)
.ThenBy(x => x.Price)
.Skip(9 * cur)
.Take(9)
.ForEach(x => embed.AddField($"{x.ItemEmoji} {x.Name}", x.Price, true));
.ForEach(x => embed
.AddField($"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}",
Format.Bold(x.Price.ToString()) + _config.Currency.Sign,
true));
return embed;
}, waifuItems.Count, 9);

View File

@@ -2,6 +2,7 @@
using Discord.Commands;
using System;
using System.Threading.Tasks;
using NadekoBot.Common;
using NadekoBot.Common.Attributes;
using NadekoBot.Services;
using NadekoBot.Db;
@@ -23,6 +24,7 @@ namespace NadekoBot.Modules.Games
_db = db;
}
[NoPublicBot]
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]

View File

@@ -73,7 +73,6 @@ namespace NadekoBot.Modules.Games.Hangman
return new(output);
}
// todo lock
public State Guess(string guess)
{
if (CurrentPhase != Phase.Running)

View File

@@ -0,0 +1,15 @@
namespace NadekoBot.Modules.Nsfw.Common
{
public enum Booru
{
Safebooru,
E621,
Derpibooru,
Rule34,
Gelbooru,
Konachan,
Yandere,
Danbooru,
Sankaku
}
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Nsfw.Common
{
public class DapiImageObject : IImageData
{
[JsonPropertyName("File_Url")]
public string FileUrl { get; set; }
public string Tags { get; set; }
[JsonPropertyName("Tag_String")]
public string TagString { get; set; }
public int Score { get; set; }
public string Rating { get; set; }
public ImageData ToCachedImageData(Booru type)
=> new ImageData(this.FileUrl, type, this.Tags?.Split(' ') ?? this.TagString?.Split(' '), Score.ToString() ?? Rating);
}
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Nsfw.Common
{
public readonly struct DapiTag
{
public string Name { get; }
[JsonConstructor]
public DapiTag(string name)
{
Name = name;
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Nsfw.Common
{
public class DerpiContainer
{
public DerpiImageObject[] Images { get; set; }
}
public class DerpiImageObject : IImageData
{
[JsonPropertyName("view_url")]
public string ViewUrl { get; set; }
public string[] Tags { get; set; }
public int Score { get; set; }
public ImageData ToCachedImageData(Booru type)
=> new(ViewUrl, type, Tags, Score.ToString("F1"));
}
}

View File

@@ -0,0 +1,41 @@
using System.Collections.Concurrent;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public sealed class DanbooruImageDownloader : DapiImageDownloader
{
// using them as concurrent hashsets, value doesn't matter
private static readonly ConcurrentDictionary<string, bool> _existentTags = new();
private static readonly ConcurrentDictionary<string, bool> _nonexistentTags = new();
public override async Task<bool> IsTagValid(string tag, CancellationToken cancel = default)
{
if (_existentTags.ContainsKey(tag))
return true;
if (_nonexistentTags.ContainsKey(tag))
return false;
var tags = await _http.GetFromJsonAsync<DapiTag[]>(_baseUrl +
"/tags.json" +
$"?search[name_or_alias_matches]={tag}",
options: this._serializerOptions,
cancellationToken: cancel);
if (tags is {Length: > 0})
{
return _existentTags[tag] = true;
}
return _nonexistentTags[tag] = false;
}
public DanbooruImageDownloader(HttpClient http)
: base(Booru.Danbooru, http, "http://danbooru.donmai.us")
{
}
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public abstract class DapiImageDownloader : ImageDownloader<DapiImageObject>
{
protected readonly string _baseUrl;
public DapiImageDownloader(Booru booru, HttpClient http, string baseUrl) : base(booru, http)
{
_baseUrl = baseUrl;
}
public abstract Task<bool> IsTagValid(string tag, CancellationToken cancel = default);
protected async Task<bool> AllTagsValid(string[] tags, CancellationToken cancel = default)
{
var results = await Task.WhenAll(tags.Select(tag => IsTagValid(tag, cancel)));
// if any of the tags is not valid, the query is not valid
foreach (var result in results)
{
if (!result)
return false;
}
return true;
}
public override async Task<List<DapiImageObject>> DownloadImagesAsync(string[] tags, int page,
bool isExplicit = false, CancellationToken cancel = default)
{
// up to 2 tags allowed on danbooru
if (tags.Length > 2)
return new();
if (!await AllTagsValid(tags, cancel).ConfigureAwait(false))
return new();
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit);
var uri = $"{_baseUrl}/posts.json?limit=200&tags={tagString}&page={page}";
var imageObjects = await _http.GetFromJsonAsync<DapiImageObject[]>(uri, _serializerOptions, cancel)
.ConfigureAwait(false);
if (imageObjects is null)
return new();
return imageObjects
.Where(x => x.FileUrl is not null)
.ToList();
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Extensions;
namespace NadekoBot.Modules.Nsfw.Common
{
public class DerpibooruImageDownloader : ImageDownloader<DerpiImageObject>
{
public DerpibooruImageDownloader(HttpClient http) : base(Booru.Derpibooru, http)
{
}
public override async Task<List<DerpiImageObject>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit);
var uri = $"https://www.derpibooru.org/api/v1/json/search/images?q={tagString.Replace('+', ',')}&per_page=49&page={page}";
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
req.Headers.AddFakeHeaders();
using var res = await _http.SendAsync(req, cancel).ConfigureAwait(false);
res.EnsureSuccessStatusCode();
var container = await res.Content.ReadFromJsonAsync<DerpiContainer>(_serializerOptions, cancel).ConfigureAwait(false);
if (container?.Images is null)
return new();
return container.Images
.Where(x => !string.IsNullOrWhiteSpace(x.ViewUrl))
.ToList();
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Extensions;
namespace NadekoBot.Modules.Nsfw.Common
{
public class E621ImageDownloader : ImageDownloader<E621Object>
{
public E621ImageDownloader(HttpClient http) : base(Booru.E621, http)
{
}
public override async Task<List<E621Object>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit: isExplicit);
var uri = $"https://e621.net/posts.json?limit=32&tags={tagString}&page={page}";
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
req.Headers.AddFakeHeaders();
using var res = await _http.SendAsync(req, cancel).ConfigureAwait(false);
res.EnsureSuccessStatusCode();
var data = await res.Content.ReadFromJsonAsync<E621Response>(_serializerOptions, cancel).ConfigureAwait(false);
if (data?.Posts is null)
return new();
return data.Posts
.Where(x => !string.IsNullOrWhiteSpace(x.File?.Url))
.ToList();
}
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace NadekoBot.Modules.Nsfw.Common
{
public class E621Response
{
public List<E621Object> Posts { get; set; }
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public class GelbooruImageDownloader : ImageDownloader<DapiImageObject>
{
public GelbooruImageDownloader(HttpClient http) : base(Booru.Gelbooru, http)
{
}
public override async Task<List<DapiImageObject>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit);
var uri = $"http://gelbooru.com/index.php?page=dapi&s=post&json=1&q=index&limit=100" +
$"&tags={tagString}&pid={page}";
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
using var res = await _http.SendAsync(req, cancel).ConfigureAwait(false);
res.EnsureSuccessStatusCode();
var resString = await res.Content.ReadAsStringAsync(cancel);
if (string.IsNullOrWhiteSpace(resString))
return new();
var images = JsonSerializer.Deserialize<List<DapiImageObject>>(resString, _serializerOptions);
if (images is null)
return new();
return images.Where(x => x.FileUrl is not null).ToList();
}
}
}

View File

@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public interface IImageDownloader
{
Task<List<ImageData>> DownloadImageDataAsync(string[] tags, int page = 0,
bool isExplicit = false, CancellationToken cancel = default);
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public abstract class ImageDownloader<T> : IImageDownloader
where T : IImageData
{
protected readonly HttpClient _http;
protected JsonSerializerOptions _serializerOptions = new JsonSerializerOptions()
{
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.WriteAsString | JsonNumberHandling.AllowReadingFromString,
};
public Booru Booru { get; }
public ImageDownloader(Booru booru, HttpClient http)
{
_http = http;
this.Booru = booru;
}
public abstract Task<List<T>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default);
public async Task<List<ImageData>> DownloadImageDataAsync(string[] tags, int page, bool isExplicit = false,
CancellationToken cancel = default)
{
var images = await DownloadImagesAsync(tags, page, isExplicit, cancel).ConfigureAwait(false);
return images.Select(x => x.ToCachedImageData(Booru)).ToList();
}
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace NadekoBot.Modules.Nsfw.Common
{
public static class ImageDownloaderHelper
{
public static string GetTagString(IEnumerable<string> tags, bool isExplicit = false)
{
if (isExplicit)
tags = tags.Append("rating:explicit");
return string.Join('+', tags.Select(x => x.ToLowerInvariant()));
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public sealed class KonachanImageDownloader : ImageDownloader<DapiImageObject>
{
private readonly string _baseUrl;
public KonachanImageDownloader(HttpClient http)
: base(Booru.Konachan, http)
{
_baseUrl = "https://konachan.com";
}
public override async Task<List<DapiImageObject>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit);
var uri = $"{_baseUrl}/post.json?s=post&q=index&limit=200&tags={tagString}&page={page}";
var imageObjects = await _http.GetFromJsonAsync<DapiImageObject[]>(uri, _serializerOptions, cancel)
.ConfigureAwait(false);
if (imageObjects is null)
return new();
return imageObjects
.Where(x => x.FileUrl is not null)
.ToList();
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public class Rule34ImageDownloader : ImageDownloader<Rule34Object>
{
public Rule34ImageDownloader(HttpClient http) : base(Booru.Rule34, http)
{
}
public override async Task<List<Rule34Object>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags);
var uri = $"https://rule34.xxx/index.php?page=dapi&s=post&q=index&json=1&limit=100" +
$"&tags={tagString}&pid={page}";
var images = await _http.GetFromJsonAsync<List<Rule34Object>>(uri, _serializerOptions, cancel).ConfigureAwait(false);
if (images is null)
return new();
return images
.Where(img => !string.IsNullOrWhiteSpace(img.Directory) && !string.IsNullOrWhiteSpace(img.Image))
.ToList();
}
}
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Nsfw.Common
{
public class SafebooruImageDownloader : ImageDownloader<SafebooruElement>
{
public SafebooruImageDownloader(HttpClient http) : base(Booru.Safebooru, http)
{
}
public override async Task<List<SafebooruElement>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit: false);
var uri = $"https://safebooru.org/index.php?page=dapi&s=post&q=index&limit=200&tags={tagString}&json=1&pid={page}";
var images = await _http.GetFromJsonAsync<List<SafebooruElement>>(uri, _serializerOptions, cancellationToken: cancel);
if (images is null)
return new();
return images;
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Extensions;
namespace NadekoBot.Modules.Nsfw.Common
{
public sealed class SankakuImageDownloader : ImageDownloader<SankakuImageObject>
{
private readonly string _baseUrl;
public SankakuImageDownloader(HttpClient http)
: base(Booru.Sankaku, http)
{
_baseUrl = "https://capi-v2.sankakucomplex.com";
_http.AddFakeHeaders();
}
public override async Task<List<SankakuImageObject>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
// explicit probably not supported
var tagString = ImageDownloaderHelper.GetTagString(tags, false);
var uri = $"{_baseUrl}/posts?tags={tagString}&limit=50";
var data = await _http.GetStringAsync(uri).ConfigureAwait(false);
return JsonSerializer.Deserialize<SankakuImageObject[]>(data, _serializerOptions)
.Where(x => !string.IsNullOrWhiteSpace(x.FileUrl) && x.FileType.StartsWith("image"))
.ToList();
}
}
}

View File

@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Extensions;
namespace NadekoBot.Modules.Nsfw.Common
{
public sealed class YandereImageDownloader : ImageDownloader<DapiImageObject>
{
private readonly string _baseUrl;
public YandereImageDownloader(HttpClient http)
: base(Booru.Yandere, http)
{
_baseUrl = "https://yande.re";
}
public override async Task<List<DapiImageObject>> DownloadImagesAsync(string[] tags, int page, bool isExplicit = false, CancellationToken cancel = default)
{
var tagString = ImageDownloaderHelper.GetTagString(tags, isExplicit);
var uri = $"{_baseUrl}/post.json?limit=200&tags={tagString}&page={page}";
var imageObjects = await _http.GetFromJsonAsync<DapiImageObject[]>(uri, _serializerOptions, cancel)
.ConfigureAwait(false);
if (imageObjects is null)
return new();
return imageObjects
.Where(x => x.FileUrl is not null)
.ToList();
}
}
}

View File

@@ -0,0 +1,27 @@
namespace NadekoBot.Modules.Nsfw.Common
{
public class E621Object : IImageData
{
public class FileData
{
public string Url { get; set; }
}
public class TagData
{
public string[] General { get; set; }
}
public class ScoreData
{
public int Total { get; set; }
}
public FileData File { get; set; }
public TagData Tags { get; set; }
public ScoreData Score { get; set; }
public ImageData ToCachedImageData(Booru type)
=> new(File.Url, Booru.E621, Tags.General, Score.Total.ToString());
}
}

View File

@@ -0,0 +1,7 @@
namespace NadekoBot.Modules.Nsfw.Common
{
public interface IImageData
{
ImageData ToCachedImageData(Booru type);
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace NadekoBot.Modules.Nsfw.Common
{
public class ImageData : IComparable<ImageData>
{
public Booru SearchType { get; }
public string FileUrl { get; }
public HashSet<string> Tags { get; }
public string Rating { get; }
public ImageData(string url, Booru type, string[] tags, string rating)
{
if (type == Booru.Danbooru && !Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
this.FileUrl = "https://danbooru.donmai.us" + url;
}
else
{
this.FileUrl = url.StartsWith("http", StringComparison.InvariantCulture) ? url : "https:" + url;
}
this.SearchType = type;
this.FileUrl = url;
this.Tags = tags.ToHashSet();
this.Rating = rating;
}
public override string ToString()
{
return FileUrl;
}
public override int GetHashCode() => FileUrl.GetHashCode();
public override bool Equals(object obj)
=> obj is ImageData ico && ico.FileUrl == this.FileUrl;
public int CompareTo(ImageData other)
=> string.Compare(FileUrl, other.FileUrl, StringComparison.InvariantCulture);
}
}

View File

@@ -0,0 +1,19 @@
namespace NadekoBot.Modules.Nsfw.Common
{
public class Rule34Object : IImageData
{
public string Image { get; init; }
public string Directory { get; init; }
public string Tags { get; init; }
public int Score { get; init; }
public ImageData ToCachedImageData(Booru type)
{
return new ImageData(
$"https://img.rule34.xxx//images/{Directory}/{Image}",
Booru.Rule34,
Tags.Split(' '),
Score.ToString());
}
}
}

View File

@@ -0,0 +1,15 @@
namespace NadekoBot.Modules.Nsfw.Common
{
public class SafebooruElement : IImageData
{
public string Directory { get; set; }
public string Image { get; set; }
public string FileUrl => $"https://safebooru.org/images/{Directory}/{Image}";
public string Rating { get; set; }
public string Tags { get; set; }
public ImageData ToCachedImageData(Booru type)
=> new ImageData(FileUrl, Booru.Safebooru, this.Tags.Split(' '), Rating);
}
}

View File

@@ -0,0 +1,27 @@
using System.Linq;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Nsfw.Common
{
public class SankakuImageObject : IImageData
{
public class Tag
{
public string Name { get; set; }
}
[JsonPropertyName("file_url")]
public string FileUrl { get; set; }
[JsonPropertyName("file_type")]
public string FileType { get; set; }
public Tag[] Tags { get; set; }
[JsonPropertyName("total_score")]
public int Score { get; set; }
public ImageData ToCachedImageData(Nsfw.Common.Booru type)
=> new(FileUrl, Nsfw.Common.Booru.Sankaku, Tags.Select(x => x.Name).ToArray(), Score.ToString());
}
}

View File

@@ -1,3 +0,0 @@
namespace NadekoBot.Modules.NSFW.Exceptions
{
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Modules.Nsfw.Common;
using NadekoBot.Modules.Searches.Common;
namespace NadekoBot.Modules.Nsfw
{
public interface ISearchImagesService
{
Task<UrlReply> Gelbooru(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Danbooru(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Konachan(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Yandere(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Rule34(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> E621(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> DerpiBooru(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Sankaku(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> SafeBooru(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Hentai(ulong? guildId, bool forceExplicit, string[] tags);
Task<UrlReply> Boobs();
ValueTask<bool> ToggleBlacklistTag(ulong guildId, string tag);
ValueTask<string[]> GetBlacklistedTags(ulong guildId);
Task<UrlReply> Butts();
Task<Gallery> GetNhentaiByIdAsync(uint id);
Task<Gallery> GetNhentaiBySearchAsync(string search);
ConcurrentDictionary<ulong, Timer> AutoHentaiTimers { get; }
ConcurrentDictionary<ulong, Timer> AutoBoobTimers { get; }
ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; }
}
}

View File

@@ -5,68 +5,28 @@ using NadekoBot.Common.Attributes;
using NadekoBot.Common.Collections;
using NadekoBot.Extensions;
using NadekoBot.Modules.Searches.Common;
using NadekoBot.Modules.Searches.Services;
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using CommandLine;
using Serilog;
namespace NadekoBot.Modules.NSFW
namespace NadekoBot.Modules.Nsfw
{
// thanks to halitalf for adding autoboob and autobutt features :D
public class NSFW : NadekoModule<SearchesService>
[NoPublicBot]
public class NSFW : NadekoModule<ISearchImagesService>
{
private static readonly ConcurrentHashSet<ulong> _hentaiBombBlacklist = new ConcurrentHashSet<ulong>();
private readonly IHttpClientFactory _httpFactory;
private readonly NadekoRandom _rng;
public NSFW(IHttpClientFactory factory)
{
_httpFactory = factory;
}
private async Task InternalHentai(IMessageChannel channel, string tag)
{
// create a random number generator
var rng = new NadekoRandom();
// get all of the DAPI search types, except first 3
// which are safebooru (not nsfw), and 2 furry ones 🤢
var listOfProviders = Enum.GetValues(typeof(DapiSearchType))
.Cast<DapiSearchType>()
.Skip(3)
.ToList();
// now try to get an image, if it fails return an error,
// keep trying for each provider until one of them is successful, or until
// we run out of providers. If we run out, then return an error
ImageCacherObject img;
do
{
// random index of the providers
var num = rng.Next(0, listOfProviders.Count);
// get the type
var type = listOfProviders[num];
// remove it
listOfProviders.RemoveAt(num);
// get the image
img = await _service.DapiSearch(tag, type, ctx.Guild?.Id, true).ConfigureAwait(false);
// if i can't find the image, ran out of providers, or tag is blacklisted
// return the error
if (img is null && !listOfProviders.Any())
{
await ReplyErrorLocalizedAsync(strs.no_results).ConfigureAwait(false);
return;
}
} while (img is null);
await channel.EmbedAsync(_eb.Create().WithOkColor()
.WithImageUrl(img.FileUrl)
.WithDescription($"[{GetText(strs.tag)}: {tag}]({img})"))
.ConfigureAwait(false);
_rng = new NadekoRandom();
}
private async Task InternalBoobs()
@@ -76,8 +36,11 @@ namespace NadekoBot.Modules.NSFW
JToken obj;
using (var http = _httpFactory.CreateClient())
{
obj = JArray.Parse(await http.GetStringAsync($"http://api.oboobs.ru/boobs/{new NadekoRandom().Next(0, 10330)}").ConfigureAwait(false))[0];
obj = JArray.Parse(await http
.GetStringAsync($"http://api.oboobs.ru/boobs/{new NadekoRandom().Next(0, 10330)}")
.ConfigureAwait(false))[0];
}
await ctx.Channel.SendMessageAsync($"http://media.oboobs.ru/{obj["preview"]}").ConfigureAwait(false);
}
catch (Exception ex)
@@ -85,6 +48,7 @@ namespace NadekoBot.Modules.NSFW
await SendErrorAsync(ex.Message).ConfigureAwait(false);
}
}
private async Task InternalButts(IMessageChannel Channel)
{
try
@@ -92,8 +56,11 @@ namespace NadekoBot.Modules.NSFW
JToken obj;
using (var http = _httpFactory.CreateClient())
{
obj = JArray.Parse(await http.GetStringAsync($"http://api.obutts.ru/butts/{new NadekoRandom().Next(0, 4335)}").ConfigureAwait(false))[0];
obj = JArray.Parse(await http
.GetStringAsync($"http://api.obutts.ru/butts/{new NadekoRandom().Next(0, 4335)}")
.ConfigureAwait(false))[0];
}
await Channel.SendMessageAsync($"http://media.obutts.ru/{obj["preview"]}").ConfigureAwait(false);
}
catch (Exception ex)
@@ -102,12 +69,11 @@ namespace NadekoBot.Modules.NSFW
}
}
#if !GLOBAL_NADEKO
[NadekoCommand, Aliases]
[RequireNsfw]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
public async Task AutoHentai(int interval = 0, string tags = null)
public async Task AutoHentai(int interval = 0, [Leftover] string tags = null)
{
Timer t;
@@ -123,16 +89,18 @@ namespace NadekoBot.Modules.NSFW
if (interval < 20)
return;
var tagsArr = tags?.Split('|');
t = new Timer(async (state) =>
{
try
{
if (tagsArr is null || tagsArr.Length == 0)
await InternalHentai(ctx.Channel, null).ConfigureAwait(false);
if (tags is null || tags.Length == 0)
await InternalDapiCommand(null, true, _service.Hentai).ConfigureAwait(false);
else
await InternalHentai(ctx.Channel, tagsArr[new NadekoRandom().Next(0, tagsArr.Length)]).ConfigureAwait(false);
{
var groups = tags.Split('|');
var group = groups[_rng.Next(0, groups.Length)];
await InternalDapiCommand(group.Split(' '), true, _service.Hentai).ConfigureAwait(false);
}
}
catch
{
@@ -148,7 +116,7 @@ namespace NadekoBot.Modules.NSFW
await ReplyConfirmLocalizedAsync(strs.autohentai_started(
interval,
string.Join(", ", tagsArr)));
string.Join(", ", tags)));
}
[NadekoCommand, Aliases]
@@ -231,25 +199,24 @@ namespace NadekoBot.Modules.NSFW
await ReplyConfirmLocalizedAsync(strs.started(interval));
}
#endif
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Hentai([Leftover] string tag = null) =>
InternalHentai(ctx.Channel, tag);
public Task Hentai(params string[] tags)
=> InternalDapiCommand(tags, true, _service.Hentai);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public async Task HentaiBomb([Leftover] string tag = null)
public async Task HentaiBomb(params string[] tags)
{
if (!_hentaiBombBlacklist.Add(ctx.Guild?.Id ?? ctx.User.Id))
return;
try
{
var images = await Task.WhenAll(_service.DapiSearch(tag, DapiSearchType.Gelbooru, ctx.Guild?.Id, true),
_service.DapiSearch(tag, DapiSearchType.Danbooru, ctx.Guild?.Id, true),
_service.DapiSearch(tag, DapiSearchType.Konachan, ctx.Guild?.Id, true),
_service.DapiSearch(tag, DapiSearchType.Yandere, ctx.Guild?.Id, true)).ConfigureAwait(false);
var images = await Task.WhenAll(_service.Yandere(ctx.Guild?.Id, true, tags),
_service.Danbooru(ctx.Guild?.Id, true, tags),
_service.Konachan(ctx.Guild?.Id, true, tags),
_service.Gelbooru(ctx.Guild?.Id, true, tags));
var linksEnum = images?.Where(l => l != null).ToArray();
if (images is null || !linksEnum.Any())
@@ -258,7 +225,8 @@ namespace NadekoBot.Modules.NSFW
return;
}
await ctx.Channel.SendMessageAsync(string.Join("\n\n", linksEnum.Select(x => x.FileUrl))).ConfigureAwait(false);
await ctx.Channel.SendMessageAsync(string.Join("\n\n", linksEnum.Select(x => x.Url)))
.ConfigureAwait(false);
}
finally
{
@@ -268,43 +236,48 @@ namespace NadekoBot.Modules.NSFW
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Yandere([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Yandere, false);
public Task Yandere(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Yandere);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Konachan([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Konachan, false);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Sankaku([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Sankaku, false);
public Task Konachan(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Konachan);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task E621([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.E621, false);
public Task Sankaku(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Sankaku);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Rule34([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Rule34, false);
public Task E621(params string[] tags)
=> InternalDapiCommand(tags, false, _service.E621);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Danbooru([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Danbooru, false);
public Task Rule34(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Rule34);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Gelbooru([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Gelbooru, false);
public Task Danbooru(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Danbooru);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Derpibooru([Leftover] string tag = null)
=> InternalDapiCommand(tag, DapiSearchType.Derpibooru, false);
public Task Gelbooru(params string[] tags)
=> InternalDapiCommand(tags, false, _service.Gelbooru);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Derpibooru(params string[] tags)
=> InternalDapiCommand(tags, false, _service.DerpiBooru);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
public Task Safebooru(params string[] tags)
=> InternalDapiCommand(tags, false, _service.SafeBooru);
[NadekoCommand, Aliases]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
@@ -315,8 +288,11 @@ namespace NadekoBot.Modules.NSFW
JToken obj;
using (var http = _httpFactory.CreateClient())
{
obj = JArray.Parse(await http.GetStringAsync($"http://api.oboobs.ru/boobs/{new NadekoRandom().Next(0, 12000)}").ConfigureAwait(false))[0];
obj = JArray.Parse(await http
.GetStringAsync($"http://api.oboobs.ru/boobs/{new NadekoRandom().Next(0, 12000)}")
.ConfigureAwait(false))[0];
}
await ctx.Channel.SendMessageAsync($"http://media.oboobs.ru/{obj["preview"]}").ConfigureAwait(false);
}
catch (Exception ex)
@@ -334,8 +310,11 @@ namespace NadekoBot.Modules.NSFW
JToken obj;
using (var http = _httpFactory.CreateClient())
{
obj = JArray.Parse(await http.GetStringAsync($"http://api.obutts.ru/butts/{new NadekoRandom().Next(0, 6100)}").ConfigureAwait(false))[0];
obj = JArray.Parse(await http
.GetStringAsync($"http://api.obutts.ru/butts/{new NadekoRandom().Next(0, 6100)}")
.ConfigureAwait(false))[0];
}
await ctx.Channel.SendMessageAsync($"http://media.obutts.ru/{obj["preview"]}").ConfigureAwait(false);
}
catch (Exception ex)
@@ -351,16 +330,16 @@ namespace NadekoBot.Modules.NSFW
{
if (string.IsNullOrWhiteSpace(tag))
{
var blTags = _service.GetBlacklistedTags(ctx.Guild.Id);
var blTags = await _service.GetBlacklistedTags(ctx.Guild.Id);
await SendConfirmAsync(GetText(strs.blacklisted_tag_list),
blTags.Any()
? string.Join(", ", blTags)
: "-").ConfigureAwait(false);
? string.Join(", ", blTags)
: "-").ConfigureAwait(false);
}
else
{
tag = tag.Trim().ToLowerInvariant();
var added = _service.ToggleBlacklistedTag(ctx.Guild.Id, tag);
var added = await _service.ToggleBlacklistTag(ctx.Guild.Id, tag);
if (added)
await ReplyPendingLocalizedAsync(strs.blacklisted_tag_add(tag));
@@ -369,15 +348,6 @@ namespace NadekoBot.Modules.NSFW
}
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public Task NsfwClearCache()
{
_service.ClearCache();
return ctx.OkAsync();
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
@@ -394,12 +364,12 @@ namespace NadekoBot.Modules.NSFW
await SendNhentaiGalleryInternalAsync(g);
}
[NadekoCommand, Aliases]
[RequireContext(ContextType.Guild)]
[RequireNsfw(Group = "nsfw_or_dm"), RequireContext(ContextType.DM, Group = "nsfw_or_dm")]
[Priority(0)]
public async Task Nhentai([Leftover]string query)
public async Task Nhentai([Leftover] string query)
{
var g = await _service.GetNhentaiBySearchAsync(query);
@@ -420,7 +390,7 @@ namespace NadekoBot.Modules.NSFW
.Select(tag => $"[{tag.Name}]({tag.Url})")
.TakeWhile(tag => (count += tag.Length) < 1000)
.JoinWith(" ");
var embed = _eb.Create()
.WithTitle(g.Title)
.WithDescription(g.FullTitle)
@@ -435,27 +405,23 @@ namespace NadekoBot.Modules.NSFW
await ctx.Channel.EmbedAsync(embed);
}
public async Task InternalDapiCommand(string tag, DapiSearchType type, bool forceExplicit)
private async Task InternalDapiCommand(string[] tags,
bool forceExplicit,
Func<ulong?, bool, string[], Task<UrlReply>> func)
{
ImageCacherObject imgObj;
imgObj = await _service.DapiSearch(tag, type, ctx.Guild?.Id, forceExplicit).ConfigureAwait(false);
if (imgObj is null)
await ReplyErrorLocalizedAsync(strs.no_results).ConfigureAwait(false);
else
var data = await func(ctx.Guild?.Id, forceExplicit, tags);
if (data is null || !string.IsNullOrWhiteSpace(data.Error))
{
var embed = _eb.Create().WithOkColor()
.WithDescription($"{ctx.User} [{tag ?? "url"}]({imgObj}) ")
.WithFooter(type.ToString());
if (Uri.IsWellFormedUriString(imgObj.FileUrl, UriKind.Absolute))
embed.WithImageUrl(imgObj.FileUrl);
else
Log.Error($"Image link from {type} is not a proper Url: {imgObj.FileUrl}");
await ctx.Channel.EmbedAsync(embed).ConfigureAwait(false);
await ReplyErrorLocalizedAsync(strs.no_results);
return;
}
await ctx.Channel.EmbedAsync(_eb
.Create(ctx)
.WithOkColor()
.WithImageUrl(data.Url)
.WithDescription($"[link]({data.Url})")
.WithFooter($"{data.Rating} ({data.Provider}) | {string.Join(" | ", data.Tags.Where(x => !string.IsNullOrWhiteSpace(x)).Take(5))}"));
}
}
}

View File

@@ -0,0 +1,12 @@
namespace NadekoBot.Modules.Nsfw
{
public interface INsfwService
{
}
public class NsfwService
{
}
}

View File

@@ -0,0 +1,328 @@
using Serilog;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using NadekoBot.Common;
using NadekoBot.Extensions;
using NadekoBot.Services;
namespace NadekoBot.Modules.Nsfw.Common
{
public class SearchImageCacher : INService
{
private readonly IHttpClientFactory _httpFactory;
private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1);
private readonly Random _rng;
private static readonly ISet<string> defaultTagBlacklist = new HashSet<string>()
{
"loli",
"lolicon",
"shota",
"shotacon",
"cub"
};
private readonly Dictionary<Booru, object> _typeLocks = new();
private readonly Dictionary<Booru, HashSet<string>> _usedTags = new();
private readonly IMemoryCache _cache;
public SearchImageCacher(IHttpClientFactory httpFactory, IMemoryCache cache)
{
_httpFactory = httpFactory;
_rng = new NadekoRandom();
_cache = cache;
// initialize new cache with empty values
foreach (var type in Enum.GetValues<Booru>())
{
_typeLocks[type] = new();
_usedTags[type] = new();
}
}
private string Key(Booru boory, string tag)
=> $"booru:{boory}__tag:{tag}";
/// <summary>
/// Download images of the specified type, and cache them.
/// </summary>
/// <param name="tags">Required tags</param>
/// <param name="forceExplicit">Whether images will be forced to be explicit</param>
/// <param name="type">Provider type</param>
/// <param name="cancel">Cancellation token</param>
/// <returns>Whether any image is found.</returns>
private async Task<bool> UpdateImagesInternalAsync(string[] tags, bool forceExplicit, Booru type, CancellationToken cancel)
{
var images = await DownloadImagesAsync(tags, forceExplicit, type, cancel).ConfigureAwait(false);
if (images is null || images.Count == 0)
{
// Log.Warning("Got no images for {0}, tags: {1}", type, string.Join(", ", tags));
return false;
}
Log.Information("Updating {0}...", type);
lock (_typeLocks[type])
{
var typeUsedTags = _usedTags[type];
foreach (var tag in tags)
typeUsedTags.Add(tag);
// if user uses no tags for the hentai command and there are no used
// tags atm, just select 50 random tags from downloaded images to seed
if (typeUsedTags.Count == 0)
images.SelectMany(x => x.Tags)
.Distinct()
.Shuffle()
.Take(50)
.ForEach(x => typeUsedTags.Add(x));
foreach (var img in images)
{
// if any of the tags is a tag banned by discord
// do not put that image in the cache
if (defaultTagBlacklist.Overlaps(img.Tags))
continue;
// if image doesn't have a proper absolute uri, skip it
if (!Uri.IsWellFormedUriString(img.FileUrl, UriKind.Absolute))
continue;
// i'm appending current tags because of tag aliasing
// this way, if user uses tag alias, for example 'kissing' -
// both 'kiss' (real tag returned by the image) and 'kissing' will be populated with
// retreived images
foreach (var tag in img.Tags.Concat(tags).Distinct())
{
if (typeUsedTags.Contains(tag))
{
var set = _cache.GetOrCreate<HashSet<ImageData>>(Key(type, tag), e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
return new();
});
if(set.Count < 100)
set.Add(img);
}
}
}
}
return true;
}
private ImageData QueryLocal(string[] tags, bool forceExplicit, Booru type, HashSet<string> blacklistedTags)
{
var setList = new List<HashSet<ImageData>>();
// ofc make sure no changes are happening while we're getting a random one
lock (_typeLocks[type])
{
// if no tags are provided, get a random tag
if (tags.Length == 0)
{
// get all tags in the cache
if (_usedTags.TryGetValue(type, out var allTags)
&& allTags.Count > 0)
{
tags = new[] {allTags.ToList()[_rng.Next(0, allTags.Count)]};
}
else
{
return null;
}
}
foreach (var tag in tags)
{
// if any tag is missing from cache, that means there is no result
if (_cache.TryGetValue<HashSet<ImageData>>(Key(type, tag), out var set))
setList.Add(set);
else
return null;
}
if (setList.Count == 0)
return null;
List<ImageData> resultList;
// if multiple tags, we need to interesect sets
if (setList.Count > 1)
{
// now that we have sets, interesect them to find eligible items
// make a copy of the 1st set
var resultSet = new HashSet<ImageData>(setList[0]);
// go through all other sets, and
for (var i = 1; i < setList.Count; ++i)
{
// if any of the elements in result set are not present in the current set
// remove it from the result set
resultSet.IntersectWith(setList[i]);
}
resultList = resultSet.ToList();
}
else
{
// if only one tag, use that set
resultList = setList[0].ToList();
}
// return a random one which doesn't have blacklisted tags in it
resultList = resultList.Where(x => !blacklistedTags.Overlaps(x.Tags)).ToList();
// if no items in the set -> not found
if (resultList.Count == 0)
return null;
var toReturn = resultList[_rng.Next(0, resultList.Count)];
// remove from cache
foreach (var tag in tags)
{
if (_cache.TryGetValue<HashSet<ImageData>>(Key(type, tag), out var items))
{
items.Remove(toReturn);
}
}
return toReturn;
}
}
public async Task<ImageData> GetImageNew(string[] tags, bool forceExplicit, Booru type,
HashSet<string> blacklistedTags, CancellationToken cancel)
{
// make sure tags are proper
tags = tags
.Where(x => x is not null)
.Select(tag => tag.ToLowerInvariant().Trim())
.Distinct()
.ToArray();
if (tags.Length > 2 && type == Booru.Danbooru)
tags = tags[..2];
// use both tags banned by discord and tags banned on the server
if (blacklistedTags.Overlaps(tags) || defaultTagBlacklist.Overlaps(tags))
return default;
// query for an image
var image = QueryLocal(tags, forceExplicit, type, blacklistedTags);
if (image is not null)
return image;
var success = false;
try
{
// if image is not found, update the cache and query again
success = await UpdateImagesInternalAsync(tags, forceExplicit, type, cancel).ConfigureAwait(false);
}
catch (HttpRequestException)
{
return default;
}
if (!success)
return default;
image = QueryLocal(tags, forceExplicit, type, blacklistedTags);
return image;
}
private readonly ConcurrentDictionary<(Booru, string), int> maxPages = new();
public async Task<List<ImageData>> DownloadImagesAsync(string[] tags, bool isExplicit, Booru type, CancellationToken cancel)
{
var tagStr = string.Join(' ', tags.OrderByDescending(x => x));
var page = 0;
var attempt = 0;
while (attempt++ <= 10)
{
if (maxPages.TryGetValue((type, tagStr), out var maxPage))
{
if (maxPage == 0)
{
Log.Information("Tag {0} yields no result on {1}, skipping.", tagStr, type);
return new();
}
page = _rng.Next(0, maxPage);
}
else
{
page = _rng.Next(0, 11);
}
var result = await DownloadImagesAsync(tags, isExplicit, type, page, cancel).ConfigureAwait(false);
if (result is null or { Count: 0 })
{
Log.Information("Tag {0}, page {1} has no result on {2}.", string.Join(", ", tags), page, type.ToString());
continue;
}
return result;
}
return new();
}
private IImageDownloader GetImageDownloader(Booru booru, HttpClient http)
=> booru switch
{
Booru.Danbooru => new DanbooruImageDownloader(http),
Booru.Yandere => new YandereImageDownloader(http),
Booru.Konachan => new KonachanImageDownloader(http),
Booru.Safebooru => new SafebooruImageDownloader(http),
Booru.E621 => new E621ImageDownloader(http),
Booru.Derpibooru => new DerpibooruImageDownloader(http),
Booru.Gelbooru => new GelbooruImageDownloader(http),
Booru.Rule34 => new Rule34ImageDownloader(http),
Booru.Sankaku => new SankakuImageDownloader(http),
_ => throw new NotImplementedException($"{booru} downloader not implemented.")
};
private async Task<List<ImageData>> DownloadImagesAsync(string[] tags, bool isExplicit, Booru type, int page, CancellationToken cancel)
{
try
{
Log.Information("Downloading from {0} (page {1})...", type, page);
using var http = _httpFactory.CreateClient();
var downloader = GetImageDownloader(type, http);
var images = await downloader.DownloadImageDataAsync(tags, page, isExplicit, cancel);
if (images.Count == 0)
{
var tagStr = string.Join(' ', tags.OrderByDescending(x => x));
maxPages[(type, tagStr)] = page;
}
return images;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
Log.Error(ex, "Error downloading an image:\nTags: {0}\nType: {1}\nPage: {2}\nMessage: {3}",
string.Join(", ", tags),
type,
page,
ex.Message);
return new();
}
}
}
}

View File

@@ -0,0 +1,393 @@
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Threading;
using LinqToDB;
using NadekoBot.Common;
using NadekoBot.Extensions;
using NadekoBot.Modules.Searches.Common;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
using Newtonsoft.Json;
using Booru = NadekoBot.Modules.Nsfw.Common.Booru;
using SearchImageCacher = NadekoBot.Modules.Nsfw.Common.SearchImageCacher;
namespace NadekoBot.Modules.Nsfw
{
public record TagRequest(ulong GuildId, bool ForceExplicit, Booru SearchType, params string[] Tags);
public record UrlReply
{
public string Error { get; init; }
public string Url { get; init; }
public string Rating { get; init; }
public string Provider { get; init; }
public List<string> Tags { get; } = new List<string>();
}
public class SearchImagesService : ISearchImagesService, INService
{
private readonly Random _rng;
private readonly HttpClient _http;
private readonly SearchImageCacher _cache;
private readonly IHttpClientFactory _httpFactory;
private readonly DbService _db;
private ConcurrentDictionary<ulong, HashSet<string>> BlacklistedTags { get; }
public ConcurrentDictionary<ulong, Timer> AutoHentaiTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
public ConcurrentDictionary<ulong, Timer> AutoBoobTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
public ConcurrentDictionary<ulong, Timer> AutoButtTimers { get; } = new ConcurrentDictionary<ulong, Timer>();
public SearchImagesService(DbService db,
IHttpClientFactory http,
SearchImageCacher cacher,
IHttpClientFactory httpFactory)
{
_db = db;
_rng = new NadekoRandom();
_http = http.CreateClient();
_http.AddFakeHeaders();
_cache = cacher;
_httpFactory = httpFactory;
using var uow = db.GetDbContext();
BlacklistedTags = new(
uow.NsfwBlacklistedTags
.AsEnumerable()
.GroupBy(x => x.GuildId)
.ToDictionary(
x => x.Key,
x => new HashSet<string>(x.Select(x => x.Tag))));
}
private Task<UrlReply> GetNsfwImageAsync(ulong? guildId, bool forceExplicit, string[] tags, Booru dapi, CancellationToken cancel = default)
{
return GetNsfwImageAsync(guildId ?? 0, tags ?? Array.Empty<string>(), forceExplicit, dapi, cancel);
}
private bool IsValidTag(string tag) => tag.All(x => x != '+' && x != '?' && x != '/'); // tags mustn't contain + or ? or /
private async Task<UrlReply> GetNsfwImageAsync(
ulong guildId,
string[] tags,
bool forceExplicit,
Booru dapi,
CancellationToken cancel)
{
if (!tags.All(x => IsValidTag(x)))
{
return new UrlReply
{
Error = "One or more tags are invalid.",
Url = ""
};
}
Log.Information("Getting {V} image for Guild: {GuildId}...", dapi.ToString(), guildId);
try
{
BlacklistedTags.TryGetValue(guildId, out var blTags);
if (dapi == Booru.E621) {
for (var i = 0; i < tags.Length; ++i)
if (tags[i] == "yuri")
tags[i] = "female/female";
}
if (dapi == Booru.Derpibooru)
{
for (var i = 0; i < tags.Length; ++i)
if (tags[i] == "yuri")
tags[i] = "lesbian";
}
var result = await _cache.GetImageNew(tags, forceExplicit, dapi, blTags ?? new HashSet<string>(), cancel)
.ConfigureAwait(false);
if (result is null)
{
return new UrlReply
{
Error = "Image not found.",
Url = ""
};
}
var reply = new UrlReply
{
Error = "",
Url = result.FileUrl,
Rating = result.Rating,
Provider = result.SearchType.ToString()
};
reply.Tags.AddRange(result.Tags);
return reply;
}
catch (Exception ex)
{
Log.Error(ex, "Failed getting {Dapi} image: {Message}", dapi, ex.Message);
return new UrlReply
{
Error = ex.Message,
Url = ""
};
}
}
public Task<UrlReply> Gelbooru(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Gelbooru);
public Task<UrlReply> Danbooru(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Danbooru);
public Task<UrlReply> Konachan(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Konachan);
public Task<UrlReply> Yandere(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Yandere);
public Task<UrlReply> Rule34(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Rule34);
public Task<UrlReply> E621(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.E621);
public Task<UrlReply> DerpiBooru(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Derpibooru);
public Task<UrlReply> SafeBooru(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Safebooru);
public Task<UrlReply> Sankaku(ulong? guildId, bool forceExplicit, string[] tags)
=> GetNsfwImageAsync(guildId, forceExplicit, tags, Booru.Sankaku);
public async Task<UrlReply> Hentai(ulong? guildId, bool forceExplicit, string[] tags)
{
var providers = new[] {
Booru.Danbooru,
Booru.Konachan,
Booru.Gelbooru,
Booru.Yandere
};
using var cancelSource = new CancellationTokenSource();
// create a task for each type
var tasks = providers.Select(type => GetNsfwImageAsync(guildId, forceExplicit, tags, type)).ToList();
do
{
// wait for any of the tasks to complete
var task = await Task.WhenAny(tasks);
// get its result
var result = task.GetAwaiter().GetResult();
if(result.Error == "")
{
// if we have a non-error result, cancel other searches and return the result
cancelSource.Cancel();
return result;
}
// if the result is an error, remove that task from the waiting list,
// and wait for another task to complete
tasks.Remove(task);
}
while (tasks.Count > 0); // keep looping as long as there is any task remaining to be attempted
// if we ran out of tasks, that means all tasks failed - return an error
return new UrlReply()
{
Error = "No hentai image found."
};
}
public async Task<UrlReply> Boobs()
{
try
{
JToken obj;
obj = JArray.Parse(await _http.GetStringAsync($"http://api.oboobs.ru/boobs/{_rng.Next(0, 12000)}").ConfigureAwait(false))[0];
return new UrlReply
{
Error = "",
Url = $"http://media.oboobs.ru/{obj["preview"]}",
};
}
catch (Exception ex)
{
Log.Error(ex, "Error retreiving boob image: {Message}", ex.Message);
return new UrlReply
{
Error = ex.Message,
Url = "",
};
}
}
private readonly object taglock = new object();
public ValueTask<bool> ToggleBlacklistTag(ulong guildId, string tag)
{
lock (taglock)
{
tag = tag.Trim().ToLowerInvariant();
var blacklistedTags = BlacklistedTags.GetOrAdd(guildId, new HashSet<string>());
var isAdded = blacklistedTags.Add(tag);
using var uow = _db.GetDbContext();
if (!isAdded)
{
blacklistedTags.Remove(tag);
uow.NsfwBlacklistedTags.DeleteAsync(x => x.GuildId == guildId && x.Tag == tag);
uow.SaveChanges();
}
else
{
uow.NsfwBlacklistedTags.Add(new NsfwBlacklistedTag()
{
Tag = tag,
GuildId = guildId
});
uow.SaveChanges();
}
return new(isAdded);
}
}
public ValueTask<string[]> GetBlacklistedTags(ulong guildId)
{
lock (taglock)
{
if (BlacklistedTags.TryGetValue(guildId, out var tags))
{
return new(tags.ToArray());
}
return new(Array.Empty<string>());
}
}
public async Task<UrlReply> Butts()
{
try
{
JToken obj;
obj = JArray.Parse(await _http.GetStringAsync($"http://api.obutts.ru/butts/{_rng.Next(0, 6100)}"))[0];
return new UrlReply
{
Error = "",
Url = $"http://media.obutts.ru/{obj["preview"]}",
};
}
catch (Exception ex)
{
Log.Error(ex, "Error retreiving butt image: {Message}", ex.Message);
return new UrlReply
{
Error = ex.Message,
Url = "",
};
}
}
#region Nhentai
private string GetNhentaiExtensionInternal(string s)
=> s switch
{
"j" => "jpg",
"p" => "png",
"g" => "gif",
_ => "jpg"
};
private Gallery ModelToGallery(NhentaiApiModel.Gallery model)
{
var thumbnail = $"https://t.nhentai.net/galleries/{model.MediaId}/thumb."
+ GetNhentaiExtensionInternal(model.Images.Thumbnail.T);
var url = $"https://nhentai.net/g/{model.Id}";
return new Gallery(
model.Id.ToString(),
url,
model.Title.English,
model.Title.Pretty,
thumbnail,
model.NumPages,
model.NumFavorites,
model.UploadDate.ToUnixTimestamp().UtcDateTime,
model.Tags.Map(x => new Tag()
{
Name = x.Name,
Url = "https://nhentai.com/" + x.Url
}));
}
private async Task<NhentaiApiModel.Gallery> GetNhentaiByIdInternalAsync(uint id)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/gallery/" + id);
return JsonConvert.DeserializeObject<NhentaiApiModel.Gallery>(res);
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with id {NhentaiId} not found", id);
return null;
}
}
private async Task<NhentaiApiModel.Gallery[]> SearchNhentaiInternalAsync(string search)
{
using var http = _httpFactory.CreateClient();
try
{
var res = await http.GetStringAsync("https://nhentai.net/api/galleries/search?query=" + search);
return JsonConvert.DeserializeObject<NhentaiApiModel.SearchResult>(res).Result;
}
catch (HttpRequestException)
{
Log.Warning("Nhentai with search {NhentaiSearch} not found", search);
return null;
}
}
public async Task<Gallery> GetNhentaiByIdAsync(uint id)
{
var model = await GetNhentaiByIdInternalAsync(id);
return ModelToGallery(model);
}
private static readonly string[] _bannedTags =
{
"loli",
"lolicon",
"shota",
"shotacon",
"cub"
};
public async Task<Gallery> GetNhentaiBySearchAsync(string search)
{
var models = await SearchNhentaiInternalAsync(search);
models = models.Where(x => !x.Tags.Any(t => _bannedTags.Contains(t.Name))).ToArray();
if (models.Length == 0)
return null;
return ModelToGallery(models[_rng.Next(0, models.Length)]);
}
#endregion
}
}

View File

@@ -1,18 +1,19 @@
using System;
using System.Collections.Generic;
using NadekoBot.Modules.Nsfw.Common;
namespace NadekoBot.Modules.Searches.Common
{
public class ImageCacherObject : IComparable<ImageCacherObject>
{
public DapiSearchType SearchType { get; }
public Booru SearchType { get; }
public string FileUrl { get; }
public HashSet<string> Tags { get; }
public string Rating { get; }
public ImageCacherObject(DapiImageObject obj, DapiSearchType type)
public ImageCacherObject(DapiImageObject obj, Booru type)
{
if (type == DapiSearchType.Danbooru && !Uri.IsWellFormedUriString(obj.FileUrl, UriKind.Absolute))
if (type == Booru.Danbooru && !Uri.IsWellFormedUriString(obj.FileUrl, UriKind.Absolute))
{
this.FileUrl = "https://danbooru.donmai.us" + obj.FileUrl;
}
@@ -25,7 +26,7 @@ namespace NadekoBot.Modules.Searches.Common
this.Tags = new HashSet<string>((obj.Tags ?? obj.TagString).Split(' '));
}
public ImageCacherObject(string url, DapiSearchType type, string tags, string rating)
public ImageCacherObject(string url, Booru type, string tags, string rating)
{
this.SearchType = type;
this.FileUrl = url;

Some files were not shown because too many files have changed in this diff Show More