mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2026-03-03 14:49:38 -05:00
Restructured the project structure back to the way it was, there's no reasonable way to split the modules
This commit is contained in:
405
src/NadekoBot/Modules/Administration/Administration.cs
Normal file
405
src/NadekoBot/Modules/Administration/Administration.cs
Normal file
@@ -0,0 +1,405 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration : NadekoModule<AdministrationService>
|
||||
{
|
||||
public enum Channel
|
||||
{
|
||||
Channel,
|
||||
Ch,
|
||||
Chnl,
|
||||
Chan
|
||||
}
|
||||
|
||||
public enum List
|
||||
{
|
||||
List = 0,
|
||||
Ls = 0
|
||||
}
|
||||
|
||||
public enum Server
|
||||
{
|
||||
Server
|
||||
}
|
||||
|
||||
public enum State
|
||||
{
|
||||
Enable,
|
||||
Disable,
|
||||
Inherit
|
||||
}
|
||||
|
||||
private readonly SomethingOnlyChannelService _somethingOnly;
|
||||
private readonly AutoPublishService _autoPubService;
|
||||
|
||||
public Administration(SomethingOnlyChannelService somethingOnly, AutoPublishService autoPubService)
|
||||
{
|
||||
_somethingOnly = somethingOnly;
|
||||
_autoPubService = autoPubService;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageGuild)]
|
||||
public async Task ImageOnlyChannel(StoopidTime time = null)
|
||||
{
|
||||
var newValue = await _somethingOnly.ToggleImageOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
|
||||
if (newValue)
|
||||
await ReplyConfirmLocalizedAsync(strs.imageonly_enable);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.imageonly_disable);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageGuild)]
|
||||
public async Task LinkOnlyChannel(StoopidTime time = null)
|
||||
{
|
||||
var newValue = await _somethingOnly.ToggleLinkOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
|
||||
if (newValue)
|
||||
await ReplyConfirmLocalizedAsync(strs.linkonly_enable);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.linkonly_disable);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageChannels)]
|
||||
[BotPerm(ChannelPerm.ManageChannels)]
|
||||
public async Task Slowmode(StoopidTime time = null)
|
||||
{
|
||||
var seconds = (int?)time?.Time.TotalSeconds ?? 0;
|
||||
if (time is not null && (time.Time < TimeSpan.FromSeconds(0) || time.Time > TimeSpan.FromHours(6)))
|
||||
return;
|
||||
|
||||
await ((ITextChannel)ctx.Channel).ModifyAsync(tcp =>
|
||||
{
|
||||
tcp.SlowModeInterval = seconds;
|
||||
});
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageMessages)]
|
||||
[Priority(2)]
|
||||
public async Task Delmsgoncmd(List _)
|
||||
{
|
||||
var guild = (SocketGuild)ctx.Guild;
|
||||
var (enabled, channels) = _service.GetDelMsgOnCmdData(ctx.Guild.Id);
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.server_delmsgoncmd))
|
||||
.WithDescription(enabled ? "✅" : "❌");
|
||||
|
||||
var str = string.Join("\n",
|
||||
channels.Select(x =>
|
||||
{
|
||||
var ch = guild.GetChannel(x.ChannelId)?.ToString() ?? x.ChannelId.ToString();
|
||||
var prefixSign = x.State ? "✅ " : "❌ ";
|
||||
return prefixSign + ch;
|
||||
}));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
str = "-";
|
||||
|
||||
embed.AddField(GetText(strs.channel_delmsgoncmd), str);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageMessages)]
|
||||
[Priority(1)]
|
||||
public async Task Delmsgoncmd(Server _ = Server.Server)
|
||||
{
|
||||
if (_service.ToggleDeleteMessageOnCommand(ctx.Guild.Id))
|
||||
{
|
||||
_service.DeleteMessagesOnCommand.Add(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.delmsg_on);
|
||||
}
|
||||
else
|
||||
{
|
||||
_service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.delmsg_off);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageMessages)]
|
||||
[Priority(0)]
|
||||
public Task Delmsgoncmd(Channel _, State s, ITextChannel ch)
|
||||
=> Delmsgoncmd(_, s, ch.Id);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageMessages)]
|
||||
[Priority(1)]
|
||||
public async Task Delmsgoncmd(Channel _, State s, ulong? chId = null)
|
||||
{
|
||||
var actualChId = chId ?? ctx.Channel.Id;
|
||||
await _service.SetDelMsgOnCmdState(ctx.Guild.Id, actualChId, s);
|
||||
|
||||
if (s == State.Disable)
|
||||
await ReplyConfirmLocalizedAsync(strs.delmsg_channel_off);
|
||||
else if (s == State.Enable)
|
||||
await ReplyConfirmLocalizedAsync(strs.delmsg_channel_on);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.delmsg_channel_inherit);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.DeafenMembers)]
|
||||
[BotPerm(GuildPerm.DeafenMembers)]
|
||||
public async Task Deafen(params IGuildUser[] users)
|
||||
{
|
||||
await _service.DeafenUsers(true, users);
|
||||
await ReplyConfirmLocalizedAsync(strs.deafen);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.DeafenMembers)]
|
||||
[BotPerm(GuildPerm.DeafenMembers)]
|
||||
public async Task UnDeafen(params IGuildUser[] users)
|
||||
{
|
||||
await _service.DeafenUsers(false, users);
|
||||
await ReplyConfirmLocalizedAsync(strs.undeafen);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel)
|
||||
{
|
||||
await voiceChannel.DeleteAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.delvoich(Format.Bold(voiceChannel.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task CreatVoiChanl([Leftover] string channelName)
|
||||
{
|
||||
var ch = await ctx.Guild.CreateVoiceChannelAsync(channelName);
|
||||
await ReplyConfirmLocalizedAsync(strs.createvoich(Format.Bold(ch.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task DelTxtChanl([Leftover] ITextChannel toDelete)
|
||||
{
|
||||
await toDelete.DeleteAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.deltextchan(Format.Bold(toDelete.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task CreaTxtChanl([Leftover] string channelName)
|
||||
{
|
||||
var txtCh = await ctx.Guild.CreateTextChannelAsync(channelName);
|
||||
await ReplyConfirmLocalizedAsync(strs.createtextchan(Format.Bold(txtCh.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task SetTopic([Leftover] string topic = null)
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
topic ??= "";
|
||||
await channel.ModifyAsync(c => c.Topic = topic);
|
||||
await ReplyConfirmLocalizedAsync(strs.set_topic);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task SetChanlName([Leftover] string name)
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
await channel.ModifyAsync(c => c.Name = name);
|
||||
await ReplyConfirmLocalizedAsync(strs.set_channel_name);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageChannels)]
|
||||
[BotPerm(GuildPerm.ManageChannels)]
|
||||
public async Task AgeRestrictToggle()
|
||||
{
|
||||
var channel = (ITextChannel)ctx.Channel;
|
||||
var isEnabled = channel.IsNsfw;
|
||||
|
||||
await channel.ModifyAsync(c => c.IsNsfw = !isEnabled);
|
||||
|
||||
if (isEnabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.nsfw_set_false);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.nsfw_set_true);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
[Priority(0)]
|
||||
public Task Edit(ulong messageId, [Leftover] string text)
|
||||
=> Edit((ITextChannel)ctx.Channel, messageId, text);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task Edit(ITextChannel channel, ulong messageId, [Leftover] string text)
|
||||
{
|
||||
var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel);
|
||||
var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel);
|
||||
if (!userPerms.Has(ChannelPermission.ManageMessages))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_u);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!botPerms.Has(ChannelPermission.ViewChannel))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_i);
|
||||
return;
|
||||
}
|
||||
|
||||
await _service.EditMessage(ctx, channel, messageId, text);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
[BotPerm(ChannelPerm.ManageMessages)]
|
||||
public Task Delete(ulong messageId, StoopidTime time = null)
|
||||
=> Delete((ITextChannel)ctx.Channel, messageId, time);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Delete(ITextChannel channel, ulong messageId, StoopidTime time = null)
|
||||
=> await InternalMessageAction(channel, messageId, time, msg => msg.DeleteAsync());
|
||||
|
||||
private async Task InternalMessageAction(
|
||||
ITextChannel channel,
|
||||
ulong messageId,
|
||||
StoopidTime time,
|
||||
Func<IMessage, Task> func)
|
||||
{
|
||||
var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel);
|
||||
var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel);
|
||||
if (!userPerms.Has(ChannelPermission.ManageMessages))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_u);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!botPerms.Has(ChannelPermission.ManageMessages))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_i);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
var msg = await channel.GetMessageAsync(messageId);
|
||||
if (msg is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.msg_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (time is null)
|
||||
await msg.DeleteAsync();
|
||||
else if (time.Time <= TimeSpan.FromDays(7))
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(time.Time);
|
||||
await msg.DeleteAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.time_too_long);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[BotPerm(ChannelPermission.CreatePublicThreads)]
|
||||
[UserPerm(ChannelPermission.CreatePublicThreads)]
|
||||
public async Task ThreadCreate([Leftover] string name)
|
||||
{
|
||||
if (ctx.Channel is not SocketTextChannel stc)
|
||||
return;
|
||||
|
||||
await stc.CreateThreadAsync(name, message: ctx.Message.ReferencedMessage);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[BotPerm(ChannelPermission.ManageThreads)]
|
||||
[UserPerm(ChannelPermission.ManageThreads)]
|
||||
public async Task ThreadDelete([Leftover] string name)
|
||||
{
|
||||
if (ctx.Channel is not SocketTextChannel stc)
|
||||
return;
|
||||
|
||||
var t = stc.Threads.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
if (t is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
await t.DeleteAsync();
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
public async Task AutoPublish()
|
||||
{
|
||||
if (ctx.Channel.GetChannelType() != ChannelType.News)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.req_announcement_channel);
|
||||
return;
|
||||
}
|
||||
|
||||
var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id);
|
||||
|
||||
if (newState)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.autopublish_enable);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.autopublish_disable);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/NadekoBot/Modules/Administration/AdministrationService.cs
Normal file
162
src/NadekoBot/Modules/Administration/AdministrationService.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class AdministrationService : INService
|
||||
{
|
||||
public ConcurrentHashSet<ulong> DeleteMessagesOnCommand { get; }
|
||||
public ConcurrentDictionary<ulong, bool> DeleteMessagesOnCommandChannels { get; }
|
||||
|
||||
private readonly DbService _db;
|
||||
private readonly IReplacementService _repSvc;
|
||||
private readonly ILogCommandService _logService;
|
||||
|
||||
public AdministrationService(
|
||||
IBot bot,
|
||||
CommandHandler cmdHandler,
|
||||
DbService db,
|
||||
IReplacementService repSvc,
|
||||
ILogCommandService logService)
|
||||
{
|
||||
_db = db;
|
||||
_repSvc = repSvc;
|
||||
_logService = logService;
|
||||
|
||||
DeleteMessagesOnCommand = new(bot.AllGuildConfigs.Where(g => g.DeleteMessageOnCommand).Select(g => g.GuildId));
|
||||
|
||||
DeleteMessagesOnCommandChannels = new(bot.AllGuildConfigs.SelectMany(x => x.DelMsgOnCmdChannels)
|
||||
.ToDictionary(x => x.ChannelId, x => x.State)
|
||||
.ToConcurrent());
|
||||
|
||||
cmdHandler.CommandExecuted += DelMsgOnCmd_Handler;
|
||||
}
|
||||
|
||||
public (bool DelMsgOnCmd, IEnumerable<DelMsgOnCmdChannel> channels) GetDelMsgOnCmdData(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels));
|
||||
|
||||
return (conf.DeleteMessageOnCommand, conf.DelMsgOnCmdChannels);
|
||||
}
|
||||
|
||||
private Task DelMsgOnCmd_Handler(IUserMessage msg, CommandInfo cmd)
|
||||
{
|
||||
if (msg.Channel is not ITextChannel channel)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
//wat ?!
|
||||
if (DeleteMessagesOnCommandChannels.TryGetValue(channel.Id, out var state))
|
||||
{
|
||||
if (state && cmd.Name != "prune" && cmd.Name != "pick")
|
||||
{
|
||||
_logService.AddDeleteIgnore(msg.Id);
|
||||
try { await msg.DeleteAsync(); }
|
||||
catch { }
|
||||
}
|
||||
//if state is false, that means do not do it
|
||||
}
|
||||
else if (DeleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick")
|
||||
{
|
||||
_logService.AddDeleteIgnore(msg.Id);
|
||||
try { await msg.DeleteAsync(); }
|
||||
catch { }
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool ToggleDeleteMessageOnCommand(ulong guildId)
|
||||
{
|
||||
bool enabled;
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
enabled = conf.DeleteMessageOnCommand = !conf.DeleteMessageOnCommand;
|
||||
|
||||
uow.SaveChanges();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState)
|
||||
{
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels));
|
||||
|
||||
var old = conf.DelMsgOnCmdChannels.FirstOrDefault(x => x.ChannelId == chId);
|
||||
if (newState == Administration.State.Inherit)
|
||||
{
|
||||
if (old is not null)
|
||||
{
|
||||
conf.DelMsgOnCmdChannels.Remove(old);
|
||||
uow.Remove(old);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (old is null)
|
||||
{
|
||||
old = new()
|
||||
{
|
||||
ChannelId = chId
|
||||
};
|
||||
conf.DelMsgOnCmdChannels.Add(old);
|
||||
}
|
||||
|
||||
old.State = newState == Administration.State.Enable;
|
||||
DeleteMessagesOnCommandChannels[chId] = newState == Administration.State.Enable;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (newState == Administration.State.Disable)
|
||||
{
|
||||
}
|
||||
else if (newState == Administration.State.Enable)
|
||||
DeleteMessagesOnCommandChannels[chId] = true;
|
||||
else
|
||||
DeleteMessagesOnCommandChannels.TryRemove(chId, out _);
|
||||
}
|
||||
|
||||
public async Task DeafenUsers(bool value, params IGuildUser[] users)
|
||||
{
|
||||
if (!users.Any())
|
||||
return;
|
||||
foreach (var u in users)
|
||||
{
|
||||
try
|
||||
{
|
||||
await u.ModifyAsync(usr => usr.Deaf = value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EditMessage(
|
||||
ICommandContext context,
|
||||
ITextChannel chanl,
|
||||
ulong messageId,
|
||||
string input)
|
||||
{
|
||||
var msg = await chanl.GetMessageAsync(messageId);
|
||||
|
||||
if (msg is not IUserMessage umsg || msg.Author.Id != context.Client.CurrentUser.Id)
|
||||
return;
|
||||
|
||||
var repCtx = new ReplacementContext(context);
|
||||
|
||||
var text = SmartText.CreateFrom(input);
|
||||
text = await _repSvc.ReplaceAsync(text, repCtx);
|
||||
|
||||
await umsg.EditAsync(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class AutoAssignRoleCommands : NadekoModule<AutoAssignRoleService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task AutoAssignRole([Leftover] IRole role)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (role.Id == ctx.Guild.EveryoneRole.Id)
|
||||
return;
|
||||
|
||||
// the user can't aar the role which is higher or equal to his highest role
|
||||
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.hierarchy);
|
||||
return;
|
||||
}
|
||||
|
||||
var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id);
|
||||
if (roles.Count == 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.aar_disabled);
|
||||
else if (roles.Contains(role.Id))
|
||||
await AutoAssignRole();
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.aar_role_removed(Format.Bold(role.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task AutoAssignRole()
|
||||
{
|
||||
if (!_service.TryGetRoles(ctx.Guild.Id, out var roles))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.aar_none);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = roles.Select(rid => ctx.Guild.GetRole(rid)).Where(r => r is not null).ToList();
|
||||
|
||||
if (existing.Count != roles.Count)
|
||||
await _service.SetAarRolesAsync(ctx.Guild.Id, existing.Select(x => x.Id));
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.aar_roles(
|
||||
'\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
using LinqToDB;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class AutoAssignRoleService : INService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly DbService _db;
|
||||
|
||||
//guildid/roleid
|
||||
private readonly ConcurrentDictionary<ulong, IReadOnlyList<ulong>> _autoAssignableRoles;
|
||||
|
||||
private readonly Channel<SocketGuildUser> _assignQueue = Channel.CreateBounded<SocketGuildUser>(
|
||||
new BoundedChannelOptions(100)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public AutoAssignRoleService(DiscordSocketClient client, IBot bot, DbService db)
|
||||
{
|
||||
_client = client;
|
||||
_db = db;
|
||||
|
||||
_autoAssignableRoles = bot.AllGuildConfigs.Where(x => !string.IsNullOrWhiteSpace(x.AutoAssignRoleIds))
|
||||
.ToDictionary<GuildConfig, ulong, IReadOnlyList<ulong>>(k => k.GuildId,
|
||||
v => v.GetAutoAssignableRoles())
|
||||
.ToConcurrent();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var user = await _assignQueue.Reader.ReadAsync();
|
||||
if (!_autoAssignableRoles.TryGetValue(user.Guild.Id, out var savedRoleIds))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var roleIds = savedRoleIds.Select(roleId => user.Guild.GetRole(roleId))
|
||||
.Where(x => x is not null)
|
||||
.ToList();
|
||||
|
||||
if (roleIds.Any())
|
||||
{
|
||||
await user.AddRolesAsync(roleIds);
|
||||
await Task.Delay(250);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning(
|
||||
"Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server the roles dont exist",
|
||||
user.Guild.Name,
|
||||
user.Guild.Id);
|
||||
|
||||
await DisableAarAsync(user.Guild.Id);
|
||||
}
|
||||
}
|
||||
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
Log.Warning(
|
||||
"Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server because I don't have role management permissions",
|
||||
user.Guild.Name,
|
||||
user.Guild.Id);
|
||||
|
||||
await DisableAarAsync(user.Guild.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in aar. Probably one of the roles doesn't exist");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_client.UserJoined += OnClientOnUserJoined;
|
||||
_client.RoleDeleted += OnClientRoleDeleted;
|
||||
}
|
||||
|
||||
private async Task OnClientRoleDeleted(SocketRole role)
|
||||
{
|
||||
if (_autoAssignableRoles.TryGetValue(role.Guild.Id, out var roles) && roles.Contains(role.Id))
|
||||
await ToggleAarAsync(role.Guild.Id, role.Id);
|
||||
}
|
||||
|
||||
private async Task OnClientOnUserJoined(SocketGuildUser user)
|
||||
{
|
||||
if (_autoAssignableRoles.TryGetValue(user.Guild.Id, out _))
|
||||
await _assignQueue.Writer.WriteAsync(user);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ulong>> ToggleAarAsync(ulong guildId, ulong roleId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
var roles = gc.GetAutoAssignableRoles();
|
||||
if (!roles.Remove(roleId) && roles.Count < 3)
|
||||
roles.Add(roleId);
|
||||
|
||||
gc.SetAutoAssignableRoles(roles);
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
if (roles.Count > 0)
|
||||
_autoAssignableRoles[guildId] = roles;
|
||||
else
|
||||
_autoAssignableRoles.TryRemove(guildId, out _);
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
public async Task DisableAarAsync(ulong guildId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
await uow.Set<GuildConfig>().AsNoTracking()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.UpdateAsync(_ => new()
|
||||
{
|
||||
AutoAssignRoleIds = null
|
||||
});
|
||||
|
||||
_autoAssignableRoles.TryRemove(guildId, out _);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetAarRolesAsync(ulong guildId, IEnumerable<ulong> newRoles)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
gc.SetAutoAssignableRoles(newRoles);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public bool TryGetRoles(ulong guildId, out IReadOnlyList<ulong> roles)
|
||||
=> _autoAssignableRoles.TryGetValue(guildId, out roles);
|
||||
}
|
||||
|
||||
public static class GuildConfigExtensions
|
||||
{
|
||||
public static List<ulong> GetAutoAssignableRoles(this GuildConfig gc)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(gc.AutoAssignRoleIds))
|
||||
return new();
|
||||
|
||||
return gc.AutoAssignRoleIds.Split(',').Select(ulong.Parse).ToList();
|
||||
}
|
||||
|
||||
public static void SetAutoAssignableRoles(this GuildConfig gc, IEnumerable<ulong> roles)
|
||||
=> gc.AutoAssignRoleIds = roles.Join(',');
|
||||
}
|
||||
87
src/NadekoBot/Modules/Administration/AutoPublishService.cs
Normal file
87
src/NadekoBot/Modules/Administration/AutoPublishService.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class AutoPublishService : IExecNoCommand, IReadyExecutor, INService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IBotCredsProvider _creds;
|
||||
private ConcurrentDictionary<ulong, ulong> _enabled;
|
||||
|
||||
public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
}
|
||||
|
||||
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
|
||||
{
|
||||
if (guild is null)
|
||||
return;
|
||||
|
||||
if (msg.Channel.GetChannelType() != ChannelType.News)
|
||||
return;
|
||||
|
||||
if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id)
|
||||
return;
|
||||
|
||||
await msg.CrosspostAsync(new RequestOptions()
|
||||
{
|
||||
RetryMode = RetryMode.AlwaysFail
|
||||
});
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
var creds = _creds.GetCreds();
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var items = await ctx.GetTable<AutoPublishChannel>()
|
||||
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId))
|
||||
.ToListAsyncLinqToDB();
|
||||
|
||||
_enabled = items
|
||||
.ToDictionary(x => x.GuildId, x => x.ChannelId)
|
||||
.ToConcurrent();
|
||||
}
|
||||
|
||||
public async Task<bool> ToggleAutoPublish(ulong guildId, ulong channelId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var deleted = await ctx.GetTable<AutoPublishChannel>()
|
||||
.DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId);
|
||||
|
||||
if (deleted != 0)
|
||||
{
|
||||
_enabled.TryRemove(guildId, out _);
|
||||
return false;
|
||||
}
|
||||
|
||||
await ctx.GetTable<AutoPublishChannel>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
ChannelId = channelId,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
},
|
||||
old => new()
|
||||
{
|
||||
ChannelId = channelId,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
GuildId = guildId
|
||||
});
|
||||
|
||||
_enabled[guildId] = channelId;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#nullable disable
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
#if !GLOBAL_NADEKO
|
||||
namespace NadekoBot.Modules.Administration
|
||||
{
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
[OwnerOnly]
|
||||
public partial class DangerousCommands : NadekoModule<DangerousCommandsService>
|
||||
{
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task SqlSelect([Leftover] string sql)
|
||||
{
|
||||
var result = _service.SelectSql(sql);
|
||||
|
||||
return ctx.SendPaginatedConfirmAsync(0,
|
||||
cur =>
|
||||
{
|
||||
var items = result.Results.Skip(cur * 20).Take(20).ToList();
|
||||
|
||||
if (!items.Any())
|
||||
return _eb.Create().WithErrorColor().WithFooter(sql).WithDescription("-");
|
||||
|
||||
return _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithFooter(sql)
|
||||
.WithTitle(string.Join(" ║ ", result.ColumnNames))
|
||||
.WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x))));
|
||||
},
|
||||
result.Results.Count,
|
||||
20);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SqlSelectCsv([Leftover] string sql)
|
||||
{
|
||||
var result = _service.SelectSql(sql);
|
||||
|
||||
// create a file stream and write the data as csv
|
||||
using var ms = new MemoryStream();
|
||||
await using var sw = new StreamWriter(ms);
|
||||
await using var csv = new CsvWriter(sw,
|
||||
new CsvConfiguration(CultureInfo.InvariantCulture) { Delimiter = "," });
|
||||
|
||||
foreach (var cn in result.ColumnNames)
|
||||
{
|
||||
csv.WriteField(cn);
|
||||
}
|
||||
|
||||
await csv.NextRecordAsync();
|
||||
|
||||
foreach (var row in result.Results)
|
||||
{
|
||||
foreach (var field in row)
|
||||
{
|
||||
csv.WriteField(field);
|
||||
}
|
||||
|
||||
await csv.NextRecordAsync();
|
||||
}
|
||||
|
||||
|
||||
await csv.FlushAsync();
|
||||
ms.Position = 0;
|
||||
|
||||
// send the file
|
||||
await ctx.Channel.SendFileAsync(ms, $"query_result_{DateTime.UtcNow.Ticks}.csv");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SqlExec([Leftover] string sql)
|
||||
{
|
||||
try
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithTitle(GetText(strs.sql_confirm_exec))
|
||||
.WithDescription(Format.Code(sql));
|
||||
|
||||
if (!await PromptUserConfirmAsync(embed))
|
||||
return;
|
||||
|
||||
var res = await _service.ExecuteSql(sql);
|
||||
await SendConfirmAsync(res.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await SendErrorAsync(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task PurgeUser(ulong userId)
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString()))));
|
||||
|
||||
if (!await PromptUserConfirmAsync(embed))
|
||||
return;
|
||||
|
||||
await _service.PurgeUserAsync(userId);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task PurgeUser([Leftover] IUser user)
|
||||
=> PurgeUser(user.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,104 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db.Models;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class DangerousCommandsService : INService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
|
||||
public DangerousCommandsService(DbService db)
|
||||
=> _db = db;
|
||||
|
||||
public async Task<int> ExecuteSql(string sql)
|
||||
{
|
||||
int res;
|
||||
await using var uow = _db.GetDbContext();
|
||||
res = await uow.Database.ExecuteSqlRawAsync(sql);
|
||||
return res;
|
||||
}
|
||||
|
||||
public SelectResult SelectSql(string sql)
|
||||
{
|
||||
var result = new SelectResult
|
||||
{
|
||||
ColumnNames = new(),
|
||||
Results = new()
|
||||
};
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conn = uow.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
using var reader = cmd.ExecuteReader();
|
||||
if (reader.HasRows)
|
||||
{
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
result.ColumnNames.Add(reader.GetName(i));
|
||||
while (reader.Read())
|
||||
{
|
||||
var obj = new object[reader.FieldCount];
|
||||
reader.GetValues(obj);
|
||||
result.Results.Add(obj.Select(x => x.ToString()).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task PurgeUserAsync(ulong userId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
// get waifu info
|
||||
var wi = await uow.Set<WaifuInfo>().FirstOrDefaultAsyncEF(x => x.Waifu.UserId == userId);
|
||||
|
||||
// if it exists, delete waifu related things
|
||||
if (wi is not null)
|
||||
{
|
||||
// remove updates which have new or old as this waifu
|
||||
await uow.Set<WaifuUpdate>().DeleteAsync(wu => wu.New.UserId == userId || wu.Old.UserId == userId);
|
||||
|
||||
// delete all items this waifu owns
|
||||
await uow.Set<WaifuItem>().DeleteAsync(x => x.WaifuInfoId == wi.Id);
|
||||
|
||||
// all waifus this waifu claims are released
|
||||
await uow.Set<WaifuInfo>()
|
||||
.AsQueryable()
|
||||
.Where(x => x.Claimer.UserId == userId)
|
||||
.UpdateAsync(x => new()
|
||||
{
|
||||
ClaimerId = null
|
||||
});
|
||||
|
||||
// all affinities set to this waifu are reset
|
||||
await uow.Set<WaifuInfo>()
|
||||
.AsQueryable()
|
||||
.Where(x => x.Affinity.UserId == userId)
|
||||
.UpdateAsync(x => new()
|
||||
{
|
||||
AffinityId = null
|
||||
});
|
||||
}
|
||||
|
||||
// delete guild xp
|
||||
await uow.Set<UserXpStats>().DeleteAsync(x => x.UserId == userId);
|
||||
|
||||
// delete currency transactions
|
||||
await uow.Set<CurrencyTransaction>().DeleteAsync(x => x.UserId == userId);
|
||||
|
||||
// delete user, currency, and clubs go away with it
|
||||
await uow.Set<DiscordUser>().DeleteAsync(u => u.UserId == userId);
|
||||
}
|
||||
|
||||
public class SelectResult
|
||||
{
|
||||
public List<string> ColumnNames { get; set; }
|
||||
public List<string[]> Results { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class GameVoiceChannelCommands : NadekoModule<GameVoiceChannelService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.MoveMembers)]
|
||||
public async Task GameVoiceChannel()
|
||||
{
|
||||
var vch = ((IGuildUser)ctx.User).VoiceChannel;
|
||||
|
||||
if (vch is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_in_voice);
|
||||
return;
|
||||
}
|
||||
|
||||
var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id);
|
||||
|
||||
if (id is null)
|
||||
await ReplyConfirmLocalizedAsync(strs.gvc_disabled);
|
||||
else
|
||||
{
|
||||
_service.GameVoiceChannels.Add(vch.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.gvc_enabled(Format.Bold(vch.Name)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Db;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class GameVoiceChannelService : INService
|
||||
{
|
||||
public ConcurrentHashSet<ulong> GameVoiceChannels { get; }
|
||||
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public GameVoiceChannelService(DiscordSocketClient client, DbService db, IBot bot)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
|
||||
GameVoiceChannels = new(bot.AllGuildConfigs
|
||||
.Where(gc => gc.GameVoiceChannel is not null)
|
||||
.Select(gc => gc.GameVoiceChannel!.Value));
|
||||
|
||||
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
|
||||
_client.PresenceUpdated += OnPresenceUpdate;
|
||||
}
|
||||
|
||||
private Task OnPresenceUpdate(SocketUser socketUser, SocketPresence before, SocketPresence after)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (socketUser is not SocketGuildUser newUser)
|
||||
return;
|
||||
// if the user is in the voice channel and that voice channel is gvc
|
||||
|
||||
if (newUser.VoiceChannel is not { } vc
|
||||
|| !GameVoiceChannels.Contains(vc.Id))
|
||||
return;
|
||||
|
||||
//if the activity has changed, and is a playi1ng activity
|
||||
foreach (var activity in after.Activities)
|
||||
{
|
||||
if (activity is { Type: ActivityType.Playing })
|
||||
//trigger gvc
|
||||
{
|
||||
if (await TriggerGvc(newUser, activity.Name))
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error running GuildMemberUpdated in gvc");
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public ulong? ToggleGameVoiceChannel(ulong guildId, ulong vchId)
|
||||
{
|
||||
ulong? id;
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
if (gc.GameVoiceChannel == vchId)
|
||||
{
|
||||
GameVoiceChannels.TryRemove(vchId);
|
||||
id = gc.GameVoiceChannel = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (gc.GameVoiceChannel is not null)
|
||||
GameVoiceChannels.TryRemove(gc.GameVoiceChannel.Value);
|
||||
GameVoiceChannels.Add(vchId);
|
||||
id = gc.GameVoiceChannel = vchId;
|
||||
}
|
||||
|
||||
uow.SaveChanges();
|
||||
return id;
|
||||
}
|
||||
|
||||
private Task OnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (usr is not SocketGuildUser gUser)
|
||||
return;
|
||||
|
||||
if (newState.VoiceChannel is null)
|
||||
return;
|
||||
|
||||
if (!GameVoiceChannels.Contains(newState.VoiceChannel.Id))
|
||||
return;
|
||||
|
||||
foreach (var game in gUser.Activities.Select(x => x.Name))
|
||||
{
|
||||
if (await TriggerGvc(gUser, game))
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error running VoiceStateUpdate in gvc");
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<bool> TriggerGvc(SocketGuildUser gUser, string game)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(game))
|
||||
return false;
|
||||
|
||||
game = game.TrimTo(50)!.ToLowerInvariant();
|
||||
var vch = gUser.Guild.VoiceChannels.FirstOrDefault(x => x.Name.ToLowerInvariant() == game);
|
||||
|
||||
if (vch is null)
|
||||
return false;
|
||||
|
||||
await Task.Delay(1000);
|
||||
await gUser.ModifyAsync(gu => gu.Channel = vch);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
229
src/NadekoBot/Modules/Administration/GreetBye/GreetCommands.cs
Normal file
229
src/NadekoBot/Modules/Administration/GreetBye/GreetCommands.cs
Normal file
@@ -0,0 +1,229 @@
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class GreetCommands : NadekoModule<GreetService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task Boost()
|
||||
{
|
||||
var enabled = await _service.ToggleBoost(ctx.Guild.Id, ctx.Channel.Id);
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.boost_on);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.boost_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task BoostDel(int timer = 30)
|
||||
{
|
||||
if (timer is < 0 or > 600)
|
||||
return;
|
||||
|
||||
await _service.SetBoostDel(ctx.Guild.Id, timer);
|
||||
|
||||
if (timer > 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.boostdel_on(timer));
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.boostdel_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task BoostMsg([Leftover] string? text = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var boostMessage = _service.GetBoostMessage(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.boostmsg_cur(boostMessage?.SanitizeMentions()));
|
||||
return;
|
||||
}
|
||||
|
||||
var sendBoostEnabled = _service.SetBoostMessage(ctx.Guild.Id, ref text);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.boostmsg_new);
|
||||
if (!sendBoostEnabled)
|
||||
await ReplyPendingLocalizedAsync(strs.boostmsg_enable($"`{prefix}boost`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task GreetDel(int timer = 30)
|
||||
{
|
||||
if (timer is < 0 or > 600)
|
||||
return;
|
||||
|
||||
await _service.SetGreetDel(ctx.Guild.Id, timer);
|
||||
|
||||
if (timer > 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.greetdel_on(timer));
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.greetdel_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task Greet()
|
||||
{
|
||||
var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id);
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.greet_on);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.greet_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task GreetMsg([Leftover] string? text = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var greetMsg = _service.GetGreetMsg(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.greetmsg_cur(greetMsg?.SanitizeMentions()));
|
||||
return;
|
||||
}
|
||||
|
||||
var sendGreetEnabled = _service.SetGreetMessage(ctx.Guild.Id, ref text);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.greetmsg_new);
|
||||
|
||||
if (!sendGreetEnabled)
|
||||
await ReplyPendingLocalizedAsync(strs.greetmsg_enable($"`{prefix}greet`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task GreetDm()
|
||||
{
|
||||
var enabled = await _service.SetGreetDm(ctx.Guild.Id);
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.greetdm_on);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.greetdm_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task GreetDmMsg([Leftover] string? text = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var dmGreetMsg = _service.GetDmGreetMsg(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.greetdmmsg_cur(dmGreetMsg?.SanitizeMentions()));
|
||||
return;
|
||||
}
|
||||
|
||||
var sendGreetEnabled = _service.SetGreetDmMessage(ctx.Guild.Id, ref text);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.greetdmmsg_new);
|
||||
if (!sendGreetEnabled)
|
||||
await ReplyPendingLocalizedAsync(strs.greetdmmsg_enable($"`{prefix}greetdm`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task Bye()
|
||||
{
|
||||
var enabled = await _service.SetBye(ctx.Guild.Id, ctx.Channel.Id);
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.bye_on);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.bye_off);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task ByeMsg([Leftover] string? text = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
var byeMsg = _service.GetByeMessage(ctx.Guild.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.byemsg_cur(byeMsg?.SanitizeMentions()));
|
||||
return;
|
||||
}
|
||||
|
||||
var sendByeEnabled = _service.SetByeMessage(ctx.Guild.Id, ref text);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.byemsg_new);
|
||||
if (!sendByeEnabled)
|
||||
await ReplyPendingLocalizedAsync(strs.byemsg_enable($"`{prefix}bye`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
public async Task ByeDel(int timer = 30)
|
||||
{
|
||||
await _service.SetByeDel(ctx.Guild.Id, timer);
|
||||
|
||||
if (timer > 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.byedel_on(timer));
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.byedel_off);
|
||||
}
|
||||
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
[Ratelimit(5)]
|
||||
public async Task ByeTest([Leftover] IGuildUser? user = null)
|
||||
{
|
||||
user ??= (IGuildUser)ctx.User;
|
||||
|
||||
await _service.ByeTest((ITextChannel)ctx.Channel, user);
|
||||
var enabled = _service.GetByeEnabled(ctx.Guild.Id);
|
||||
if (!enabled)
|
||||
await ReplyPendingLocalizedAsync(strs.byemsg_enable($"`{prefix}bye`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
[Ratelimit(5)]
|
||||
public async Task GreetTest([Leftover] IGuildUser? user = null)
|
||||
{
|
||||
user ??= (IGuildUser)ctx.User;
|
||||
|
||||
await _service.GreetTest((ITextChannel)ctx.Channel, user);
|
||||
var enabled = _service.GetGreetEnabled(ctx.Guild.Id);
|
||||
if (!enabled)
|
||||
await ReplyPendingLocalizedAsync(strs.greetmsg_enable($"`{prefix}greet`"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageGuild)]
|
||||
[Ratelimit(5)]
|
||||
public async Task GreetDmTest([Leftover] IGuildUser? user = null)
|
||||
{
|
||||
user ??= (IGuildUser)ctx.User;
|
||||
|
||||
var success = await _service.GreetDmTest(user);
|
||||
if (success)
|
||||
await ctx.OkAsync();
|
||||
else
|
||||
await ctx.WarningAsync();
|
||||
var enabled = _service.GetGreetDmEnabled(ctx.Guild.Id);
|
||||
if (!enabled)
|
||||
await ReplyPendingLocalizedAsync(strs.greetdmmsg_enable($"`{prefix}greetdm`"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public class GreetGrouper<T>
|
||||
{
|
||||
private readonly Dictionary<ulong, HashSet<T>> _group;
|
||||
private readonly object _locker = new();
|
||||
|
||||
public GreetGrouper()
|
||||
=> _group = new();
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a group, if group already exists, adds the specified user
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the server for which to create group for</param>
|
||||
/// <param name="toAddIfExists">User to add if group already exists</param>
|
||||
/// <returns></returns>
|
||||
public bool CreateOrAdd(ulong guildId, T toAddIfExists)
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
if (_group.TryGetValue(guildId, out var list))
|
||||
{
|
||||
list.Add(toAddIfExists);
|
||||
return false;
|
||||
}
|
||||
|
||||
_group[guildId] = new();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove the specified amount of items from the group. If all items are removed, group will be removed.
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the group</param>
|
||||
/// <param name="count">Maximum number of items to retrieve</param>
|
||||
/// <param name="items">Items retrieved</param>
|
||||
/// <returns>Whether the group has no more items left and is deleted</returns>
|
||||
public bool ClearGroup(ulong guildId, int count, out IReadOnlyCollection<T> items)
|
||||
{
|
||||
lock (_locker)
|
||||
{
|
||||
if (_group.TryGetValue(guildId, out var set))
|
||||
{
|
||||
// if we want more than there are, return everything
|
||||
if (count >= set.Count)
|
||||
{
|
||||
items = set;
|
||||
_group.Remove(guildId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// if there are more in the group than what's needed
|
||||
// take the requested number, remove them from the set
|
||||
// and return them
|
||||
var toReturn = set.TakeWhile(_ => count-- != 0).ToList();
|
||||
foreach (var item in toReturn)
|
||||
set.Remove(item);
|
||||
|
||||
items = toReturn;
|
||||
// returning falsemeans group is not yet deleted
|
||||
// because there are items left
|
||||
return false;
|
||||
}
|
||||
|
||||
items = Array.Empty<T>();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
670
src/NadekoBot/Modules/Administration/GreetBye/GreetService.cs
Normal file
670
src/NadekoBot/Modules/Administration/GreetBye/GreetService.cs
Normal file
@@ -0,0 +1,670 @@
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using System.Threading.Channels;
|
||||
using NadekoBot.Common;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public class GreetService : INService, IReadyExecutor
|
||||
{
|
||||
public bool GroupGreets
|
||||
=> _bss.Data.GroupGreets;
|
||||
|
||||
private readonly DbService _db;
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, GreetSettings> _guildConfigsCache;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
private readonly GreetGrouper<IGuildUser> _greets = new();
|
||||
private readonly GreetGrouper<IUser> _byes = new();
|
||||
private readonly BotConfigService _bss;
|
||||
private readonly IReplacementService _repSvc;
|
||||
|
||||
public GreetService(
|
||||
DiscordSocketClient client,
|
||||
IBot bot,
|
||||
DbService db,
|
||||
BotConfigService bss,
|
||||
IReplacementService repSvc)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
_bss = bss;
|
||||
_repSvc = repSvc;
|
||||
|
||||
_guildConfigsCache = new(bot.AllGuildConfigs.ToDictionary(g => g.GuildId, GreetSettings.Create));
|
||||
|
||||
_client.UserJoined += OnUserJoined;
|
||||
_client.UserLeft += OnUserLeft;
|
||||
|
||||
bot.JoinedGuild += OnBotJoinedGuild;
|
||||
_client.LeftGuild += OnClientLeftGuild;
|
||||
|
||||
_client.GuildMemberUpdated += ClientOnGuildMemberUpdated;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var (conf, user, compl) = await _greetDmQueue.Reader.ReadAsync();
|
||||
var res = await GreetDmUserInternal(conf, user);
|
||||
compl.TrySetResult(res);
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
}
|
||||
|
||||
private Task ClientOnGuildMemberUpdated(Cacheable<SocketGuildUser, ulong> optOldUser, SocketGuildUser newUser)
|
||||
{
|
||||
// if user is a new booster
|
||||
// or boosted again the same server
|
||||
if ((optOldUser.Value is { PremiumSince: null } && newUser is { PremiumSince: not null })
|
||||
|| (optOldUser.Value?.PremiumSince is { } oldDate
|
||||
&& newUser.PremiumSince is { } newDate
|
||||
&& newDate > oldDate))
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(newUser.Guild.Id);
|
||||
if (!conf.SendBoostMessage)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(TriggerBoostMessage(conf, newUser));
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Func<Task> TriggerBoostMessage(GreetSettings conf, SocketGuildUser user)
|
||||
=> async () =>
|
||||
{
|
||||
var channel = user.Guild.GetTextChannel(conf.BoostMessageChannelId);
|
||||
if (channel is null)
|
||||
return;
|
||||
|
||||
await SendBoostMessage(conf, user, channel);
|
||||
};
|
||||
|
||||
private async Task<bool> SendBoostMessage(GreetSettings conf, IGuildUser user, ITextChannel channel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(conf.BoostMessage))
|
||||
return false;
|
||||
|
||||
var toSend = SmartText.CreateFrom(conf.BoostMessage);
|
||||
|
||||
try
|
||||
{
|
||||
var newContent = await _repSvc.ReplaceAsync(toSend,
|
||||
new(client: _client, guild: user.Guild, channel: channel, users: user));
|
||||
var toDelete = await channel.SendAsync(newContent);
|
||||
if (conf.BoostMessageDeleteAfter > 0)
|
||||
toDelete.DeleteAfter(conf.BoostMessageDeleteAfter);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error sending boost message");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private Task OnClientLeftGuild(SocketGuild arg)
|
||||
{
|
||||
_guildConfigsCache.TryRemove(arg.Id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnBotJoinedGuild(GuildConfig gc)
|
||||
{
|
||||
_guildConfigsCache[gc.GuildId] = GreetSettings.Create(gc);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnUserLeft(SocketGuild guild, SocketUser user)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(guild.Id);
|
||||
|
||||
if (!conf.SendChannelByeMessage)
|
||||
return;
|
||||
var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ByeMessageChannelId);
|
||||
|
||||
if (channel is null) //maybe warn the server owner that the channel is missing
|
||||
return;
|
||||
|
||||
if (GroupGreets)
|
||||
{
|
||||
// if group is newly created, greet that user right away,
|
||||
// but any user which joins in the next 5 seconds will
|
||||
// be greeted in a group greet
|
||||
if (_byes.CreateOrAdd(guild.Id, user))
|
||||
{
|
||||
// greet single user
|
||||
await ByeUsers(conf, channel, new[] { user });
|
||||
var groupClear = false;
|
||||
while (!groupClear)
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
groupClear = _byes.ClearGroup(guild.Id, 5, out var toBye);
|
||||
await ByeUsers(conf, channel, toBye);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
await ByeUsers(conf, channel, new[] { user });
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string? GetDmGreetMsg(ulong id)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.GuildConfigsForId(id, set => set).DmGreetMessageText;
|
||||
}
|
||||
|
||||
public string? GetGreetMsg(ulong gid)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.GuildConfigsForId(gid, set => set).ChannelGreetMessageText;
|
||||
}
|
||||
|
||||
public string? GetBoostMessage(ulong gid)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.GuildConfigsForId(gid, set => set).BoostMessage;
|
||||
}
|
||||
|
||||
public GreetSettings GetGreetSettings(ulong gid)
|
||||
{
|
||||
if (_guildConfigsCache.TryGetValue(gid, out var gs))
|
||||
return gs;
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
return GreetSettings.Create(uow.GuildConfigsForId(gid, set => set));
|
||||
}
|
||||
|
||||
private Task ByeUsers(GreetSettings conf, ITextChannel channel, IUser user)
|
||||
=> ByeUsers(conf, channel, new[] { user });
|
||||
|
||||
private async Task ByeUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection<IUser> users)
|
||||
{
|
||||
if (!users.Any())
|
||||
return;
|
||||
|
||||
// var rep = new ReplacementBuilder().WithChannel(channel)
|
||||
// .WithClient(_client)
|
||||
// .WithServer(_client, (SocketGuild)channel.Guild)
|
||||
// .WithManyUsers(users)
|
||||
// .Build();
|
||||
|
||||
var repCtx = new ReplacementContext(client: _client,
|
||||
guild: channel.Guild,
|
||||
channel: channel,
|
||||
users: users.ToArray());
|
||||
|
||||
var text = SmartText.CreateFrom(conf.ChannelByeMessageText);
|
||||
text = await _repSvc.ReplaceAsync(text, repCtx);
|
||||
try
|
||||
{
|
||||
var toDelete = await channel.SendAsync(text);
|
||||
if (conf.AutoDeleteByeMessagesTimer > 0)
|
||||
toDelete.DeleteAfter(conf.AutoDeleteByeMessagesTimer);
|
||||
}
|
||||
catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions ||
|
||||
ex.DiscordCode == DiscordErrorCode.UnknownChannel)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Missing permissions to send a bye message, the bye message will be disabled on server: {GuildId}",
|
||||
channel.GuildId);
|
||||
await SetBye(channel.GuildId, channel.Id, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error embeding bye message");
|
||||
}
|
||||
}
|
||||
|
||||
private Task GreetUsers(GreetSettings conf, ITextChannel channel, IGuildUser user)
|
||||
=> GreetUsers(conf, channel, new[] { user });
|
||||
|
||||
private async Task GreetUsers(GreetSettings conf, ITextChannel channel, IReadOnlyCollection<IGuildUser> users)
|
||||
{
|
||||
if (users.Count == 0)
|
||||
return;
|
||||
|
||||
// var rep = new ReplacementBuilder()
|
||||
// .WithChannel(channel)
|
||||
// .WithClient(_client)
|
||||
// .WithServer(_client, (SocketGuild)channel.Guild)
|
||||
// .WithManyUsers(users)
|
||||
// .Build();
|
||||
|
||||
var repCtx = new ReplacementContext(client: _client,
|
||||
guild: channel.Guild,
|
||||
channel: channel,
|
||||
users: users.ToArray());
|
||||
var text = SmartText.CreateFrom(conf.ChannelGreetMessageText);
|
||||
text = await _repSvc.ReplaceAsync(text, repCtx);
|
||||
try
|
||||
{
|
||||
var toDelete = await channel.SendAsync(text);
|
||||
if (conf.AutoDeleteGreetMessagesTimer > 0)
|
||||
toDelete.DeleteAfter(conf.AutoDeleteGreetMessagesTimer);
|
||||
}
|
||||
catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.InsufficientPermissions ||
|
||||
ex.DiscordCode == DiscordErrorCode.UnknownChannel)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Missing permissions to send a bye message, the greet message will be disabled on server: {GuildId}",
|
||||
channel.GuildId);
|
||||
await SetGreet(channel.GuildId, channel.Id, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error embeding greet message");
|
||||
}
|
||||
}
|
||||
|
||||
private readonly Channel<(GreetSettings, IGuildUser, TaskCompletionSource<bool>)> _greetDmQueue =
|
||||
Channel.CreateBounded<(GreetSettings, IGuildUser, TaskCompletionSource<bool>)>(new BoundedChannelOptions(60)
|
||||
{
|
||||
// The limit of 60 users should be only hit when there's a raid. In that case
|
||||
// probably the best thing to do is to drop newest (raiding) users
|
||||
FullMode = BoundedChannelFullMode.DropNewest
|
||||
});
|
||||
|
||||
private async Task<bool> GreetDmUser(GreetSettings conf, IGuildUser user)
|
||||
{
|
||||
var completionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
await _greetDmQueue.Writer.WriteAsync((conf, user, completionSource));
|
||||
return await completionSource.Task;
|
||||
}
|
||||
|
||||
private async Task<bool> GreetDmUserInternal(GreetSettings conf, IGuildUser user)
|
||||
{
|
||||
try
|
||||
{
|
||||
// var rep = new ReplacementBuilder()
|
||||
// .WithUser(user)
|
||||
// .WithServer(_client, (SocketGuild)user.Guild)
|
||||
// .Build();
|
||||
|
||||
var repCtx = new ReplacementContext(client: _client, guild: user.Guild, users: user);
|
||||
var text = SmartText.CreateFrom(conf.DmGreetMessageText);
|
||||
text = await _repSvc.ReplaceAsync(text, repCtx);
|
||||
|
||||
if (text is SmartPlainText pt)
|
||||
{
|
||||
text = new SmartEmbedText()
|
||||
{
|
||||
Description = pt.Text
|
||||
};
|
||||
}
|
||||
|
||||
if (text is SmartEmbedText set)
|
||||
{
|
||||
text = set with
|
||||
{
|
||||
Footer = CreateFooterSource(user)
|
||||
};
|
||||
}
|
||||
else if (text is SmartEmbedTextArray seta)
|
||||
{
|
||||
// if the greet dm message is a text array
|
||||
var ebElem = seta.Embeds.LastOrDefault();
|
||||
if (ebElem is null)
|
||||
{
|
||||
// if there are no embeds, add an embed with the footer
|
||||
text = seta with
|
||||
{
|
||||
Embeds = new[]
|
||||
{
|
||||
new SmartEmbedArrayElementText()
|
||||
{
|
||||
Footer = CreateFooterSource(user)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// if the maximum amount of embeds is reached, edit the last embed
|
||||
if (seta.Embeds.Length >= 10)
|
||||
{
|
||||
seta.Embeds[^1] = seta.Embeds[^1] with
|
||||
{
|
||||
Footer = CreateFooterSource(user)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// if there is less than 10 embeds, add an embed with footer only
|
||||
seta.Embeds = seta.Embeds.Append(new SmartEmbedArrayElementText()
|
||||
{
|
||||
Footer = CreateFooterSource(user)
|
||||
}).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await user.SendAsync(text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SmartTextEmbedFooter CreateFooterSource(IGuildUser user)
|
||||
=> new()
|
||||
{
|
||||
Text = $"This message was sent from {user.Guild} server.",
|
||||
IconUrl = user.Guild.IconUrl
|
||||
};
|
||||
|
||||
private Task OnUserJoined(IGuildUser user)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(user.GuildId);
|
||||
|
||||
if (conf.SendChannelGreetMessage)
|
||||
{
|
||||
var channel = await user.Guild.GetTextChannelAsync(conf.GreetMessageChannelId);
|
||||
if (channel is not null)
|
||||
{
|
||||
if (GroupGreets)
|
||||
{
|
||||
// if group is newly created, greet that user right away,
|
||||
// but any user which joins in the next 5 seconds will
|
||||
// be greeted in a group greet
|
||||
if (_greets.CreateOrAdd(user.GuildId, user))
|
||||
{
|
||||
// greet single user
|
||||
await GreetUsers(conf, channel, new[] { user });
|
||||
var groupClear = false;
|
||||
while (!groupClear)
|
||||
{
|
||||
await Task.Delay(5000);
|
||||
groupClear = _greets.ClearGroup(user.GuildId, 5, out var toGreet);
|
||||
await GreetUsers(conf, channel, toGreet);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
await GreetUsers(conf, channel, new[] { user });
|
||||
}
|
||||
}
|
||||
|
||||
if (conf.SendDmGreetMessage)
|
||||
await GreetDmUser(conf, user);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string? GetByeMessage(ulong gid)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.GuildConfigsForId(gid, set => set).ChannelByeMessageText;
|
||||
}
|
||||
|
||||
public GreetSettings GetOrAddSettingsForGuild(ulong guildId)
|
||||
{
|
||||
if (_guildConfigsCache.TryGetValue(guildId, out var settings))
|
||||
return settings;
|
||||
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
settings = GreetSettings.Create(gc);
|
||||
}
|
||||
|
||||
_guildConfigsCache.TryAdd(guildId, settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
public async Task<bool> SetGreet(ulong guildId, ulong channelId, bool? value = null)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
var enabled = conf.SendChannelGreetMessage = value ?? !conf.SendChannelGreetMessage;
|
||||
conf.GreetMessageChannelId = channelId;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public bool SetGreetMessage(ulong guildId, ref string message)
|
||||
{
|
||||
message = message.SanitizeMentions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ArgumentNullException(nameof(message));
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.ChannelGreetMessageText = message;
|
||||
var greetMsgEnabled = conf.SendChannelGreetMessage;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache.AddOrUpdate(guildId, toAdd, (_, _) => toAdd);
|
||||
|
||||
uow.SaveChanges();
|
||||
return greetMsgEnabled;
|
||||
}
|
||||
|
||||
public async Task<bool> SetGreetDm(ulong guildId, bool? value = null)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
var enabled = conf.SendDmGreetMessage = value ?? !conf.SendDmGreetMessage;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public bool SetGreetDmMessage(ulong guildId, ref string? message)
|
||||
{
|
||||
message = message?.SanitizeMentions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ArgumentNullException(nameof(message));
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.DmGreetMessageText = message;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
uow.SaveChanges();
|
||||
return conf.SendDmGreetMessage;
|
||||
}
|
||||
|
||||
public async Task<bool> SetBye(ulong guildId, ulong channelId, bool? value = null)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
var enabled = conf.SendChannelByeMessage = value ?? !conf.SendChannelByeMessage;
|
||||
conf.ByeMessageChannelId = channelId;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public bool SetByeMessage(ulong guildId, ref string? message)
|
||||
{
|
||||
message = message?.SanitizeMentions();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
throw new ArgumentNullException(nameof(message));
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.ChannelByeMessageText = message;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
uow.SaveChanges();
|
||||
return conf.SendChannelByeMessage;
|
||||
}
|
||||
|
||||
public async Task SetByeDel(ulong guildId, int timer)
|
||||
{
|
||||
if (timer is < 0 or > 600)
|
||||
return;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.AutoDeleteByeMessagesTimer = timer;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task SetGreetDel(ulong guildId, int timer)
|
||||
{
|
||||
if (timer is < 0 or > 600)
|
||||
return;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.AutoDeleteGreetMessagesTimer = timer;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public bool SetBoostMessage(ulong guildId, ref string message)
|
||||
{
|
||||
message = message.SanitizeMentions();
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.BoostMessage = message;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
uow.SaveChanges();
|
||||
return conf.SendBoostMessage;
|
||||
}
|
||||
|
||||
public async Task SetBoostDel(ulong guildId, int timer)
|
||||
{
|
||||
if (timer is < 0 or > 600)
|
||||
throw new ArgumentOutOfRangeException(nameof(timer));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
conf.BoostMessageDeleteAfter = timer;
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> ToggleBoost(ulong guildId, ulong channelId, bool? forceState = null)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
if (forceState is not bool fs)
|
||||
conf.SendBoostMessage = !conf.SendBoostMessage;
|
||||
else
|
||||
conf.SendBoostMessage = fs;
|
||||
|
||||
conf.BoostMessageChannelId = channelId;
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
var toAdd = GreetSettings.Create(conf);
|
||||
_guildConfigsCache[guildId] = toAdd;
|
||||
return conf.SendBoostMessage;
|
||||
}
|
||||
|
||||
#region Get Enabled Status
|
||||
|
||||
public bool GetGreetDmEnabled(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
return conf.SendDmGreetMessage;
|
||||
}
|
||||
|
||||
public bool GetGreetEnabled(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
return conf.SendChannelGreetMessage;
|
||||
}
|
||||
|
||||
public bool GetByeEnabled(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set);
|
||||
return conf.SendChannelByeMessage;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Test Messages
|
||||
|
||||
public Task ByeTest(ITextChannel channel, IGuildUser user)
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(user.GuildId);
|
||||
return ByeUsers(conf, channel, user);
|
||||
}
|
||||
|
||||
public Task GreetTest(ITextChannel channel, IGuildUser user)
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(user.GuildId);
|
||||
return GreetUsers(conf, channel, user);
|
||||
}
|
||||
|
||||
public Task<bool> GreetDmTest(IGuildUser user)
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(user.GuildId);
|
||||
return GreetDmUser(conf, user);
|
||||
}
|
||||
|
||||
public Task<bool> BoostTest(ITextChannel channel, IGuildUser user)
|
||||
{
|
||||
var conf = GetOrAddSettingsForGuild(user.GuildId);
|
||||
return SendBoostMessage(conf, user, channel);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public class GreetSettings
|
||||
{
|
||||
public int AutoDeleteGreetMessagesTimer { get; set; }
|
||||
public int AutoDeleteByeMessagesTimer { get; set; }
|
||||
|
||||
public ulong GreetMessageChannelId { get; set; }
|
||||
public ulong ByeMessageChannelId { get; set; }
|
||||
|
||||
public bool SendDmGreetMessage { get; set; }
|
||||
public string? DmGreetMessageText { get; set; }
|
||||
|
||||
public bool SendChannelGreetMessage { get; set; }
|
||||
public string? ChannelGreetMessageText { get; set; }
|
||||
|
||||
public bool SendChannelByeMessage { get; set; }
|
||||
public string? ChannelByeMessageText { get; set; }
|
||||
|
||||
public bool SendBoostMessage { get; set; }
|
||||
public string? BoostMessage { get; set; }
|
||||
public int BoostMessageDeleteAfter { get; set; }
|
||||
public ulong BoostMessageChannelId { get; set; }
|
||||
|
||||
public static GreetSettings Create(GuildConfig g)
|
||||
=> new()
|
||||
{
|
||||
AutoDeleteByeMessagesTimer = g.AutoDeleteByeMessagesTimer,
|
||||
AutoDeleteGreetMessagesTimer = g.AutoDeleteGreetMessagesTimer,
|
||||
GreetMessageChannelId = g.GreetMessageChannelId,
|
||||
ByeMessageChannelId = g.ByeMessageChannelId,
|
||||
SendDmGreetMessage = g.SendDmGreetMessage,
|
||||
DmGreetMessageText = g.DmGreetMessageText,
|
||||
SendChannelGreetMessage = g.SendChannelGreetMessage,
|
||||
ChannelGreetMessageText = g.ChannelGreetMessageText,
|
||||
SendChannelByeMessage = g.SendChannelByeMessage,
|
||||
ChannelByeMessageText = g.ChannelByeMessageText,
|
||||
SendBoostMessage = g.SendBoostMessage,
|
||||
BoostMessage = g.BoostMessage,
|
||||
BoostMessageDeleteAfter = g.BoostMessageDeleteAfter,
|
||||
BoostMessageChannelId = g.BoostMessageChannelId
|
||||
};
|
||||
}
|
||||
235
src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs
Normal file
235
src/NadekoBot/Modules/Administration/ImageOnlyChannelService.cs
Normal file
@@ -0,0 +1,235 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using System.Net;
|
||||
using System.Threading.Channels;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class SomethingOnlyChannelService : IExecOnMessage
|
||||
{
|
||||
public int Priority { get; } = 0;
|
||||
private readonly IMemoryCache _ticketCache;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly DbService _db;
|
||||
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _imageOnly;
|
||||
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _linkOnly;
|
||||
|
||||
private readonly Channel<IUserMessage> _deleteQueue = Channel.CreateBounded<IUserMessage>(
|
||||
new BoundedChannelOptions(100)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
|
||||
public SomethingOnlyChannelService(IMemoryCache ticketCache, DiscordSocketClient client, DbService db)
|
||||
{
|
||||
_ticketCache = ticketCache;
|
||||
_client = client;
|
||||
_db = db;
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
_imageOnly = uow.Set<ImageOnlyChannel>()
|
||||
.Where(x => x.Type == OnlyChannelType.Image)
|
||||
.ToList()
|
||||
.GroupBy(x => x.GuildId)
|
||||
.ToDictionary(x => x.Key, x => new ConcurrentHashSet<ulong>(x.Select(y => y.ChannelId)))
|
||||
.ToConcurrent();
|
||||
|
||||
_linkOnly = uow.Set<ImageOnlyChannel>()
|
||||
.Where(x => x.Type == OnlyChannelType.Link)
|
||||
.ToList()
|
||||
.GroupBy(x => x.GuildId)
|
||||
.ToDictionary(x => x.Key, x => new ConcurrentHashSet<ulong>(x.Select(y => y.ChannelId)))
|
||||
.ToConcurrent();
|
||||
|
||||
_ = Task.Run(DeleteQueueRunner);
|
||||
|
||||
_client.ChannelDestroyed += ClientOnChannelDestroyed;
|
||||
}
|
||||
|
||||
private async Task ClientOnChannelDestroyed(SocketChannel ch)
|
||||
{
|
||||
if (ch is not IGuildChannel gch)
|
||||
return;
|
||||
|
||||
if (_imageOnly.TryGetValue(gch.GuildId, out var channels) && channels.TryRemove(ch.Id))
|
||||
await ToggleImageOnlyChannelAsync(gch.GuildId, ch.Id, true);
|
||||
}
|
||||
|
||||
private async Task DeleteQueueRunner()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var toDelete = await _deleteQueue.Reader.ReadAsync();
|
||||
try
|
||||
{
|
||||
await toDelete.DeleteAsync();
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
// disable if bot can't delete messages in the channel
|
||||
await ToggleImageOnlyChannelAsync(((ITextChannel)toDelete.Channel).GuildId, toDelete.Channel.Id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ToggleImageOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false)
|
||||
{
|
||||
var newState = false;
|
||||
await using var uow = _db.GetDbContext();
|
||||
if (forceDisable || (_imageOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId)))
|
||||
{
|
||||
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Image);
|
||||
}
|
||||
else
|
||||
{
|
||||
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId);
|
||||
uow.Set<ImageOnlyChannel>().Add(new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
ChannelId = channelId,
|
||||
Type = OnlyChannelType.Image
|
||||
});
|
||||
|
||||
if (_linkOnly.TryGetValue(guildId, out var chs))
|
||||
chs.TryRemove(channelId);
|
||||
|
||||
channels = _imageOnly.GetOrAdd(guildId, new ConcurrentHashSet<ulong>());
|
||||
channels.Add(channelId);
|
||||
newState = true;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return newState;
|
||||
}
|
||||
|
||||
public async Task<bool> ToggleLinkOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false)
|
||||
{
|
||||
var newState = false;
|
||||
await using var uow = _db.GetDbContext();
|
||||
if (forceDisable || (_linkOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId)))
|
||||
{
|
||||
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Link);
|
||||
}
|
||||
else
|
||||
{
|
||||
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId);
|
||||
uow.Set<ImageOnlyChannel>().Add(new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
ChannelId = channelId,
|
||||
Type = OnlyChannelType.Link
|
||||
});
|
||||
|
||||
if (_imageOnly.TryGetValue(guildId, out var chs))
|
||||
chs.TryRemove(channelId);
|
||||
|
||||
channels = _linkOnly.GetOrAdd(guildId, new ConcurrentHashSet<ulong>());
|
||||
channels.Add(channelId);
|
||||
newState = true;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return newState;
|
||||
}
|
||||
|
||||
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg)
|
||||
{
|
||||
if (msg.Channel is not ITextChannel tch)
|
||||
return false;
|
||||
|
||||
if (_imageOnly.TryGetValue(tch.GuildId, out var chs) && chs.Contains(msg.Channel.Id))
|
||||
return await HandleOnlyChannel(tch, msg, OnlyChannelType.Image);
|
||||
|
||||
if (_linkOnly.TryGetValue(tch.GuildId, out chs) && chs.Contains(msg.Channel.Id))
|
||||
return await HandleOnlyChannel(tch, msg, OnlyChannelType.Link);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<bool> HandleOnlyChannel(ITextChannel tch, IUserMessage msg, OnlyChannelType type)
|
||||
{
|
||||
if (type == OnlyChannelType.Image)
|
||||
{
|
||||
if (msg.Attachments.Any(x => x is { Height: > 0, Width: > 0 }))
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (msg.Content.TryGetUrlPath(out _))
|
||||
return false;
|
||||
}
|
||||
|
||||
var user = await tch.Guild.GetUserAsync(msg.Author.Id)
|
||||
?? await _client.Rest.GetGuildUserAsync(tch.GuildId, msg.Author.Id);
|
||||
|
||||
if (user is null)
|
||||
return false;
|
||||
|
||||
// ignore owner and admin
|
||||
if (user.Id == tch.Guild.OwnerId || user.GuildPermissions.Administrator)
|
||||
{
|
||||
Log.Information("{Type}-Only Channel: Ignoring owner od admin ({ChannelId})", type, msg.Channel.Id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ignore users higher in hierarchy
|
||||
var botUser = await tch.Guild.GetCurrentUserAsync();
|
||||
if (user.GetRoles().Max(x => x.Position) >= botUser.GetRoles().Max(x => x.Position))
|
||||
return false;
|
||||
|
||||
if (!botUser.GetPermissions(tch).ManageChannel)
|
||||
{
|
||||
if(type == OnlyChannelType.Image)
|
||||
await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true);
|
||||
else
|
||||
await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
var shouldLock = AddUserTicket(tch.GuildId, msg.Author.Id);
|
||||
if (shouldLock)
|
||||
{
|
||||
await tch.AddPermissionOverwriteAsync(msg.Author, new(sendMessages: PermValue.Deny));
|
||||
Log.Warning("{Type}-Only Channel: User {User} [{UserId}] has been banned from typing in the channel [{ChannelId}]",
|
||||
type,
|
||||
msg.Author,
|
||||
msg.Author.Id,
|
||||
msg.Channel.Id);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _deleteQueue.Writer.WriteAsync(msg);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error deleting message {MessageId} in image-only channel {ChannelId}", msg.Id, tch.Id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool AddUserTicket(ulong guildId, ulong userId)
|
||||
{
|
||||
var old = _ticketCache.GetOrCreate($"{guildId}_{userId}",
|
||||
entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1);
|
||||
return 0;
|
||||
});
|
||||
|
||||
_ticketCache.Set($"{guildId}_{userId}", ++old);
|
||||
|
||||
// if this is the third time that the user posts a
|
||||
// non image in an image-only channel on this server
|
||||
return old > 2;
|
||||
}
|
||||
}
|
||||
253
src/NadekoBot/Modules/Administration/LocalizationCommands.cs
Normal file
253
src/NadekoBot/Modules/Administration/LocalizationCommands.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
#nullable disable
|
||||
using System.Globalization;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class LocalizationCommands : NadekoModule
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> _supportedLocales = new Dictionary<string, string>
|
||||
{
|
||||
{ "ar", "العربية" },
|
||||
{ "zh-TW", "繁體中文, 台灣" },
|
||||
{ "zh-CN", "简体中文, 中华人民共和国" },
|
||||
{ "nl-NL", "Nederlands, Nederland" },
|
||||
{ "en-US", "English, United States" },
|
||||
{ "fr-FR", "Français, France" },
|
||||
{ "cs-CZ", "Čeština, Česká republika" },
|
||||
{ "da-DK", "Dansk, Danmark" },
|
||||
{ "de-DE", "Deutsch, Deutschland" },
|
||||
{ "he-IL", "עברית, ישראל" },
|
||||
{ "hu-HU", "Magyar, Magyarország" },
|
||||
{ "id-ID", "Bahasa Indonesia, Indonesia" },
|
||||
{ "it-IT", "Italiano, Italia" },
|
||||
{ "ja-JP", "日本語, 日本" },
|
||||
{ "ko-KR", "한국어, 대한민국" },
|
||||
{ "nb-NO", "Norsk, Norge" },
|
||||
{ "pl-PL", "Polski, Polska" },
|
||||
{ "pt-BR", "Português Brasileiro, Brasil" },
|
||||
{ "ro-RO", "Română, România" },
|
||||
{ "ru-RU", "Русский, Россия" },
|
||||
{ "sr-Cyrl-RS", "Српски, Србија" },
|
||||
{ "es-ES", "Español, España" },
|
||||
{ "sv-SE", "Svenska, Sverige" },
|
||||
{ "tr-TR", "Türkçe, Türkiye" },
|
||||
{ "ts-TS", "Tsundere, You Baka" },
|
||||
{ "uk-UA", "Українська, Україна" }
|
||||
};
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public async Task LanguageSet()
|
||||
=> await ReplyConfirmLocalizedAsync(strs.lang_set_show(Format.Bold(Culture.ToString()),
|
||||
Format.Bold(Culture.NativeName)));
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(1)]
|
||||
public async Task LanguageSet(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
CultureInfo ci;
|
||||
if (name.Trim().ToLowerInvariant() == "default")
|
||||
{
|
||||
_localization.RemoveGuildCulture(ctx.Guild);
|
||||
ci = _localization.DefaultCultureInfo;
|
||||
}
|
||||
else
|
||||
{
|
||||
ci = new(name);
|
||||
_localization.SetGuildCulture(ctx.Guild, ci);
|
||||
}
|
||||
|
||||
var nativeName = ci.NativeName;
|
||||
if (ci.Name == "ts-TS")
|
||||
nativeName = _supportedLocales[ci.Name];
|
||||
await ReplyConfirmLocalizedAsync(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(nativeName)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.lang_set_fail);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task LanguageSetDefault()
|
||||
{
|
||||
var cul = _localization.DefaultCultureInfo;
|
||||
await ReplyErrorLocalizedAsync(strs.lang_set_bot_show(cul, cul.NativeName));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task LanguageSetDefault(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
CultureInfo ci;
|
||||
if (name.Trim().ToLowerInvariant() == "default")
|
||||
{
|
||||
_localization.ResetDefaultCulture();
|
||||
ci = _localization.DefaultCultureInfo;
|
||||
}
|
||||
else
|
||||
{
|
||||
ci = new(name);
|
||||
_localization.SetDefaultCulture(ci);
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.lang_set_bot(Format.Bold(ci.ToString()),
|
||||
Format.Bold(ci.NativeName)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.lang_set_fail);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task LanguagesList()
|
||||
=> await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.lang_list))
|
||||
.WithDescription(string.Join("\n",
|
||||
_supportedLocales.Select(
|
||||
x => $"{Format.Code(x.Key),-10} => {x.Value}"))));
|
||||
}
|
||||
}
|
||||
/* list of language codes for reference.
|
||||
* taken from https://github.com/dotnet/coreclr/blob/ee5862c6a257e60e263537d975ab6c513179d47f/src/mscorlib/src/System/Globalization/CultureData.cs#L192
|
||||
{ "029", "en-029" },
|
||||
{ "AE", "ar-AE" },
|
||||
{ "AF", "prs-AF" },
|
||||
{ "AL", "sq-AL" },
|
||||
{ "AM", "hy-AM" },
|
||||
{ "AR", "es-AR" },
|
||||
{ "AT", "de-AT" },
|
||||
{ "AU", "en-AU" },
|
||||
{ "AZ", "az-Cyrl-AZ" },
|
||||
{ "BA", "bs-Latn-BA" },
|
||||
{ "BD", "bn-BD" },
|
||||
{ "BE", "nl-BE" },
|
||||
{ "BG", "bg-BG" },
|
||||
{ "BH", "ar-BH" },
|
||||
{ "BN", "ms-BN" },
|
||||
{ "BO", "es-BO" },
|
||||
{ "BR", "pt-BR" },
|
||||
{ "BY", "be-BY" },
|
||||
{ "BZ", "en-BZ" },
|
||||
{ "CA", "en-CA" },
|
||||
{ "CH", "it-CH" },
|
||||
{ "CL", "es-CL" },
|
||||
{ "CN", "zh-CN" },
|
||||
{ "CO", "es-CO" },
|
||||
{ "CR", "es-CR" },
|
||||
{ "CS", "sr-Cyrl-CS" },
|
||||
{ "CZ", "cs-CZ" },
|
||||
{ "DE", "de-DE" },
|
||||
{ "DK", "da-DK" },
|
||||
{ "DO", "es-DO" },
|
||||
{ "DZ", "ar-DZ" },
|
||||
{ "EC", "es-EC" },
|
||||
{ "EE", "et-EE" },
|
||||
{ "EG", "ar-EG" },
|
||||
{ "ES", "es-ES" },
|
||||
{ "ET", "am-ET" },
|
||||
{ "FI", "fi-FI" },
|
||||
{ "FO", "fo-FO" },
|
||||
{ "FR", "fr-FR" },
|
||||
{ "GB", "en-GB" },
|
||||
{ "GE", "ka-GE" },
|
||||
{ "GL", "kl-GL" },
|
||||
{ "GR", "el-GR" },
|
||||
{ "GT", "es-GT" },
|
||||
{ "HK", "zh-HK" },
|
||||
{ "HN", "es-HN" },
|
||||
{ "HR", "hr-HR" },
|
||||
{ "HU", "hu-HU" },
|
||||
{ "ID", "id-ID" },
|
||||
{ "IE", "en-IE" },
|
||||
{ "IL", "he-IL" },
|
||||
{ "IN", "hi-IN" },
|
||||
{ "IQ", "ar-IQ" },
|
||||
{ "IR", "fa-IR" },
|
||||
{ "IS", "is-IS" },
|
||||
{ "IT", "it-IT" },
|
||||
{ "IV", "" },
|
||||
{ "JM", "en-JM" },
|
||||
{ "JO", "ar-JO" },
|
||||
{ "JP", "ja-JP" },
|
||||
{ "KE", "sw-KE" },
|
||||
{ "KG", "ky-KG" },
|
||||
{ "KH", "km-KH" },
|
||||
{ "KR", "ko-KR" },
|
||||
{ "KW", "ar-KW" },
|
||||
{ "KZ", "kk-KZ" },
|
||||
{ "LA", "lo-LA" },
|
||||
{ "LB", "ar-LB" },
|
||||
{ "LI", "de-LI" },
|
||||
{ "LK", "si-LK" },
|
||||
{ "LT", "lt-LT" },
|
||||
{ "LU", "lb-LU" },
|
||||
{ "LV", "lv-LV" },
|
||||
{ "LY", "ar-LY" },
|
||||
{ "MA", "ar-MA" },
|
||||
{ "MC", "fr-MC" },
|
||||
{ "ME", "sr-Latn-ME" },
|
||||
{ "MK", "mk-MK" },
|
||||
{ "MN", "mn-MN" },
|
||||
{ "MO", "zh-MO" },
|
||||
{ "MT", "mt-MT" },
|
||||
{ "MV", "dv-MV" },
|
||||
{ "MX", "es-MX" },
|
||||
{ "MY", "ms-MY" },
|
||||
{ "NG", "ig-NG" },
|
||||
{ "NI", "es-NI" },
|
||||
{ "NL", "nl-NL" },
|
||||
{ "NO", "nn-NO" },
|
||||
{ "NP", "ne-NP" },
|
||||
{ "NZ", "en-NZ" },
|
||||
{ "OM", "ar-OM" },
|
||||
{ "PA", "es-PA" },
|
||||
{ "PE", "es-PE" },
|
||||
{ "PH", "en-PH" },
|
||||
{ "PK", "ur-PK" },
|
||||
{ "PL", "pl-PL" },
|
||||
{ "PR", "es-PR" },
|
||||
{ "PT", "pt-PT" },
|
||||
{ "PY", "es-PY" },
|
||||
{ "QA", "ar-QA" },
|
||||
{ "RO", "ro-RO" },
|
||||
{ "RS", "sr-Latn-RS" },
|
||||
{ "RU", "ru-RU" },
|
||||
{ "RW", "rw-RW" },
|
||||
{ "SA", "ar-SA" },
|
||||
{ "SE", "sv-SE" },
|
||||
{ "SG", "zh-SG" },
|
||||
{ "SI", "sl-SI" },
|
||||
{ "SK", "sk-SK" },
|
||||
{ "SN", "wo-SN" },
|
||||
{ "SV", "es-SV" },
|
||||
{ "SY", "ar-SY" },
|
||||
{ "TH", "th-TH" },
|
||||
{ "TJ", "tg-Cyrl-TJ" },
|
||||
{ "TM", "tk-TM" },
|
||||
{ "TN", "ar-TN" },
|
||||
{ "TR", "tr-TR" },
|
||||
{ "TT", "en-TT" },
|
||||
{ "TW", "zh-TW" },
|
||||
{ "UA", "uk-UA" },
|
||||
{ "US", "en-US" },
|
||||
{ "UY", "es-UY" },
|
||||
{ "UZ", "uz-Cyrl-UZ" },
|
||||
{ "VE", "es-VE" },
|
||||
{ "VN", "vi-VN" },
|
||||
{ "YE", "ar-YE" },
|
||||
{ "ZA", "af-ZA" },
|
||||
{ "ZW", "en-ZW" }
|
||||
*/
|
||||
231
src/NadekoBot/Modules/Administration/Mute/MuteCommands.cs
Normal file
231
src/NadekoBot/Modules/Administration/Mute/MuteCommands.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class MuteCommands : NadekoModule<MuteService>
|
||||
{
|
||||
private async Task<bool> VerifyMutePermissions(IGuildUser runnerUser, IGuildUser targetUser)
|
||||
{
|
||||
var runnerUserRoles = runnerUser.GetRoles();
|
||||
var targetUserRoles = targetUser.GetRoles();
|
||||
if (runnerUser.Id != ctx.Guild.OwnerId
|
||||
&& runnerUserRoles.Max(x => x.Position) <= targetUserRoles.Max(x => x.Position))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_perms);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
public async Task MuteRole([Leftover] IRole role = null)
|
||||
{
|
||||
if (role is null)
|
||||
{
|
||||
var muteRole = await _service.GetMuteRole(ctx.Guild);
|
||||
await ReplyConfirmLocalizedAsync(strs.mute_role(Format.Code(muteRole.Name)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.User.Id != ctx.Guild.OwnerId
|
||||
&& role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_u);
|
||||
return;
|
||||
}
|
||||
|
||||
await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.mute_role_set);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
|
||||
[Priority(0)]
|
||||
public async Task Mute(IGuildUser target, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, target))
|
||||
return;
|
||||
|
||||
await _service.MuteUser(target, ctx.User, reason: reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_muted(Format.Bold(target.ToString())));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Exception in the mute command");
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
|
||||
[Priority(1)]
|
||||
public async Task Mute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
|
||||
return;
|
||||
|
||||
await _service.TimedMute(user, ctx.User, time.Time, reason: reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_muted_time(Format.Bold(user.ToString()),
|
||||
(int)time.Time.TotalMinutes));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in mute command");
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
|
||||
public async Task Unmute(IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, reason: reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_unmuted(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(0)]
|
||||
public async Task ChatMute(IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
|
||||
return;
|
||||
|
||||
await _service.MuteUser(user, ctx.User, MuteType.Chat, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_chat_mute(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Exception in the chatmute command");
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(1)]
|
||||
public async Task ChatMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
|
||||
return;
|
||||
|
||||
await _service.TimedMute(user, ctx.User, time.Time, MuteType.Chat, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_chat_mute_time(Format.Bold(user.ToString()),
|
||||
(int)time.Time.TotalMinutes));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in chatmute command");
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ChatUnmute(IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
await _service.UnmuteUser(user.Guild.Id, user.Id, ctx.User, MuteType.Chat, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_chat_unmute(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.MuteMembers)]
|
||||
[Priority(0)]
|
||||
public async Task VoiceMute(IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
|
||||
return;
|
||||
|
||||
await _service.MuteUser(user, ctx.User, MuteType.Voice, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_voice_mute(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.MuteMembers)]
|
||||
[Priority(1)]
|
||||
public async Task VoiceMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
|
||||
return;
|
||||
|
||||
await _service.TimedMute(user, ctx.User, time.Time, MuteType.Voice, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_voice_mute_time(Format.Bold(user.ToString()),
|
||||
(int)time.Time.TotalMinutes));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.MuteMembers)]
|
||||
public async Task VoiceUnmute(IGuildUser user, [Leftover] string reason = "")
|
||||
{
|
||||
try
|
||||
{
|
||||
await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, MuteType.Voice, reason);
|
||||
await ReplyConfirmLocalizedAsync(strs.user_voice_unmute(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.mute_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
505
src/NadekoBot/Modules/Administration/Mute/MuteService.cs
Normal file
505
src/NadekoBot/Modules/Administration/Mute/MuteService.cs
Normal file
@@ -0,0 +1,505 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public enum MuteType
|
||||
{
|
||||
Voice,
|
||||
Chat,
|
||||
All
|
||||
}
|
||||
|
||||
public class MuteService : INService
|
||||
{
|
||||
public enum TimerType { Mute, Ban, AddRole }
|
||||
|
||||
private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny,
|
||||
sendMessages: PermValue.Deny,
|
||||
sendMessagesInThreads: PermValue.Deny,
|
||||
attachFiles: PermValue.Deny);
|
||||
|
||||
public event Action<IGuildUser, IUser, MuteType, string> UserMuted = delegate { };
|
||||
public event Action<IGuildUser, IUser, MuteType, string> UserUnmuted = delegate { };
|
||||
|
||||
public ConcurrentDictionary<ulong, string> GuildMuteRoles { get; }
|
||||
public ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> MutedUsers { get; }
|
||||
|
||||
public ConcurrentDictionary<ulong, ConcurrentDictionary<(ulong, TimerType), Timer>> UnTimers { get; } = new();
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly DbService _db;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
|
||||
public MuteService(DiscordSocketClient client, DbService db, IEmbedBuilderService eb)
|
||||
{
|
||||
_client = client;
|
||||
_db = db;
|
||||
_eb = eb;
|
||||
|
||||
using (var uow = db.GetDbContext())
|
||||
{
|
||||
var guildIds = client.Guilds.Select(x => x.Id).ToList();
|
||||
var configs = uow.Set<GuildConfig>()
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(x => x.MutedUsers)
|
||||
.Include(x => x.UnbanTimer)
|
||||
.Include(x => x.UnmuteTimers)
|
||||
.Include(x => x.UnroleTimer)
|
||||
.Where(x => guildIds.Contains(x.GuildId))
|
||||
.ToList();
|
||||
|
||||
GuildMuteRoles = configs.Where(c => !string.IsNullOrWhiteSpace(c.MuteRoleName))
|
||||
.ToDictionary(c => c.GuildId, c => c.MuteRoleName)
|
||||
.ToConcurrent();
|
||||
|
||||
MutedUsers = new(configs.ToDictionary(k => k.GuildId,
|
||||
v => new ConcurrentHashSet<ulong>(v.MutedUsers.Select(m => m.UserId))));
|
||||
|
||||
var max = TimeSpan.FromDays(49);
|
||||
|
||||
foreach (var conf in configs)
|
||||
{
|
||||
foreach (var x in conf.UnmuteTimers)
|
||||
{
|
||||
TimeSpan after;
|
||||
if (x.UnmuteAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
|
||||
after = TimeSpan.FromMinutes(2);
|
||||
else
|
||||
{
|
||||
var unmute = x.UnmuteAt - DateTime.UtcNow;
|
||||
after = unmute > max ? max : unmute;
|
||||
}
|
||||
|
||||
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Mute);
|
||||
}
|
||||
|
||||
foreach (var x in conf.UnbanTimer)
|
||||
{
|
||||
TimeSpan after;
|
||||
if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
|
||||
after = TimeSpan.FromMinutes(2);
|
||||
else
|
||||
{
|
||||
var unban = x.UnbanAt - DateTime.UtcNow;
|
||||
after = unban > max ? max : unban;
|
||||
}
|
||||
|
||||
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Ban);
|
||||
}
|
||||
|
||||
foreach (var x in conf.UnroleTimer)
|
||||
{
|
||||
TimeSpan after;
|
||||
if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
|
||||
after = TimeSpan.FromMinutes(2);
|
||||
else
|
||||
{
|
||||
var unban = x.UnbanAt - DateTime.UtcNow;
|
||||
after = unban > max ? max : unban;
|
||||
}
|
||||
|
||||
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.AddRole, x.RoleId);
|
||||
}
|
||||
}
|
||||
|
||||
_client.UserJoined += Client_UserJoined;
|
||||
}
|
||||
|
||||
UserMuted += OnUserMuted;
|
||||
UserUnmuted += OnUserUnmuted;
|
||||
}
|
||||
|
||||
private void OnUserMuted(
|
||||
IGuildUser user,
|
||||
IUser mod,
|
||||
MuteType type,
|
||||
string reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
return;
|
||||
|
||||
_ = Task.Run(() => user.SendMessageAsync(embed: _eb.Create()
|
||||
.WithDescription(
|
||||
$"You've been muted in {user.Guild} server")
|
||||
.AddField("Mute Type", type.ToString())
|
||||
.AddField("Moderator", mod.ToString())
|
||||
.AddField("Reason", reason)
|
||||
.Build()));
|
||||
}
|
||||
|
||||
private void OnUserUnmuted(
|
||||
IGuildUser user,
|
||||
IUser mod,
|
||||
MuteType type,
|
||||
string reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
return;
|
||||
|
||||
_ = Task.Run(() => user.SendMessageAsync(embed: _eb.Create()
|
||||
.WithDescription(
|
||||
$"You've been unmuted in {user.Guild} server")
|
||||
.AddField("Unmute Type", type.ToString())
|
||||
.AddField("Moderator", mod.ToString())
|
||||
.AddField("Reason", reason)
|
||||
.Build()));
|
||||
}
|
||||
|
||||
private Task Client_UserJoined(IGuildUser usr)
|
||||
{
|
||||
try
|
||||
{
|
||||
MutedUsers.TryGetValue(usr.Guild.Id, out var muted);
|
||||
|
||||
if (muted is null || !muted.Contains(usr.Id))
|
||||
return Task.CompletedTask;
|
||||
_ = Task.Run(() => MuteUser(usr, _client.CurrentUser, reason: "Sticky mute"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in MuteService UserJoined event");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task SetMuteRoleAsync(ulong guildId, string name)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var config = uow.GuildConfigsForId(guildId, set => set);
|
||||
config.MuteRoleName = name;
|
||||
GuildMuteRoles.AddOrUpdate(guildId, name, (_, _) => name);
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task MuteUser(
|
||||
IGuildUser usr,
|
||||
IUser mod,
|
||||
MuteType type = MuteType.All,
|
||||
string reason = "")
|
||||
{
|
||||
if (type == MuteType.All)
|
||||
{
|
||||
try { await usr.ModifyAsync(x => x.Mute = true); }
|
||||
catch { }
|
||||
|
||||
var muteRole = await GetMuteRole(usr.Guild);
|
||||
if (!usr.RoleIds.Contains(muteRole.Id))
|
||||
await usr.AddRoleAsync(muteRole);
|
||||
StopTimer(usr.GuildId, usr.Id, TimerType.Mute);
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(usr.Guild.Id,
|
||||
set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers));
|
||||
config.MutedUsers.Add(new()
|
||||
{
|
||||
UserId = usr.Id
|
||||
});
|
||||
if (MutedUsers.TryGetValue(usr.Guild.Id, out var muted))
|
||||
muted.Add(usr.Id);
|
||||
|
||||
config.UnmuteTimers.RemoveWhere(x => x.UserId == usr.Id);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
UserMuted(usr, mod, MuteType.All, reason);
|
||||
}
|
||||
else if (type == MuteType.Voice)
|
||||
{
|
||||
try
|
||||
{
|
||||
await usr.ModifyAsync(x => x.Mute = true);
|
||||
UserMuted(usr, mod, MuteType.Voice, reason);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else if (type == MuteType.Chat)
|
||||
{
|
||||
await usr.AddRoleAsync(await GetMuteRole(usr.Guild));
|
||||
UserMuted(usr, mod, MuteType.Chat, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnmuteUser(
|
||||
ulong guildId,
|
||||
ulong usrId,
|
||||
IUser mod,
|
||||
MuteType type = MuteType.All,
|
||||
string reason = "")
|
||||
{
|
||||
var usr = _client.GetGuild(guildId)?.GetUser(usrId);
|
||||
if (type == MuteType.All)
|
||||
{
|
||||
StopTimer(guildId, usrId, TimerType.Mute);
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(guildId,
|
||||
set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers));
|
||||
var match = new MutedUserId
|
||||
{
|
||||
UserId = usrId
|
||||
};
|
||||
var toRemove = config.MutedUsers.FirstOrDefault(x => x.Equals(match));
|
||||
if (toRemove is not null)
|
||||
uow.Remove(toRemove);
|
||||
if (MutedUsers.TryGetValue(guildId, out var muted))
|
||||
muted.TryRemove(usrId);
|
||||
|
||||
config.UnmuteTimers.RemoveWhere(x => x.UserId == usrId);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
if (usr is not null)
|
||||
{
|
||||
try { await usr.ModifyAsync(x => x.Mute = false); }
|
||||
catch { }
|
||||
|
||||
try { await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); }
|
||||
catch
|
||||
{
|
||||
/*ignore*/
|
||||
}
|
||||
|
||||
UserUnmuted(usr, mod, MuteType.All, reason);
|
||||
}
|
||||
}
|
||||
else if (type == MuteType.Voice)
|
||||
{
|
||||
if (usr is null)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await usr.ModifyAsync(x => x.Mute = false);
|
||||
UserUnmuted(usr, mod, MuteType.Voice, reason);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else if (type == MuteType.Chat)
|
||||
{
|
||||
if (usr is null)
|
||||
return;
|
||||
await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild));
|
||||
UserUnmuted(usr, mod, MuteType.Chat, reason);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IRole> GetMuteRole(IGuild guild)
|
||||
{
|
||||
if (guild is null)
|
||||
throw new ArgumentNullException(nameof(guild));
|
||||
|
||||
const string defaultMuteRoleName = "nadeko-mute";
|
||||
|
||||
var muteRoleName = GuildMuteRoles.GetOrAdd(guild.Id, defaultMuteRoleName);
|
||||
|
||||
var muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName);
|
||||
if (muteRole is null)
|
||||
//if it doesn't exist, create it
|
||||
{
|
||||
try { muteRole = await guild.CreateRoleAsync(muteRoleName, isMentionable: false); }
|
||||
catch
|
||||
{
|
||||
//if creations fails, maybe the name is not correct, find default one, if doesn't work, create default one
|
||||
muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName)
|
||||
?? await guild.CreateRoleAsync(defaultMuteRoleName, isMentionable: false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var toOverwrite in await guild.GetTextChannelsAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id
|
||||
&& x.TargetType == PermissionTarget.Role))
|
||||
{
|
||||
await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite);
|
||||
|
||||
await Task.Delay(200);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
return muteRole;
|
||||
}
|
||||
|
||||
public async Task TimedMute(
|
||||
IGuildUser user,
|
||||
IUser mod,
|
||||
TimeSpan after,
|
||||
MuteType muteType = MuteType.All,
|
||||
string reason = "")
|
||||
{
|
||||
await MuteUser(user, mod, muteType, reason); // mute the user. This will also remove any previous unmute timers
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnmuteTimers));
|
||||
config.UnmuteTimers.Add(new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
UnmuteAt = DateTime.UtcNow + after
|
||||
}); // add teh unmute timer to the database
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
StartUn_Timer(user.GuildId, user.Id, after, TimerType.Mute); // start the timer
|
||||
}
|
||||
|
||||
public async Task TimedBan(
|
||||
IGuild guild,
|
||||
ulong userId,
|
||||
TimeSpan after,
|
||||
string reason,
|
||||
int pruneDays)
|
||||
{
|
||||
await guild.AddBanAsync(userId, pruneDays, reason);
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.UnbanTimer));
|
||||
config.UnbanTimer.Add(new()
|
||||
{
|
||||
UserId = userId,
|
||||
UnbanAt = DateTime.UtcNow + after
|
||||
}); // add teh unmute timer to the database
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
StartUn_Timer(guild.Id, userId, after, TimerType.Ban); // start the timer
|
||||
}
|
||||
|
||||
public async Task TimedRole(
|
||||
IGuildUser user,
|
||||
TimeSpan after,
|
||||
string reason,
|
||||
IRole role)
|
||||
{
|
||||
await user.AddRoleAsync(role);
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnroleTimer));
|
||||
config.UnroleTimer.Add(new()
|
||||
{
|
||||
UserId = user.Id,
|
||||
UnbanAt = DateTime.UtcNow + after,
|
||||
RoleId = role.Id
|
||||
}); // add teh unmute timer to the database
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
StartUn_Timer(user.GuildId, user.Id, after, TimerType.AddRole, role.Id); // start the timer
|
||||
}
|
||||
|
||||
public void StartUn_Timer(
|
||||
ulong guildId,
|
||||
ulong userId,
|
||||
TimeSpan after,
|
||||
TimerType type,
|
||||
ulong? roleId = null)
|
||||
{
|
||||
//load the unmute timers for this guild
|
||||
var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>());
|
||||
|
||||
//unmute timer to be added
|
||||
var toAdd = new Timer(async _ =>
|
||||
{
|
||||
if (type == TimerType.Ban)
|
||||
{
|
||||
try
|
||||
{
|
||||
RemoveTimerFromDb(guildId, userId, type);
|
||||
StopTimer(guildId, userId, type);
|
||||
var guild = _client.GetGuild(guildId); // load the guild
|
||||
if (guild is not null)
|
||||
await guild.RemoveBanAsync(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId);
|
||||
}
|
||||
}
|
||||
else if (type == TimerType.AddRole)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (roleId is null)
|
||||
return;
|
||||
|
||||
RemoveTimerFromDb(guildId, userId, type);
|
||||
StopTimer(guildId, userId, type);
|
||||
var guild = _client.GetGuild(guildId);
|
||||
var user = guild?.GetUser(userId);
|
||||
var role = guild?.GetRole(roleId.Value);
|
||||
if (guild is not null && user is not null && user.Roles.Contains(role))
|
||||
await user.RemoveRoleAsync(role);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
// unmute the user, this will also remove the timer from the db
|
||||
await UnmuteUser(guildId, userId, _client.CurrentUser, reason: "Timed mute expired");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db
|
||||
Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId);
|
||||
}
|
||||
}
|
||||
},
|
||||
null,
|
||||
after,
|
||||
Timeout.InfiniteTimeSpan);
|
||||
|
||||
//add it, or stop the old one and add this one
|
||||
userUnTimers.AddOrUpdate((userId, type),
|
||||
_ => toAdd,
|
||||
(_, old) =>
|
||||
{
|
||||
old.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
return toAdd;
|
||||
});
|
||||
}
|
||||
|
||||
public void StopTimer(ulong guildId, ulong userId, TimerType type)
|
||||
{
|
||||
if (!UnTimers.TryGetValue(guildId, out var userTimer))
|
||||
return;
|
||||
|
||||
if (userTimer.TryRemove((userId, type), out var removed))
|
||||
removed.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
private void RemoveTimerFromDb(ulong guildId, ulong userId, TimerType type)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
object toDelete;
|
||||
if (type == TimerType.Mute)
|
||||
{
|
||||
var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnmuteTimers));
|
||||
toDelete = config.UnmuteTimers.FirstOrDefault(x => x.UserId == userId);
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnbanTimer));
|
||||
toDelete = config.UnbanTimer.FirstOrDefault(x => x.UserId == userId);
|
||||
}
|
||||
|
||||
if (toDelete is not null)
|
||||
uow.Remove(toDelete);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class DiscordPermOverrideCommands : NadekoModule<DiscordPermOverrideService>
|
||||
{
|
||||
// override stats, it should require that the user has managessages guild permission
|
||||
// .po 'stats' add user guild managemessages
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task DiscordPermOverride(CommandOrExprInfo cmd, params GuildPerm[] perms)
|
||||
{
|
||||
if (perms is null || perms.Length == 0)
|
||||
{
|
||||
await _service.RemoveOverride(ctx.Guild.Id, cmd.Name);
|
||||
await ReplyConfirmLocalizedAsync(strs.perm_override_reset);
|
||||
return;
|
||||
}
|
||||
|
||||
var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc);
|
||||
await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.perm_override(Format.Bold(aggregatePerms.ToString()),
|
||||
Format.Code(cmd.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task DiscordPermOverrideReset()
|
||||
{
|
||||
var result = await PromptUserConfirmAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithDescription(GetText(strs.perm_override_all_confirm)));
|
||||
|
||||
if (!result)
|
||||
return;
|
||||
|
||||
await _service.ClearAllOverrides(ctx.Guild.Id);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.perm_override_all);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task DiscordPermOverrideList(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
var overrides = await _service.GetAllOverrides(ctx.Guild.Id);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var eb = _eb.Create().WithTitle(GetText(strs.perm_overrides)).WithOkColor();
|
||||
|
||||
var thisPageOverrides = overrides.Skip(9 * curPage).Take(9).ToList();
|
||||
|
||||
if (thisPageOverrides.Count == 0)
|
||||
eb.WithDescription(GetText(strs.perm_override_page_none));
|
||||
else
|
||||
{
|
||||
eb.WithDescription(thisPageOverrides.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}")
|
||||
.Join("\n"));
|
||||
}
|
||||
|
||||
return eb;
|
||||
},
|
||||
overrides.Count,
|
||||
9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class PlayingRotateCommands : NadekoModule<PlayingRotateService>
|
||||
{
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task RotatePlaying()
|
||||
{
|
||||
if (_service.ToggleRotatePlaying())
|
||||
await ReplyConfirmLocalizedAsync(strs.ropl_enabled);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.ropl_disabled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task AddPlaying(ActivityType t, [Leftover] string status)
|
||||
{
|
||||
await _service.AddPlaying(t, status);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.ropl_added);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task ListPlaying()
|
||||
{
|
||||
var statuses = _service.GetRotatingStatuses();
|
||||
|
||||
if (!statuses.Any())
|
||||
await ReplyErrorLocalizedAsync(strs.ropl_not_set);
|
||||
else
|
||||
{
|
||||
var i = 1;
|
||||
await ReplyConfirmLocalizedAsync(strs.ropl_list(string.Join("\n\t",
|
||||
statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}"))));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task RemovePlaying(int index)
|
||||
{
|
||||
index -= 1;
|
||||
|
||||
var msg = await _service.RemovePlayingAsync(index);
|
||||
|
||||
if (msg is null)
|
||||
return;
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.reprm(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class PlayingRotateService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly BotConfigService _bss;
|
||||
private readonly SelfService _selfService;
|
||||
private readonly IReplacementService _repService;
|
||||
// private readonly Replacer _rep;
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public PlayingRotateService(
|
||||
DiscordSocketClient client,
|
||||
DbService db,
|
||||
BotConfigService bss,
|
||||
IEnumerable<IPlaceholderProvider> phProviders,
|
||||
SelfService selfService,
|
||||
IReplacementService repService)
|
||||
{
|
||||
_db = db;
|
||||
_bss = bss;
|
||||
_selfService = selfService;
|
||||
_repService = repService;
|
||||
_client = client;
|
||||
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
|
||||
var index = 0;
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_bss.Data.RotateStatuses)
|
||||
continue;
|
||||
|
||||
IReadOnlyList<RotatingPlayingStatus> rotatingStatuses;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
rotatingStatuses = uow.Set<RotatingPlayingStatus>().AsNoTracking().OrderBy(x => x.Id).ToList();
|
||||
}
|
||||
|
||||
if (rotatingStatuses.Count == 0)
|
||||
continue;
|
||||
|
||||
var playingStatus = index >= rotatingStatuses.Count
|
||||
? rotatingStatuses[index = 0]
|
||||
: rotatingStatuses[index++];
|
||||
|
||||
var statusText = await _repService.ReplaceAsync(playingStatus.Status, new (client: _client));
|
||||
await _selfService.SetGameAsync(statusText, (ActivityType)playingStatus.Type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> RemovePlayingAsync(int index)
|
||||
{
|
||||
if (index < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var toRemove = await uow.Set<RotatingPlayingStatus>().AsQueryable().AsNoTracking().Skip(index).FirstOrDefaultAsync();
|
||||
|
||||
if (toRemove is null)
|
||||
return null;
|
||||
|
||||
uow.Remove(toRemove);
|
||||
await uow.SaveChangesAsync();
|
||||
return toRemove.Status;
|
||||
}
|
||||
|
||||
public async Task AddPlaying(ActivityType activityType, string status)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var toAdd = new RotatingPlayingStatus
|
||||
{
|
||||
Status = status,
|
||||
Type = (Nadeko.Bot.Db.ActivityType)activityType
|
||||
};
|
||||
uow.Add(toAdd);
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public bool ToggleRotatePlaying()
|
||||
{
|
||||
var enabled = false;
|
||||
_bss.ModifyConfig(bs => { enabled = bs.RotateStatuses = !bs.RotateStatuses; });
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public IReadOnlyList<RotatingPlayingStatus> GetRotatingStatuses()
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<RotatingPlayingStatus>().AsNoTracking().ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class PrefixCommands : NadekoModule
|
||||
{
|
||||
public enum Set
|
||||
{
|
||||
Set
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[Priority(1)]
|
||||
public async Task Prefix()
|
||||
=> await ReplyConfirmLocalizedAsync(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild))));
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(0)]
|
||||
public Task Prefix(Set _, [Leftover] string newPrefix)
|
||||
=> Prefix(newPrefix);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(0)]
|
||||
public async Task Prefix([Leftover] string toSet)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(prefix))
|
||||
return;
|
||||
|
||||
var oldPrefix = prefix;
|
||||
var newPrefix = _cmdHandler.SetPrefix(ctx.Guild, toSet);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task DefPrefix([Leftover] string toSet = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toSet))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.defprefix_current(_cmdHandler.GetPrefix()));
|
||||
return;
|
||||
}
|
||||
|
||||
var oldPrefix = _cmdHandler.GetPrefix();
|
||||
var newPrefix = _cmdHandler.SetDefaultPrefix(toSet);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class ProtectionCommands : NadekoModule<ProtectionService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task AntiAlt()
|
||||
{
|
||||
if (await _service.TryStopAntiAlt(ctx.Guild.Id))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Alt"));
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.protection_not_running("Anti-Alt"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task AntiAlt(
|
||||
StoopidTime minAge,
|
||||
PunishmentAction action,
|
||||
[Leftover] StoopidTime punishTime = null)
|
||||
{
|
||||
var minAgeMinutes = (int)minAge.Time.TotalMinutes;
|
||||
var punishTimeMinutes = (int?)punishTime?.Time.TotalMinutes ?? 0;
|
||||
|
||||
if (minAgeMinutes < 1 || punishTimeMinutes < 0)
|
||||
return;
|
||||
|
||||
var minutes = (int?)punishTime?.Time.TotalMinutes ?? 0;
|
||||
if (action is PunishmentAction.TimeOut && minutes < 1)
|
||||
minutes = 1;
|
||||
|
||||
await _service.StartAntiAltAsync(ctx.Guild.Id,
|
||||
minAgeMinutes,
|
||||
action,
|
||||
minutes);
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task AntiAlt(StoopidTime minAge, PunishmentAction action, [Leftover] IRole role)
|
||||
{
|
||||
var minAgeMinutes = (int)minAge.Time.TotalMinutes;
|
||||
|
||||
if (minAgeMinutes < 1)
|
||||
return;
|
||||
|
||||
if (action == PunishmentAction.TimeOut)
|
||||
return;
|
||||
|
||||
await _service.StartAntiAltAsync(ctx.Guild.Id, minAgeMinutes, action, roleId: role.Id);
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public Task AntiRaid()
|
||||
{
|
||||
if (_service.TryStopAntiRaid(ctx.Guild.Id))
|
||||
return ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Raid"));
|
||||
return ReplyPendingLocalizedAsync(strs.protection_not_running("Anti-Raid"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(1)]
|
||||
public Task AntiRaid(
|
||||
int userThreshold,
|
||||
int seconds,
|
||||
PunishmentAction action,
|
||||
[Leftover] StoopidTime punishTime)
|
||||
=> InternalAntiRaid(userThreshold, seconds, action, punishTime);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(2)]
|
||||
public Task AntiRaid(int userThreshold, int seconds, PunishmentAction action)
|
||||
=> InternalAntiRaid(userThreshold, seconds, action);
|
||||
|
||||
private async Task InternalAntiRaid(
|
||||
int userThreshold,
|
||||
int seconds = 10,
|
||||
PunishmentAction action = PunishmentAction.Mute,
|
||||
StoopidTime punishTime = null)
|
||||
{
|
||||
if (action == PunishmentAction.AddRole)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.punishment_unsupported(action));
|
||||
return;
|
||||
}
|
||||
|
||||
if (userThreshold is < 2 or > 30)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.raid_cnt(2, 30));
|
||||
return;
|
||||
}
|
||||
|
||||
if (seconds is < 2 or > 300)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.raid_time(2, 300));
|
||||
return;
|
||||
}
|
||||
|
||||
if (punishTime is not null)
|
||||
{
|
||||
if (!_service.IsDurationAllowed(action))
|
||||
await ReplyErrorLocalizedAsync(strs.prot_cant_use_time);
|
||||
}
|
||||
|
||||
var time = (int?)punishTime?.Time.TotalMinutes ?? 0;
|
||||
if (time is < 0 or > 60 * 24)
|
||||
return;
|
||||
|
||||
if(action is PunishmentAction.TimeOut && time < 1)
|
||||
return;
|
||||
|
||||
var stats = await _service.StartAntiRaidAsync(ctx.Guild.Id, userThreshold, seconds, action, time);
|
||||
|
||||
if (stats is null)
|
||||
return;
|
||||
|
||||
await SendConfirmAsync(GetText(strs.prot_enable("Anti-Raid")),
|
||||
$"{ctx.User.Mention} {GetAntiRaidString(stats)}");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public Task AntiSpam()
|
||||
{
|
||||
if (_service.TryStopAntiSpam(ctx.Guild.Id))
|
||||
return ReplyConfirmLocalizedAsync(strs.prot_disable("Anti-Spam"));
|
||||
return ReplyPendingLocalizedAsync(strs.protection_not_running("Anti-Spam"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(0)]
|
||||
public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] IRole role)
|
||||
{
|
||||
if (action != PunishmentAction.AddRole)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return InternalAntiSpam(messageCount, action, null, role);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(1)]
|
||||
public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] StoopidTime punishTime)
|
||||
=> InternalAntiSpam(messageCount, action, punishTime);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[Priority(2)]
|
||||
public Task AntiSpam(int messageCount, PunishmentAction action)
|
||||
=> InternalAntiSpam(messageCount, action);
|
||||
|
||||
private async Task InternalAntiSpam(
|
||||
int messageCount,
|
||||
PunishmentAction action,
|
||||
StoopidTime timeData = null,
|
||||
IRole role = null)
|
||||
{
|
||||
if (messageCount is < 2 or > 10)
|
||||
return;
|
||||
|
||||
if (timeData is not null)
|
||||
{
|
||||
if (!_service.IsDurationAllowed(action))
|
||||
await ReplyErrorLocalizedAsync(strs.prot_cant_use_time);
|
||||
}
|
||||
|
||||
var time = (int?)timeData?.Time.TotalMinutes ?? 0;
|
||||
if (time is < 0 or > 60 * 24)
|
||||
return;
|
||||
|
||||
if (action is PunishmentAction.TimeOut && time < 1)
|
||||
return;
|
||||
|
||||
var stats = await _service.StartAntiSpamAsync(ctx.Guild.Id, messageCount, action, time, role?.Id);
|
||||
|
||||
await SendConfirmAsync(GetText(strs.prot_enable("Anti-Spam")),
|
||||
$"{ctx.User.Mention} {GetAntiSpamString(stats)}");
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task AntispamIgnore()
|
||||
{
|
||||
var added = await _service.AntiSpamIgnoreAsync(ctx.Guild.Id, ctx.Channel.Id);
|
||||
|
||||
if (added is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.protection_not_running("Anti-Spam"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (added.Value)
|
||||
await ReplyConfirmLocalizedAsync(strs.spam_ignore("Anti-Spam"));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.spam_not_ignore("Anti-Spam"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task AntiList()
|
||||
{
|
||||
var (spam, raid, alt) = _service.GetAntiStats(ctx.Guild.Id);
|
||||
|
||||
if (spam is null && raid is null && alt is null)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.prot_none);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.prot_active));
|
||||
|
||||
if (spam is not null)
|
||||
embed.AddField("Anti-Spam", GetAntiSpamString(spam).TrimTo(1024), true);
|
||||
|
||||
if (raid is not null)
|
||||
embed.AddField("Anti-Raid", GetAntiRaidString(raid).TrimTo(1024), true);
|
||||
|
||||
if (alt is not null)
|
||||
embed.AddField("Anti-Alt", GetAntiAltString(alt), true);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
private string GetAntiAltString(AntiAltStats alt)
|
||||
=> GetText(strs.anti_alt_status(Format.Bold(alt.MinAge.ToString(@"dd\d\ hh\h\ mm\m\ ")),
|
||||
Format.Bold(alt.Action.ToString()),
|
||||
Format.Bold(alt.Counter.ToString())));
|
||||
|
||||
private string GetAntiSpamString(AntiSpamStats stats)
|
||||
{
|
||||
var settings = stats.AntiSpamSettings;
|
||||
var ignoredString = string.Join(", ", settings.IgnoredChannels.Select(c => $"<#{c.ChannelId}>"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ignoredString))
|
||||
ignoredString = "none";
|
||||
|
||||
var add = string.Empty;
|
||||
if (settings.MuteTime > 0)
|
||||
add = $" ({TimeSpan.FromMinutes(settings.MuteTime):hh\\hmm\\m})";
|
||||
|
||||
return GetText(strs.spam_stats(Format.Bold(settings.MessageThreshold.ToString()),
|
||||
Format.Bold(settings.Action + add),
|
||||
ignoredString));
|
||||
}
|
||||
|
||||
private string GetAntiRaidString(AntiRaidStats stats)
|
||||
{
|
||||
var actionString = Format.Bold(stats.AntiRaidSettings.Action.ToString());
|
||||
|
||||
if (stats.AntiRaidSettings.PunishDuration > 0)
|
||||
actionString += $" **({TimeSpan.FromMinutes(stats.AntiRaidSettings.PunishDuration):hh\\hmm\\m})**";
|
||||
|
||||
return GetText(strs.raid_stats(Format.Bold(stats.AntiRaidSettings.UserThreshold.ToString()),
|
||||
Format.Bold(stats.AntiRaidSettings.Seconds.ToString()),
|
||||
actionString));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class ProtectionService : INService
|
||||
{
|
||||
public event Func<PunishmentAction, ProtectionType, IGuildUser[], Task> OnAntiProtectionTriggered = delegate
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, AntiRaidStats> _antiRaidGuilds = new();
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, AntiSpamStats> _antiSpamGuilds = new();
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, AntiAltStats> _antiAltGuilds = new();
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly MuteService _mute;
|
||||
private readonly DbService _db;
|
||||
private readonly UserPunishService _punishService;
|
||||
|
||||
private readonly Channel<PunishQueueItem> _punishUserQueue =
|
||||
Channel.CreateUnbounded<PunishQueueItem>(new()
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public ProtectionService(
|
||||
DiscordSocketClient client,
|
||||
IBot bot,
|
||||
MuteService mute,
|
||||
DbService db,
|
||||
UserPunishService punishService)
|
||||
{
|
||||
_client = client;
|
||||
_mute = mute;
|
||||
_db = db;
|
||||
_punishService = punishService;
|
||||
|
||||
var ids = client.GetGuildIds();
|
||||
using (var uow = db.GetDbContext())
|
||||
{
|
||||
var configs = uow.Set<GuildConfig>()
|
||||
.AsQueryable()
|
||||
.Include(x => x.AntiRaidSetting)
|
||||
.Include(x => x.AntiSpamSetting)
|
||||
.ThenInclude(x => x.IgnoredChannels)
|
||||
.Include(x => x.AntiAltSetting)
|
||||
.Where(x => ids.Contains(x.GuildId))
|
||||
.ToList();
|
||||
|
||||
foreach (var gc in configs)
|
||||
Initialize(gc);
|
||||
}
|
||||
|
||||
_client.MessageReceived += HandleAntiSpam;
|
||||
_client.UserJoined += HandleUserJoined;
|
||||
|
||||
bot.JoinedGuild += _bot_JoinedGuild;
|
||||
_client.LeftGuild += _client_LeftGuild;
|
||||
|
||||
_ = Task.Run(RunQueue);
|
||||
}
|
||||
|
||||
private async Task RunQueue()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var item = await _punishUserQueue.Reader.ReadAsync();
|
||||
|
||||
var muteTime = item.MuteTime;
|
||||
var gu = item.User;
|
||||
try
|
||||
{
|
||||
await _punishService.ApplyPunishment(gu.Guild,
|
||||
gu,
|
||||
_client.CurrentUser,
|
||||
item.Action,
|
||||
muteTime,
|
||||
item.RoleId,
|
||||
$"{item.Type} Protection");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in punish queue: {Message}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task _client_LeftGuild(SocketGuild guild)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
TryStopAntiRaid(guild.Id);
|
||||
TryStopAntiSpam(guild.Id);
|
||||
await TryStopAntiAlt(guild.Id);
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task _bot_JoinedGuild(GuildConfig gc)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gcWithData = uow.GuildConfigsForId(gc.GuildId,
|
||||
set => set.Include(x => x.AntiRaidSetting)
|
||||
.Include(x => x.AntiAltSetting)
|
||||
.Include(x => x.AntiSpamSetting)
|
||||
.ThenInclude(x => x.IgnoredChannels));
|
||||
|
||||
Initialize(gcWithData);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Initialize(GuildConfig gc)
|
||||
{
|
||||
var raid = gc.AntiRaidSetting;
|
||||
var spam = gc.AntiSpamSetting;
|
||||
|
||||
if (raid is not null)
|
||||
{
|
||||
var raidStats = new AntiRaidStats
|
||||
{
|
||||
AntiRaidSettings = raid
|
||||
};
|
||||
_antiRaidGuilds[gc.GuildId] = raidStats;
|
||||
}
|
||||
|
||||
if (spam is not null)
|
||||
{
|
||||
_antiSpamGuilds[gc.GuildId] = new()
|
||||
{
|
||||
AntiSpamSettings = spam
|
||||
};
|
||||
}
|
||||
|
||||
var alt = gc.AntiAltSetting;
|
||||
if (alt is not null)
|
||||
_antiAltGuilds[gc.GuildId] = new(alt);
|
||||
}
|
||||
|
||||
private Task HandleUserJoined(SocketGuildUser user)
|
||||
{
|
||||
if (user.IsBot)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_antiRaidGuilds.TryGetValue(user.Guild.Id, out var maybeStats);
|
||||
_antiAltGuilds.TryGetValue(user.Guild.Id, out var maybeAlts);
|
||||
|
||||
if (maybeStats is null && maybeAlts is null)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (maybeAlts is { } alts)
|
||||
{
|
||||
if (user.CreatedAt != default)
|
||||
{
|
||||
var diff = DateTime.UtcNow - user.CreatedAt.UtcDateTime;
|
||||
if (diff < alts.MinAge)
|
||||
{
|
||||
alts.Increment();
|
||||
|
||||
await PunishUsers(alts.Action,
|
||||
ProtectionType.Alting,
|
||||
alts.ActionDurationMinutes,
|
||||
alts.RoleId,
|
||||
user);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (maybeStats is not { } stats || !stats.RaidUsers.Add(user))
|
||||
return;
|
||||
|
||||
++stats.UsersCount;
|
||||
|
||||
if (stats.UsersCount >= stats.AntiRaidSettings.UserThreshold)
|
||||
{
|
||||
var users = stats.RaidUsers.ToArray();
|
||||
stats.RaidUsers.Clear();
|
||||
var settings = stats.AntiRaidSettings;
|
||||
|
||||
await PunishUsers(settings.Action, ProtectionType.Raiding, settings.PunishDuration, null, users);
|
||||
}
|
||||
|
||||
await Task.Delay(1000 * stats.AntiRaidSettings.Seconds);
|
||||
|
||||
stats.RaidUsers.TryRemove(user);
|
||||
--stats.UsersCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleAntiSpam(SocketMessage arg)
|
||||
{
|
||||
if (arg is not SocketUserMessage msg || msg.Author.IsBot)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (msg.Channel is not ITextChannel channel)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_antiSpamGuilds.TryGetValue(channel.Guild.Id, out var spamSettings)
|
||||
|| spamSettings.AntiSpamSettings.IgnoredChannels.Contains(new()
|
||||
{
|
||||
ChannelId = channel.Id
|
||||
}))
|
||||
return;
|
||||
|
||||
var stats = spamSettings.UserStats.AddOrUpdate(msg.Author.Id,
|
||||
_ => new(msg),
|
||||
(_, old) =>
|
||||
{
|
||||
old.ApplyNextMessage(msg);
|
||||
return old;
|
||||
});
|
||||
|
||||
if (stats.Count >= spamSettings.AntiSpamSettings.MessageThreshold)
|
||||
{
|
||||
if (spamSettings.UserStats.TryRemove(msg.Author.Id, out stats))
|
||||
{
|
||||
var settings = spamSettings.AntiSpamSettings;
|
||||
await PunishUsers(settings.Action,
|
||||
ProtectionType.Spamming,
|
||||
settings.MuteTime,
|
||||
settings.RoleId,
|
||||
(IGuildUser)msg.Author);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PunishUsers(
|
||||
PunishmentAction action,
|
||||
ProtectionType pt,
|
||||
int muteTime,
|
||||
ulong? roleId,
|
||||
params IGuildUser[] gus)
|
||||
{
|
||||
Log.Information("[{PunishType}] - Punishing [{Count}] users with [{PunishAction}] in {GuildName} guild",
|
||||
pt,
|
||||
gus.Length,
|
||||
action,
|
||||
gus[0].Guild.Name);
|
||||
|
||||
foreach (var gu in gus)
|
||||
{
|
||||
await _punishUserQueue.Writer.WriteAsync(new()
|
||||
{
|
||||
Action = action,
|
||||
Type = pt,
|
||||
User = gu,
|
||||
MuteTime = muteTime,
|
||||
RoleId = roleId
|
||||
});
|
||||
}
|
||||
|
||||
_ = OnAntiProtectionTriggered(action, pt, gus);
|
||||
}
|
||||
|
||||
public async Task<AntiRaidStats> StartAntiRaidAsync(
|
||||
ulong guildId,
|
||||
int userThreshold,
|
||||
int seconds,
|
||||
PunishmentAction action,
|
||||
int minutesDuration)
|
||||
{
|
||||
var g = _client.GetGuild(guildId);
|
||||
await _mute.GetMuteRole(g);
|
||||
|
||||
if (action == PunishmentAction.AddRole)
|
||||
return null;
|
||||
|
||||
if (!IsDurationAllowed(action))
|
||||
minutesDuration = 0;
|
||||
|
||||
var stats = new AntiRaidStats
|
||||
{
|
||||
AntiRaidSettings = new()
|
||||
{
|
||||
Action = action,
|
||||
Seconds = seconds,
|
||||
UserThreshold = userThreshold,
|
||||
PunishDuration = minutesDuration
|
||||
}
|
||||
};
|
||||
|
||||
_antiRaidGuilds.AddOrUpdate(guildId, stats, (_, _) => stats);
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting));
|
||||
|
||||
gc.AntiRaidSetting = stats.AntiRaidSettings;
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
public bool TryStopAntiRaid(ulong guildId)
|
||||
{
|
||||
if (_antiRaidGuilds.TryRemove(guildId, out _))
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting));
|
||||
|
||||
gc.AntiRaidSetting = null;
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryStopAntiSpam(ulong guildId)
|
||||
{
|
||||
if (_antiSpamGuilds.TryRemove(guildId, out _))
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId,
|
||||
set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels));
|
||||
|
||||
gc.AntiSpamSetting = null;
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<AntiSpamStats> StartAntiSpamAsync(
|
||||
ulong guildId,
|
||||
int messageCount,
|
||||
PunishmentAction action,
|
||||
int punishDurationMinutes,
|
||||
ulong? roleId)
|
||||
{
|
||||
var g = _client.GetGuild(guildId);
|
||||
await _mute.GetMuteRole(g);
|
||||
|
||||
if (!IsDurationAllowed(action))
|
||||
punishDurationMinutes = 0;
|
||||
|
||||
var stats = new AntiSpamStats
|
||||
{
|
||||
AntiSpamSettings = new()
|
||||
{
|
||||
Action = action,
|
||||
MessageThreshold = messageCount,
|
||||
MuteTime = punishDurationMinutes,
|
||||
RoleId = roleId
|
||||
}
|
||||
};
|
||||
|
||||
stats = _antiSpamGuilds.AddOrUpdate(guildId,
|
||||
stats,
|
||||
(_, old) =>
|
||||
{
|
||||
stats.AntiSpamSettings.IgnoredChannels = old.AntiSpamSettings.IgnoredChannels;
|
||||
return stats;
|
||||
});
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiSpamSetting));
|
||||
|
||||
if (gc.AntiSpamSetting is not null)
|
||||
{
|
||||
gc.AntiSpamSetting.Action = stats.AntiSpamSettings.Action;
|
||||
gc.AntiSpamSetting.MessageThreshold = stats.AntiSpamSettings.MessageThreshold;
|
||||
gc.AntiSpamSetting.MuteTime = stats.AntiSpamSettings.MuteTime;
|
||||
gc.AntiSpamSetting.RoleId = stats.AntiSpamSettings.RoleId;
|
||||
}
|
||||
else
|
||||
gc.AntiSpamSetting = stats.AntiSpamSettings;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return stats;
|
||||
}
|
||||
|
||||
public async Task<bool?> AntiSpamIgnoreAsync(ulong guildId, ulong channelId)
|
||||
{
|
||||
var obj = new AntiSpamIgnore
|
||||
{
|
||||
ChannelId = channelId
|
||||
};
|
||||
bool added;
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId,
|
||||
set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels));
|
||||
var spam = gc.AntiSpamSetting;
|
||||
if (spam is null)
|
||||
return null;
|
||||
|
||||
if (spam.IgnoredChannels.Add(obj)) // if adding to db is successful
|
||||
{
|
||||
if (_antiSpamGuilds.TryGetValue(guildId, out var temp))
|
||||
temp.AntiSpamSettings.IgnoredChannels.Add(obj); // add to local cache
|
||||
|
||||
added = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var toRemove = spam.IgnoredChannels.First(x => x.ChannelId == channelId);
|
||||
uow.Set<AntiSpamIgnore>().Remove(toRemove); // remove from db
|
||||
if (_antiSpamGuilds.TryGetValue(guildId, out var temp))
|
||||
temp.AntiSpamSettings.IgnoredChannels.Remove(toRemove); // remove from local cache
|
||||
|
||||
added = false;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return added;
|
||||
}
|
||||
|
||||
public (AntiSpamStats, AntiRaidStats, AntiAltStats) GetAntiStats(ulong guildId)
|
||||
{
|
||||
_antiRaidGuilds.TryGetValue(guildId, out var antiRaidStats);
|
||||
_antiSpamGuilds.TryGetValue(guildId, out var antiSpamStats);
|
||||
_antiAltGuilds.TryGetValue(guildId, out var antiAltStats);
|
||||
|
||||
return (antiSpamStats, antiRaidStats, antiAltStats);
|
||||
}
|
||||
|
||||
public bool IsDurationAllowed(PunishmentAction action)
|
||||
{
|
||||
switch (action)
|
||||
{
|
||||
case PunishmentAction.Ban:
|
||||
case PunishmentAction.Mute:
|
||||
case PunishmentAction.ChatMute:
|
||||
case PunishmentAction.VoiceMute:
|
||||
case PunishmentAction.AddRole:
|
||||
case PunishmentAction.TimeOut:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartAntiAltAsync(
|
||||
ulong guildId,
|
||||
int minAgeMinutes,
|
||||
PunishmentAction action,
|
||||
int actionDurationMinutes = 0,
|
||||
ulong? roleId = null)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting));
|
||||
gc.AntiAltSetting = new()
|
||||
{
|
||||
Action = action,
|
||||
ActionDurationMinutes = actionDurationMinutes,
|
||||
MinAge = TimeSpan.FromMinutes(minAgeMinutes),
|
||||
RoleId = roleId
|
||||
};
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
_antiAltGuilds[guildId] = new(gc.AntiAltSetting);
|
||||
}
|
||||
|
||||
public async Task<bool> TryStopAntiAlt(ulong guildId)
|
||||
{
|
||||
if (!_antiAltGuilds.TryRemove(guildId, out _))
|
||||
return false;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting));
|
||||
gc.AntiAltSetting = null;
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public enum ProtectionType
|
||||
{
|
||||
Raiding,
|
||||
Spamming,
|
||||
Alting
|
||||
}
|
||||
|
||||
public class AntiRaidStats
|
||||
{
|
||||
public AntiRaidSetting AntiRaidSettings { get; set; }
|
||||
public int UsersCount { get; set; }
|
||||
public ConcurrentHashSet<IGuildUser> RaidUsers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AntiSpamStats
|
||||
{
|
||||
public AntiSpamSetting AntiSpamSettings { get; set; }
|
||||
public ConcurrentDictionary<ulong, UserSpamStats> UserStats { get; set; } = new();
|
||||
}
|
||||
|
||||
public class AntiAltStats
|
||||
{
|
||||
public PunishmentAction Action
|
||||
=> _setting.Action;
|
||||
|
||||
public int ActionDurationMinutes
|
||||
=> _setting.ActionDurationMinutes;
|
||||
|
||||
public ulong? RoleId
|
||||
=> _setting.RoleId;
|
||||
|
||||
public TimeSpan MinAge
|
||||
=> _setting.MinAge;
|
||||
|
||||
public int Counter
|
||||
=> counter;
|
||||
|
||||
private readonly AntiAltSetting _setting;
|
||||
|
||||
private int counter;
|
||||
|
||||
public AntiAltStats(AntiAltSetting setting)
|
||||
=> _setting = setting;
|
||||
|
||||
public void Increment()
|
||||
=> Interlocked.Increment(ref counter);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public class PunishQueueItem
|
||||
{
|
||||
public PunishmentAction Action { get; set; }
|
||||
public ProtectionType Type { get; set; }
|
||||
public int MuteTime { get; set; }
|
||||
public ulong? RoleId { get; set; }
|
||||
public IGuildUser User { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public sealed class UserSpamStats
|
||||
{
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_applyLock)
|
||||
{
|
||||
Cleanup();
|
||||
return _messageTracker.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string lastMessage;
|
||||
|
||||
private readonly Queue<DateTime> _messageTracker;
|
||||
|
||||
private readonly object _applyLock = new();
|
||||
|
||||
private readonly TimeSpan _maxTime = TimeSpan.FromMinutes(30);
|
||||
|
||||
public UserSpamStats(IUserMessage msg)
|
||||
{
|
||||
lastMessage = msg.Content.ToUpperInvariant();
|
||||
_messageTracker = new();
|
||||
|
||||
ApplyNextMessage(msg);
|
||||
}
|
||||
|
||||
public void ApplyNextMessage(IUserMessage message)
|
||||
{
|
||||
var upperMsg = message.Content.ToUpperInvariant();
|
||||
|
||||
lock (_applyLock)
|
||||
{
|
||||
if (upperMsg != lastMessage || (string.IsNullOrWhiteSpace(upperMsg) && message.Attachments.Any()))
|
||||
{
|
||||
// if it's a new message, reset spam counter
|
||||
lastMessage = upperMsg;
|
||||
_messageTracker.Clear();
|
||||
}
|
||||
|
||||
_messageTracker.Enqueue(DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
lock (_applyLock)
|
||||
{
|
||||
while (_messageTracker.TryPeek(out var dateTime))
|
||||
{
|
||||
if (DateTime.UtcNow - dateTime < _maxTime)
|
||||
break;
|
||||
|
||||
_messageTracker.Dequeue();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/NadekoBot/Modules/Administration/Prune/PruneCommands.cs
Normal file
114
src/NadekoBot/Modules/Administration/Prune/PruneCommands.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
#nullable disable
|
||||
using CommandLine;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class PruneCommands : NadekoModule<PruneService>
|
||||
{
|
||||
private static readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
|
||||
|
||||
public sealed class PruneOptions : INadekoCommandOptions
|
||||
{
|
||||
[Option(shortName: 's', longName: "safe", Default = false, HelpText = "Whether pinned messages should be deleted.", Required = false)]
|
||||
public bool Safe { get; set; }
|
||||
|
||||
[Option(shortName: 'a', longName: "after", Default = null, HelpText = "Prune only messages after the specified message ID.", Required = false)]
|
||||
public ulong? After { get; set; }
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
//deletes her own messages, no perm required
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NadekoOptions<PruneOptions>]
|
||||
public async Task Prune(params string[] args)
|
||||
{
|
||||
var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args);
|
||||
|
||||
var user = await ctx.Guild.GetCurrentUserAsync();
|
||||
|
||||
if (opts.Safe)
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel, 100, x => x.Author.Id == user.Id && !x.IsPinned, opts.After);
|
||||
else
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel, 100, x => x.Author.Id == user.Id, opts.After);
|
||||
|
||||
ctx.Message.DeleteAfter(3);
|
||||
}
|
||||
|
||||
// prune x
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
[BotPerm(ChannelPerm.ManageMessages)]
|
||||
[NadekoOptions<PruneOptions>]
|
||||
[Priority(1)]
|
||||
public async Task Prune(int count, params string[] args)
|
||||
{
|
||||
count++;
|
||||
if (count < 1)
|
||||
return;
|
||||
if (count > 1000)
|
||||
count = 1000;
|
||||
|
||||
var (opts, _) = OptionsParser.ParseFrom<PruneOptions>(new PruneOptions(), args);
|
||||
|
||||
if (opts.Safe)
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel, count, x => !x.IsPinned, opts.After);
|
||||
else
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel, count, _ => true, opts.After);
|
||||
}
|
||||
|
||||
//prune @user [x]
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
[BotPerm(ChannelPerm.ManageMessages)]
|
||||
[NadekoOptions<PruneOptions>]
|
||||
[Priority(0)]
|
||||
public Task Prune(IGuildUser user, int count = 100, params string[] args)
|
||||
=> Prune(user.Id, count, args);
|
||||
|
||||
//prune userid [x]
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(ChannelPerm.ManageMessages)]
|
||||
[BotPerm(ChannelPerm.ManageMessages)]
|
||||
[NadekoOptions<PruneOptions>]
|
||||
[Priority(0)]
|
||||
public async Task Prune(ulong userId, int count = 100, params string[] args)
|
||||
{
|
||||
if (userId == ctx.User.Id)
|
||||
count++;
|
||||
|
||||
if (count < 1)
|
||||
return;
|
||||
|
||||
if (count > 1000)
|
||||
count = 1000;
|
||||
|
||||
var (opts, _) = OptionsParser.ParseFrom<PruneOptions>(new PruneOptions(), args);
|
||||
|
||||
if (opts.Safe)
|
||||
{
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned,
|
||||
opts.After);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _service.PruneWhere((ITextChannel)ctx.Channel,
|
||||
count,
|
||||
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks,
|
||||
opts.After);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/NadekoBot/Modules/Administration/Prune/PruneService.cs
Normal file
89
src/NadekoBot/Modules/Administration/Prune/PruneService.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class PruneService : INService
|
||||
{
|
||||
//channelids where prunes are currently occuring
|
||||
private readonly ConcurrentHashSet<ulong> _pruningGuilds = new();
|
||||
private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
|
||||
private readonly ILogCommandService _logService;
|
||||
|
||||
public PruneService(ILogCommandService logService)
|
||||
=> _logService = logService;
|
||||
|
||||
public async Task PruneWhere(ITextChannel channel, int amount, Func<IMessage, bool> predicate, ulong? after = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel, nameof(channel));
|
||||
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
|
||||
if (!_pruningGuilds.Add(channel.GuildId))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
IMessage[] msgs;
|
||||
IMessage lastMessage = null;
|
||||
var dled = await channel.GetMessagesAsync(50).FlattenAsync();
|
||||
|
||||
msgs = dled
|
||||
.Where(predicate)
|
||||
.Where(x => after is ulong a ? x.Id > a : true)
|
||||
.Take(amount)
|
||||
.ToArray();
|
||||
|
||||
while (amount > 0 && msgs.Any())
|
||||
{
|
||||
lastMessage = msgs[^1];
|
||||
|
||||
var bulkDeletable = new List<IMessage>();
|
||||
var singleDeletable = new List<IMessage>();
|
||||
foreach (var x in msgs)
|
||||
{
|
||||
_logService.AddDeleteIgnore(x.Id);
|
||||
|
||||
if (now - x.CreatedAt < _twoWeeks)
|
||||
bulkDeletable.Add(x);
|
||||
else
|
||||
singleDeletable.Add(x);
|
||||
}
|
||||
|
||||
if (bulkDeletable.Count > 0)
|
||||
{
|
||||
await channel.DeleteMessagesAsync(bulkDeletable);
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
|
||||
foreach (var group in singleDeletable.Chunk(5))
|
||||
{
|
||||
await group.Select(x => x.DeleteAsync()).WhenAll();
|
||||
await Task.Delay(5000);
|
||||
}
|
||||
|
||||
//this isn't good, because this still work as if i want to remove only specific user's messages from the last
|
||||
//100 messages, Maybe this needs to be reduced by msgs.Length instead of 100
|
||||
amount -= 50;
|
||||
if (amount > 0)
|
||||
{
|
||||
dled = await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync();
|
||||
|
||||
msgs = dled
|
||||
.Where(predicate)
|
||||
.Where(x => after is ulong a ? x.Id > a : true)
|
||||
.Take(amount)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
//ignore
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pruningGuilds.TryRemove(channel.GuildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Patronage;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using OneOf;
|
||||
using OneOf.Types;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public interface IReactionRoleService
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a single reaction role
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild where to add a reaction role</param>
|
||||
/// <param name="msg">Message to which to add a reaction role</param>
|
||||
/// <param name="emote"></param>
|
||||
/// <param name="role"></param>
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns>The result of the operation</returns>
|
||||
Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
string emote,
|
||||
IRole role,
|
||||
int group = 0,
|
||||
int levelReq = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Get all reaction roles on the specified server
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <returns></returns>
|
||||
Task<IReadOnlyCollection<ReactionRoleV2>> GetReactionRolesAsync(ulong guildId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove reaction roles on the specified message
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <param name="messageId"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> RemoveReactionRoles(ulong guildId, ulong messageId);
|
||||
|
||||
/// <summary>
|
||||
/// Remove all reaction roles in the specified server
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <returns></returns>
|
||||
Task<int> RemoveAllReactionRoles(ulong guildId);
|
||||
|
||||
Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
public partial class ReactionRoleCommands : NadekoModule
|
||||
{
|
||||
private readonly IReactionRoleService _rero;
|
||||
|
||||
public ReactionRoleCommands(IReactionRoleService rero)
|
||||
{
|
||||
_rero = rero;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ReactionRoleAdd(
|
||||
ulong messageId,
|
||||
string emoteStr,
|
||||
IRole role,
|
||||
int group = 0,
|
||||
int levelReq = 0)
|
||||
{
|
||||
if (group < 0)
|
||||
return;
|
||||
|
||||
if (levelReq < 0)
|
||||
return;
|
||||
|
||||
var msg = await ctx.Channel.GetMessageAsync(messageId);
|
||||
if (msg is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.User.Id != ctx.Guild.OwnerId && ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.hierarchy);
|
||||
return;
|
||||
}
|
||||
|
||||
var emote = emoteStr.ToIEmote();
|
||||
await msg.AddReactionAsync(emote);
|
||||
var res = await _rero.AddReactionRole(ctx.Guild,
|
||||
msg,
|
||||
emoteStr,
|
||||
role,
|
||||
group,
|
||||
levelReq);
|
||||
|
||||
await res.Match(
|
||||
_ => ctx.OkAsync(),
|
||||
fl =>
|
||||
{
|
||||
_ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
|
||||
return !fl.IsPatronLimit
|
||||
? ReplyErrorLocalizedAsync(strs.limit_reached(fl.Quota))
|
||||
: ReplyPendingLocalizedAsync(strs.feature_limit_reached_owner(fl.Quota, fl.Name));
|
||||
});
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ReactionRolesList(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
var reros = await _rero.GetReactionRolesAsync(ctx.Guild.Id);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page, curPage =>
|
||||
{
|
||||
var embed = _eb.Create(ctx)
|
||||
.WithOkColor();
|
||||
|
||||
var content = string.Empty;
|
||||
foreach (var g in reros.OrderBy(x => x.Group)
|
||||
.Skip(curPage * 10)
|
||||
.GroupBy(x => x.MessageId)
|
||||
.OrderBy(x => x.Key))
|
||||
{
|
||||
var messageId = g.Key;
|
||||
content +=
|
||||
$"[{messageId}](https://discord.com/channels/{ctx.Guild.Id}/{g.First().ChannelId}/{g.Key})\n";
|
||||
|
||||
var groupGroups = g.GroupBy(x => x.Group);
|
||||
|
||||
foreach (var ggs in groupGroups)
|
||||
{
|
||||
content += $"`< {(g.Key == 0 ? ("Not Exclusive (Group 0)") : ($"Group {ggs.Key}"))} >`\n";
|
||||
|
||||
foreach (var rero in ggs)
|
||||
{
|
||||
content +=
|
||||
$"\t{rero.Emote} -> {(ctx.Guild.GetRole(rero.RoleId)?.Mention ?? "<missing role>")}";
|
||||
if (rero.LevelReq > 0)
|
||||
content += $" (lvl {rero.LevelReq}+)";
|
||||
content += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
embed.WithDescription(string.IsNullOrWhiteSpace(content)
|
||||
? "There are no reaction roles on this server"
|
||||
: content);
|
||||
|
||||
return embed;
|
||||
}, reros.Count, 10);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ReactionRolesRemove(ulong messageId)
|
||||
{
|
||||
var succ = await _rero.RemoveReactionRoles(ctx.Guild.Id, messageId);
|
||||
if (succ)
|
||||
await ctx.OkAsync();
|
||||
else
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ReactionRolesDeleteAll()
|
||||
{
|
||||
await _rero.RemoveAllReactionRoles(ctx.Guild.Id);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Ratelimit(60)]
|
||||
public async Task ReactionRolesTransfer(ulong fromMessageId, ulong toMessageId)
|
||||
{
|
||||
var msg = await ctx.Channel.GetMessageAsync(toMessageId);
|
||||
|
||||
if (msg is null)
|
||||
{
|
||||
await ctx.ErrorAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var reactions = await _rero.TransferReactionRolesAsync(ctx.Guild.Id, fromMessageId, toMessageId);
|
||||
|
||||
if (reactions.Count == 0)
|
||||
{
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var r in reactions)
|
||||
{
|
||||
await msg.AddReactionAsync(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Modules.Patronage;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using OneOf.Types;
|
||||
using OneOf;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class ReactionRolesService : IReadyExecutor, INService, IReactionRoleService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
private ConcurrentDictionary<ulong, List<ReactionRoleV2>> _cache;
|
||||
private readonly object _cacheLock = new();
|
||||
private readonly SemaphoreSlim _assignementLock = new(1, 1);
|
||||
private readonly IPatronageService _ps;
|
||||
|
||||
private static readonly FeatureLimitKey _reroFLKey = new()
|
||||
{
|
||||
Key = "rero:max_count",
|
||||
PrettyName = "Reaction Role"
|
||||
};
|
||||
|
||||
public ReactionRolesService(
|
||||
DiscordSocketClient client,
|
||||
DbService db,
|
||||
IBotCredentials creds,
|
||||
IPatronageService ps)
|
||||
{
|
||||
_db = db;
|
||||
_ps = ps;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_cache = new();
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var reros = await uow.GetTable<ReactionRoleV2>()
|
||||
.Where(
|
||||
x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId))
|
||||
.ToListAsyncLinqToDB();
|
||||
|
||||
foreach (var group in reros.GroupBy(x => x.MessageId))
|
||||
{
|
||||
_cache[group.Key] = group.ToList();
|
||||
}
|
||||
|
||||
_client.ReactionAdded += ClientOnReactionAdded;
|
||||
_client.ReactionRemoved += ClientOnReactionRemoved;
|
||||
}
|
||||
|
||||
private async Task<(IGuildUser, IRole)> GetUserAndRoleAsync(
|
||||
ulong userId,
|
||||
ReactionRoleV2 rero)
|
||||
{
|
||||
var guild = _client.GetGuild(rero.GuildId);
|
||||
var role = guild?.GetRole(rero.RoleId);
|
||||
|
||||
if (role is null)
|
||||
return default;
|
||||
|
||||
var user = guild.GetUser(userId) as IGuildUser
|
||||
?? await _client.Rest.GetGuildUserAsync(guild.Id, userId);
|
||||
|
||||
if (user is null)
|
||||
return default;
|
||||
|
||||
return (user, role);
|
||||
}
|
||||
|
||||
private Task ClientOnReactionRemoved(
|
||||
Cacheable<IUserMessage, ulong> cmsg,
|
||||
Cacheable<IMessageChannel, ulong> ch,
|
||||
SocketReaction r)
|
||||
{
|
||||
if (!_cache.TryGetValue(cmsg.Id, out var reros))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var emote = await GetFixedEmoteAsync(cmsg, r.Emote);
|
||||
|
||||
var rero = reros.FirstOrDefault(x => x.Emote == emote.Name
|
||||
|| x.Emote == emote.ToString());
|
||||
if (rero is null)
|
||||
return;
|
||||
|
||||
var (user, role) = await GetUserAndRoleAsync(r.UserId, rero);
|
||||
|
||||
if (user.IsBot)
|
||||
return;
|
||||
|
||||
await _assignementLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (user.RoleIds.Contains(role.Id))
|
||||
{
|
||||
await user.RemoveRoleAsync(role.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_assignementLock.Release();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
// had to add this because for some reason, reactionremoved event's reaction doesn't have IsAnimated set,
|
||||
// causing the .ToString() to be wrong on animated custom emotes
|
||||
private async Task<IEmote> GetFixedEmoteAsync(
|
||||
Cacheable<IUserMessage, ulong> cmsg,
|
||||
IEmote inputEmote)
|
||||
{
|
||||
// this should only run for emote
|
||||
if (inputEmote is not Emote e)
|
||||
return inputEmote;
|
||||
|
||||
// try to get the message and pull
|
||||
var msg = await cmsg.GetOrDownloadAsync();
|
||||
|
||||
var emote = msg.Reactions.Keys.FirstOrDefault(x => e.Equals(x));
|
||||
return emote ?? inputEmote;
|
||||
}
|
||||
|
||||
private Task ClientOnReactionAdded(
|
||||
Cacheable<IUserMessage, ulong> msg,
|
||||
Cacheable<IMessageChannel, ulong> ch,
|
||||
SocketReaction r)
|
||||
{
|
||||
if (!_cache.TryGetValue(msg.Id, out var reros))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var rero = reros.FirstOrDefault(x => x.Emote == r.Emote.Name || x.Emote == r.Emote.ToString());
|
||||
if (rero is null)
|
||||
return;
|
||||
|
||||
var (user, role) = await GetUserAndRoleAsync(r.UserId, rero);
|
||||
|
||||
if (user.IsBot)
|
||||
return;
|
||||
|
||||
await _assignementLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (!user.RoleIds.Contains(role.Id))
|
||||
{
|
||||
// first check if there is a level requirement
|
||||
// and if there is, make sure user satisfies it
|
||||
if (rero.LevelReq > 0)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var levelData = await ctx.GetTable<UserXpStats>()
|
||||
.GetLevelDataFor(user.GuildId, user.Id);
|
||||
|
||||
if (levelData.Level < rero.LevelReq)
|
||||
return;
|
||||
}
|
||||
|
||||
// remove all other roles from the same group from the user
|
||||
// execept in group 0, which is a special, non-exclusive group
|
||||
if (rero.Group != 0)
|
||||
{
|
||||
var exclusive = reros
|
||||
.Where(x => x.Group == rero.Group && x.RoleId != role.Id)
|
||||
.Select(x => x.RoleId)
|
||||
.Distinct();
|
||||
|
||||
|
||||
try { await user.RemoveRolesAsync(exclusive); }
|
||||
catch { }
|
||||
|
||||
// remove user's previous reaction
|
||||
try
|
||||
{
|
||||
var m = await msg.GetOrDownloadAsync();
|
||||
if (m is not null)
|
||||
{
|
||||
var reactToRemove = m.Reactions
|
||||
.FirstOrDefault(x => x.Key.ToString() != r.Emote.ToString())
|
||||
.Key;
|
||||
|
||||
if (reactToRemove is not null)
|
||||
{
|
||||
await m.RemoveReactionAsync(reactToRemove, user);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await user.AddRoleAsync(role.Id);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_assignementLock.Release();
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single reaction role
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild where to add a reaction role</param>
|
||||
/// <param name="msg">Message to which to add a reaction role</param>
|
||||
/// <param name="emote"></param>
|
||||
/// <param name="role"></param>
|
||||
/// <param name="group"></param>
|
||||
/// <param name="levelReq"></param>
|
||||
/// <returns>The result of the operation</returns>
|
||||
public async Task<OneOf<Success, FeatureLimit>> AddReactionRole(
|
||||
IGuild guild,
|
||||
IMessage msg,
|
||||
string emote,
|
||||
IRole role,
|
||||
int group = 0,
|
||||
int levelReq = 0)
|
||||
{
|
||||
if (group < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(group));
|
||||
|
||||
if (levelReq < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(group));
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
|
||||
await using var tran = await ctx.Database.BeginTransactionAsync();
|
||||
var activeReactionRoles = await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guild.Id)
|
||||
.CountAsync();
|
||||
|
||||
var result = await _ps.TryGetFeatureLimitAsync(_reroFLKey, guild.OwnerId, 50);
|
||||
if (result.Quota != -1 && activeReactionRoles >= result.Quota)
|
||||
return result;
|
||||
|
||||
await ctx.GetTable<ReactionRoleV2>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
GuildId = guild.Id,
|
||||
ChannelId = msg.Channel.Id,
|
||||
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
});
|
||||
|
||||
await tran.CommitAsync();
|
||||
|
||||
var obj = new ReactionRoleV2()
|
||||
{
|
||||
GuildId = guild.Id,
|
||||
MessageId = msg.Id,
|
||||
Emote = emote,
|
||||
RoleId = role.Id,
|
||||
Group = group,
|
||||
LevelReq = levelReq
|
||||
};
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache.AddOrUpdate(msg.Id,
|
||||
_ => new()
|
||||
{
|
||||
obj
|
||||
},
|
||||
(_, list) =>
|
||||
{
|
||||
list.RemoveAll(x => x.Emote == emote);
|
||||
list.Add(obj);
|
||||
return list;
|
||||
});
|
||||
}
|
||||
|
||||
return new Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all reaction roles on the specified server
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<IReadOnlyCollection<ReactionRoleV2>> GetReactionRolesAsync(ulong guildId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove reaction roles on the specified message
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <param name="messageId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<bool> RemoveReactionRoles(ulong guildId, ulong messageId)
|
||||
{
|
||||
// guildid is used for quick index lookup
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var changed = await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guildId && x.MessageId == messageId)
|
||||
.DeleteAsync();
|
||||
|
||||
_cache.TryRemove(messageId, out _);
|
||||
|
||||
if (changed == 0)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove all reaction roles in the specified server
|
||||
/// </summary>
|
||||
/// <param name="guildId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<int> RemoveAllReactionRoles(ulong guildId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var output = await ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.DeleteWithOutputAsync(x => x.MessageId);
|
||||
|
||||
lock (_cacheLock)
|
||||
{
|
||||
foreach (var o in output)
|
||||
{
|
||||
_cache.TryRemove(o, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return output.Length;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(
|
||||
ulong guildId,
|
||||
ulong fromMessageId,
|
||||
ulong toMessageId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var updated = ctx.GetTable<ReactionRoleV2>()
|
||||
.Where(x => x.GuildId == guildId && x.MessageId == fromMessageId)
|
||||
.UpdateWithOutput(old => new()
|
||||
{
|
||||
MessageId = toMessageId
|
||||
},
|
||||
(old, neu) => neu);
|
||||
lock (_cacheLock)
|
||||
{
|
||||
if (_cache.TryRemove(fromMessageId, out var data))
|
||||
{
|
||||
if (_cache.TryGetValue(toMessageId, out var newData))
|
||||
{
|
||||
newData.AddRange(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
_cache[toMessageId] = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated.Select(x => x.Emote.ToIEmote()).ToList();
|
||||
}
|
||||
}
|
||||
207
src/NadekoBot/Modules/Administration/Role/RoleCommands.cs
Normal file
207
src/NadekoBot/Modules/Administration/Role/RoleCommands.cs
Normal file
@@ -0,0 +1,207 @@
|
||||
#nullable disable
|
||||
using System.Xml.Schema;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
public partial class RoleCommands : NadekoModule
|
||||
{
|
||||
public enum Exclude
|
||||
{
|
||||
Excl
|
||||
}
|
||||
|
||||
private readonly IServiceProvider _services;
|
||||
private StickyRolesService _stickyRoleSvc;
|
||||
|
||||
public RoleCommands(IServiceProvider services, StickyRolesService stickyRoleSvc)
|
||||
{
|
||||
_services = services;
|
||||
_stickyRoleSvc = stickyRoleSvc;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task SetRole(IGuildUser targetUser, [Leftover] IRole roleToAdd)
|
||||
{
|
||||
var runnerUser = (IGuildUser)ctx.User;
|
||||
var runnerMaxRolePosition = runnerUser.GetRoles().Max(x => x.Position);
|
||||
if (ctx.User.Id != ctx.Guild.OwnerId && runnerMaxRolePosition <= roleToAdd.Position)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await targetUser.AddRoleAsync(roleToAdd);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.setrole(Format.Bold(roleToAdd.Name),
|
||||
Format.Bold(targetUser.ToString())));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in setrole command");
|
||||
await ReplyErrorLocalizedAsync(strs.setrole_err);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task RemoveRole(IGuildUser targetUser, [Leftover] IRole roleToRemove)
|
||||
{
|
||||
var runnerUser = (IGuildUser)ctx.User;
|
||||
if (ctx.User.Id != runnerUser.Guild.OwnerId
|
||||
&& runnerUser.GetRoles().Max(x => x.Position) <= roleToRemove.Position)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await targetUser.RemoveRoleAsync(roleToRemove);
|
||||
await ReplyConfirmLocalizedAsync(strs.remrole(Format.Bold(roleToRemove.Name),
|
||||
Format.Bold(targetUser.ToString())));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.remrole_err);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task RenameRole(IRole roleToEdit, [Leftover] string newname)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= roleToEdit.Position)
|
||||
return;
|
||||
try
|
||||
{
|
||||
if (roleToEdit.Position > (await ctx.Guild.GetCurrentUserAsync()).GetRoles().Max(r => r.Position))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.renrole_perms);
|
||||
return;
|
||||
}
|
||||
|
||||
await roleToEdit.ModifyAsync(g => g.Name = newname);
|
||||
await ReplyConfirmLocalizedAsync(strs.renrole);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.renrole_err);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task RemoveAllRoles([Leftover] IGuildUser user)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
|
||||
var userRoles = user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole).ToList();
|
||||
|
||||
if (user.Id == ctx.Guild.OwnerId
|
||||
|| (ctx.User.Id != ctx.Guild.OwnerId
|
||||
&& guser.GetRoles().Max(x => x.Position) <= userRoles.Max(x => x.Position)))
|
||||
return;
|
||||
try
|
||||
{
|
||||
await user.RemoveRolesAsync(userRoles);
|
||||
await ReplyConfirmLocalizedAsync(strs.rar(Format.Bold(user.ToString())));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.rar_err);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task CreateRole([Leftover] string roleName = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(roleName))
|
||||
return;
|
||||
|
||||
var r = await ctx.Guild.CreateRoleAsync(roleName, isMentionable: false);
|
||||
await ReplyConfirmLocalizedAsync(strs.cr(Format.Bold(r.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task DeleteRole([Leftover] IRole role)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
|
||||
return;
|
||||
|
||||
await role.DeleteAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.dr(Format.Bold(role.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task RoleHoist([Leftover] IRole role)
|
||||
{
|
||||
var newHoisted = !role.IsHoisted;
|
||||
await role.ModifyAsync(r => r.Hoist = newHoisted);
|
||||
if (newHoisted)
|
||||
await ReplyConfirmLocalizedAsync(strs.rolehoist_enabled(Format.Bold(role.Name)));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.rolehoist_disabled(Format.Bold(role.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task RoleColor([Leftover] IRole role)
|
||||
=> await SendConfirmAsync("Role Color", role.Color.RawValue.ToString("x6"));
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(0)]
|
||||
public async Task RoleColor(Color color, [Leftover] IRole role)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rgba32 = color.ToPixel<Rgba32>();
|
||||
await role.ModifyAsync(r => r.Color = new Discord.Color(rgba32.R, rgba32.G, rgba32.B));
|
||||
await ReplyConfirmLocalizedAsync(strs.rc(Format.Bold(role.Name)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.rc_perms);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task StickyRoles()
|
||||
{
|
||||
var newState = await _stickyRoleSvc.ToggleStickyRoles(ctx.Guild.Id);
|
||||
|
||||
if (newState)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.sticky_roles_enabled);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.sticky_roles_disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
src/NadekoBot/Modules/Administration/Role/StickyRolesService.cs
Normal file
139
src/NadekoBot/Modules/Administration/Role/StickyRolesService.cs
Normal file
@@ -0,0 +1,139 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public sealed class StickyRolesService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly DbService _db;
|
||||
private HashSet<ulong> _stickyRoles = new();
|
||||
|
||||
public StickyRolesService(
|
||||
DiscordSocketClient client,
|
||||
IBotCredentials creds,
|
||||
DbService db)
|
||||
{
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
await using (var ctx = _db.GetDbContext())
|
||||
{
|
||||
_stickyRoles = (await ctx
|
||||
.Set<GuildConfig>()
|
||||
.ToLinqToDBTable()
|
||||
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId,
|
||||
_creds.TotalShards,
|
||||
_client.ShardId))
|
||||
.Where(x => x.StickyRoles)
|
||||
.Select(x => x.GuildId)
|
||||
.ToListAsync())
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
_client.UserJoined += ClientOnUserJoined;
|
||||
_client.UserLeft += ClientOnUserLeft;
|
||||
|
||||
// cleanup old ones every hour
|
||||
// 30 days retention
|
||||
if (_client.ShardId == 0)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.GetTable<StickyRole>()
|
||||
.Where(x => x.DateAdded < DateTime.UtcNow - TimeSpan.FromDays(30))
|
||||
.DeleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task ClientOnUserLeft(SocketGuild guild, SocketUser user)
|
||||
{
|
||||
if (user is not SocketGuildUser gu)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (!_stickyRoles.Contains(guild.Id))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () => await SaveRolesAsync(guild.Id, gu.Id, gu.Roles));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task SaveRolesAsync(ulong guildId, ulong userId, IReadOnlyCollection<SocketRole> guRoles)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.GetTable<StickyRole>()
|
||||
.InsertAsync(() => new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
UserId = userId,
|
||||
RoleIds = string.Join(',',
|
||||
guRoles.Where(x => !x.IsEveryone && !x.IsManaged).Select(x => x.Id.ToString())),
|
||||
DateAdded = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private Task ClientOnUserJoined(SocketGuildUser user)
|
||||
{
|
||||
if (!_stickyRoles.Contains(user.Guild.Id))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var roles = await GetRolesAsync(user.Guild.Id, user.Id);
|
||||
|
||||
await user.AddRolesAsync(roles);
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<ulong[]> GetRolesAsync(ulong guildId, ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var stickyRolesEntry = await ctx
|
||||
.GetTable<StickyRole>()
|
||||
.Where(x => x.GuildId == guildId && x.UserId == userId)
|
||||
.DeleteWithOutputAsync();
|
||||
|
||||
if (stickyRolesEntry is { Length: > 0 })
|
||||
{
|
||||
return stickyRolesEntry[0].GetRoleIds();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public async Task<bool> ToggleStickyRoles(ulong guildId, bool? newState = null)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var config = ctx.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
config.StickyRoles = newState ?? !config.StickyRoles;
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
if (config.StickyRoles)
|
||||
{
|
||||
_stickyRoles.Add(guildId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_stickyRoles.Remove(guildId);
|
||||
}
|
||||
|
||||
return config.StickyRoles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Self;
|
||||
|
||||
public sealed class CheckForUpdatesService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly BotConfigService _bcs;
|
||||
private readonly IBotCredsProvider _bcp;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IEmbedBuilderService _ebs;
|
||||
|
||||
public CheckForUpdatesService(BotConfigService bcs, IBotCredsProvider bcp, IHttpClientFactory httpFactory,
|
||||
DiscordSocketClient client, IEmbedBuilderService ebs)
|
||||
{
|
||||
_bcs = bcs;
|
||||
_bcp = bcp;
|
||||
_httpFactory = httpFactory;
|
||||
_client = client;
|
||||
_ebs = ebs;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
var conf = _bcs.Data;
|
||||
|
||||
if (!conf.CheckForUpdates)
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
const string URL = "https://cdn.nadeko.bot/cmds/versions.json";
|
||||
using var http = _httpFactory.CreateClient();
|
||||
var versions = await http.GetFromJsonAsync<string[]>(URL);
|
||||
|
||||
if (versions is null)
|
||||
continue;
|
||||
|
||||
var latest = versions[0];
|
||||
var latestVersion = Version.Parse(latest);
|
||||
var lastKnownVersion = GetLastKnownVersion();
|
||||
|
||||
if (lastKnownVersion is null)
|
||||
{
|
||||
UpdateLastKnownVersion(latestVersion);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (latestVersion > lastKnownVersion)
|
||||
{
|
||||
UpdateLastKnownVersion(latestVersion);
|
||||
|
||||
// pull changelog
|
||||
var changelog = await http.GetStringAsync("https://gitlab.com/Kwoth/nadekobot/-/raw/v4/CHANGELOG.md");
|
||||
|
||||
var thisVersionChangelog = GetVersionChangelog(latestVersion, changelog);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(thisVersionChangelog))
|
||||
{
|
||||
Log.Warning("New version {BotVersion} was found but changelog is unavailable",
|
||||
thisVersionChangelog);
|
||||
continue;
|
||||
}
|
||||
|
||||
var creds = _bcp.GetCreds();
|
||||
await creds.OwnerIds
|
||||
.Select(async x =>
|
||||
{
|
||||
var user = await _client.GetUserAsync(x);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
var eb = _ebs.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor($"NadekoBot v{latestVersion} Released!")
|
||||
.WithTitle("Changelog")
|
||||
.WithUrl("https://gitlab.com/Kwoth/nadekobot/-/blob/v4/CHANGELOG.md")
|
||||
.WithDescription(thisVersionChangelog.TrimTo(4096))
|
||||
.WithFooter("You may disable these messages by typing '.conf bot checkforupdates false'");
|
||||
|
||||
await user.EmbedAsync(eb);
|
||||
}).WhenAll();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error while checking for new bot release: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetVersionChangelog(Version latestVersion, string changelog)
|
||||
{
|
||||
var clSpan = changelog.AsSpan();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var started = false;
|
||||
foreach (var line in clSpan.EnumerateLines())
|
||||
{
|
||||
// if we're at the current version, keep reading lines and adding to the output
|
||||
if (started)
|
||||
{
|
||||
// if we got to previous version, end
|
||||
if (line.StartsWith("## ["))
|
||||
break;
|
||||
|
||||
// if we're reading a new segment, reformat it to print it better to discord
|
||||
if (line.StartsWith("### "))
|
||||
{
|
||||
sb.AppendLine(Format.Bold(line.ToString()));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(line.ToString());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith($"## [{latestVersion.ToString()}]"))
|
||||
{
|
||||
started = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private const string LAST_KNOWN_VERSION_PATH = "data/last_known_version.txt";
|
||||
private Version? GetLastKnownVersion()
|
||||
{
|
||||
if (!File.Exists(LAST_KNOWN_VERSION_PATH))
|
||||
return null;
|
||||
|
||||
return Version.TryParse(File.ReadAllText(LAST_KNOWN_VERSION_PATH), out var ver)
|
||||
? ver
|
||||
: null;
|
||||
}
|
||||
|
||||
private void UpdateLastKnownVersion(Version version)
|
||||
{
|
||||
File.WriteAllText("data/last_known_version.txt", version.ToString());
|
||||
}
|
||||
}
|
||||
619
src/NadekoBot/Modules/Administration/Self/SelfCommands.cs
Normal file
619
src/NadekoBot/Modules/Administration/Self/SelfCommands.cs
Normal file
@@ -0,0 +1,619 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using Nadeko.Common.Medusa;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class SelfCommands : NadekoModule<SelfService>
|
||||
{
|
||||
public enum SettableUserStatus
|
||||
{
|
||||
Online,
|
||||
Invisible,
|
||||
Idle,
|
||||
Dnd
|
||||
}
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly IMedusaLoaderService _medusaLoader;
|
||||
private readonly ICoordinator _coord;
|
||||
private readonly DbService _db;
|
||||
|
||||
public SelfCommands(
|
||||
DiscordSocketClient client,
|
||||
DbService db,
|
||||
IBotStrings strings,
|
||||
ICoordinator coord,
|
||||
IMedusaLoaderService medusaLoader)
|
||||
{
|
||||
_client = client;
|
||||
_db = db;
|
||||
_strings = strings;
|
||||
_coord = coord;
|
||||
_medusaLoader = medusaLoader;
|
||||
}
|
||||
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public Task CacheUsers()
|
||||
=> CacheUsers(ctx.Guild);
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task CacheUsers(IGuild guild)
|
||||
{
|
||||
var downloadUsersTask = guild.DownloadUsersAsync();
|
||||
var message = await ReplyPendingLocalizedAsync(strs.cache_users_pending);
|
||||
using var dbContext = _db.GetDbContext();
|
||||
|
||||
await downloadUsersTask;
|
||||
|
||||
var users = (await guild.GetUsersAsync(CacheMode.CacheOnly))
|
||||
.Cast<IUser>()
|
||||
.ToList();
|
||||
|
||||
var (added, updated) = await _service.RefreshUsersAsync(users);
|
||||
|
||||
await message.ModifyAsync(x =>
|
||||
x.Embed = _eb.Create()
|
||||
.WithDescription(GetText(strs.cache_users_done(added, updated)))
|
||||
.WithOkColor()
|
||||
.Build()
|
||||
);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task DoAs(IUser user, [Leftover] string message)
|
||||
{
|
||||
if (ctx.User is not IGuildUser { GuildPermissions.Administrator: true })
|
||||
return;
|
||||
|
||||
if (ctx.Guild is SocketGuild sg && ctx.Channel is ISocketMessageChannel ch
|
||||
&& ctx.Message is SocketUserMessage msg)
|
||||
{
|
||||
var fakeMessage = new DoAsUserMessage(msg, user, message);
|
||||
|
||||
await _cmdHandler.TryRunCommand(sg, ch, fakeMessage);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.error_occured);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task StartupCommandAdd([Leftover] string cmdText)
|
||||
{
|
||||
if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
|
||||
return;
|
||||
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
var cmd = new AutoCommand
|
||||
{
|
||||
CommandText = cmdText,
|
||||
ChannelId = ctx.Channel.Id,
|
||||
ChannelName = ctx.Channel.Name,
|
||||
GuildId = ctx.Guild?.Id,
|
||||
GuildName = ctx.Guild?.Name,
|
||||
VoiceChannelId = guser.VoiceChannel?.Id,
|
||||
VoiceChannelName = guser.VoiceChannel?.Name,
|
||||
Interval = 0
|
||||
};
|
||||
_service.AddNewAutoCommand(cmd);
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.scadd))
|
||||
.AddField(GetText(strs.server),
|
||||
cmd.GuildId is null ? "-" : $"{cmd.GuildName}/{cmd.GuildId}",
|
||||
true)
|
||||
.AddField(GetText(strs.channel), $"{cmd.ChannelName}/{cmd.ChannelId}", true)
|
||||
.AddField(GetText(strs.command_text), cmdText));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task AutoCommandAdd(int interval, [Leftover] string cmdText)
|
||||
{
|
||||
if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
|
||||
return;
|
||||
|
||||
if (interval < 5)
|
||||
return;
|
||||
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
var cmd = new AutoCommand
|
||||
{
|
||||
CommandText = cmdText,
|
||||
ChannelId = ctx.Channel.Id,
|
||||
ChannelName = ctx.Channel.Name,
|
||||
GuildId = ctx.Guild?.Id,
|
||||
GuildName = ctx.Guild?.Name,
|
||||
VoiceChannelId = guser.VoiceChannel?.Id,
|
||||
VoiceChannelName = guser.VoiceChannel?.Name,
|
||||
Interval = interval
|
||||
};
|
||||
_service.AddNewAutoCommand(cmd);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task StartupCommandsList(int page = 1)
|
||||
{
|
||||
if (page-- < 1)
|
||||
return;
|
||||
|
||||
var scmds = _service.GetStartupCommands().Skip(page * 5).Take(5).ToList();
|
||||
|
||||
if (scmds.Count == 0)
|
||||
await ReplyErrorLocalizedAsync(strs.startcmdlist_none);
|
||||
else
|
||||
{
|
||||
var i = 0;
|
||||
await SendConfirmAsync(text: string.Join("\n",
|
||||
scmds.Select(x => $@"```css
|
||||
#{++i + (page * 5)}
|
||||
[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")}
|
||||
[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId}
|
||||
[{GetText(strs.command_text)}]: {x.CommandText}```")),
|
||||
title: string.Empty,
|
||||
footer: GetText(strs.page(page + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task AutoCommandsList(int page = 1)
|
||||
{
|
||||
if (page-- < 1)
|
||||
return;
|
||||
|
||||
var scmds = _service.GetAutoCommands().Skip(page * 5).Take(5).ToList();
|
||||
if (!scmds.Any())
|
||||
await ReplyErrorLocalizedAsync(strs.autocmdlist_none);
|
||||
else
|
||||
{
|
||||
var i = 0;
|
||||
await SendConfirmAsync(text: string.Join("\n",
|
||||
scmds.Select(x => $@"```css
|
||||
#{++i + (page * 5)}
|
||||
[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")}
|
||||
[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId}
|
||||
{GetIntervalText(x.Interval)}
|
||||
[{GetText(strs.command_text)}]: {x.CommandText}```")),
|
||||
title: string.Empty,
|
||||
footer: GetText(strs.page(page + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
private string GetIntervalText(int interval)
|
||||
=> $"[{GetText(strs.interval)}]: {interval}";
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task Wait(int miliseconds)
|
||||
{
|
||||
if (miliseconds <= 0)
|
||||
return;
|
||||
ctx.Message.DeleteAfter(0);
|
||||
try
|
||||
{
|
||||
var msg = await SendConfirmAsync($"⏲ {miliseconds}ms");
|
||||
msg.DeleteAfter(miliseconds / 1000);
|
||||
}
|
||||
catch { }
|
||||
|
||||
await Task.Delay(miliseconds);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task AutoCommandRemove([Leftover] int index)
|
||||
{
|
||||
if (!_service.RemoveAutoCommand(--index, out _))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.acrm_fail);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task StartupCommandRemove([Leftover] int index)
|
||||
{
|
||||
if (!_service.RemoveStartupCommand(--index, out _))
|
||||
await ReplyErrorLocalizedAsync(strs.scrm_fail);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.scrm);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task StartupCommandsClear()
|
||||
{
|
||||
_service.ClearStartupCommands();
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.startcmds_cleared);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task ForwardMessages()
|
||||
{
|
||||
var enabled = _service.ForwardMessages();
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.fwdm_start);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.fwdm_stop);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task ForwardToAll()
|
||||
{
|
||||
var enabled = _service.ForwardToAll();
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.fwall_start);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.fwall_stop);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[OwnerOnly]
|
||||
public async Task ForwardToChannel()
|
||||
{
|
||||
var enabled = _service.ForwardToChannel(ctx.Channel.Id);
|
||||
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.fwch_start);
|
||||
else
|
||||
await ReplyPendingLocalizedAsync(strs.fwch_stop);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task ShardStats(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
var statuses = _coord.GetAllShardStatuses();
|
||||
|
||||
var status = string.Join(" : ",
|
||||
statuses.Select(x => (ConnectionStateToEmoji(x), x))
|
||||
.GroupBy(x => x.Item1)
|
||||
.Select(x => $"`{x.Count()} {x.Key}`")
|
||||
.ToArray());
|
||||
|
||||
var allShardStrings = statuses.Select(st =>
|
||||
{
|
||||
var timeDiff = DateTime.UtcNow - st.LastUpdate;
|
||||
var stateStr = ConnectionStateToEmoji(st);
|
||||
var maxGuildCountLength =
|
||||
statuses.Max(x => x.GuildCount).ToString().Length;
|
||||
return $"`{stateStr} "
|
||||
+ $"| #{st.ShardId.ToString().PadBoth(3)} "
|
||||
+ $"| {timeDiff:mm\\:ss} "
|
||||
+ $"| {st.GuildCount.ToString().PadBoth(maxGuildCountLength)} `";
|
||||
})
|
||||
.ToArray();
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var str = string.Join("\n", allShardStrings.Skip(25 * curPage).Take(25));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(str))
|
||||
str = GetText(strs.no_shards_on_page);
|
||||
|
||||
return _eb.Create().WithOkColor().WithDescription($"{status}\n\n{str}");
|
||||
},
|
||||
allShardStrings.Length,
|
||||
25);
|
||||
}
|
||||
|
||||
private static string ConnectionStateToEmoji(ShardStatus status)
|
||||
{
|
||||
var timeDiff = DateTime.UtcNow - status.LastUpdate;
|
||||
return status.ConnectionState switch
|
||||
{
|
||||
ConnectionState.Disconnected => "🔻",
|
||||
_ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ",
|
||||
ConnectionState.Connected => "✅",
|
||||
_ => " ⏳"
|
||||
};
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task RestartShard(int shardId)
|
||||
{
|
||||
var success = _coord.RestartShard(shardId);
|
||||
if (success)
|
||||
await ReplyConfirmLocalizedAsync(strs.shard_reconnecting(Format.Bold("#" + shardId)));
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.no_shard_id);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task Leave([Leftover] string guildStr)
|
||||
=> _service.LeaveGuild(guildStr);
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task DeleteEmptyServers()
|
||||
{
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
var toLeave = _client.Guilds
|
||||
.Where(s => s.MemberCount == 1 && s.Users.Count == 1)
|
||||
.ToList();
|
||||
|
||||
foreach (var server in toLeave)
|
||||
{
|
||||
try
|
||||
{
|
||||
await server.DeleteAsync();
|
||||
Log.Information("Deleted server {ServerName} [{ServerId}]",
|
||||
server.Name,
|
||||
server.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"Error leaving server {ServerName} [{ServerId}]",
|
||||
server.Name,
|
||||
server.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.deleted_x_servers(toLeave.Count));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task Die(bool graceful = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.SetStatusAsync(UserStatus.Invisible);
|
||||
_ = _client.StopAsync();
|
||||
await ReplyConfirmLocalizedAsync(strs.shutting_down);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
await Task.Delay(2000);
|
||||
_coord.Die(graceful);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task Restart()
|
||||
{
|
||||
var success = _coord.RestartBot();
|
||||
if (!success)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.restart_fail);
|
||||
return;
|
||||
}
|
||||
|
||||
try { await ReplyConfirmLocalizedAsync(strs.restarting); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetName([Leftover] string newName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.CurrentUser.ModifyAsync(u => u.Username = newName);
|
||||
}
|
||||
catch (RateLimitedException)
|
||||
{
|
||||
Log.Warning("You've been ratelimited. Wait 2 hours to change your name");
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.bot_name(Format.Bold(newName)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[UserPerm(GuildPerm.ManageNicknames)]
|
||||
[BotPerm(GuildPerm.ChangeNickname)]
|
||||
[Priority(0)]
|
||||
public async Task SetNick([Leftover] string newNick = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newNick))
|
||||
return;
|
||||
var curUser = await ctx.Guild.GetCurrentUserAsync();
|
||||
await curUser.ModifyAsync(u => u.Nickname = newNick);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.bot_nick(Format.Bold(newNick) ?? "-"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[BotPerm(GuildPerm.ManageNicknames)]
|
||||
[UserPerm(GuildPerm.ManageNicknames)]
|
||||
[Priority(1)]
|
||||
public async Task SetNick(IGuildUser gu, [Leftover] string newNick = null)
|
||||
{
|
||||
var sg = (SocketGuild)ctx.Guild;
|
||||
if (sg.OwnerId == gu.Id
|
||||
|| gu.GetRoles().Max(r => r.Position) >= sg.CurrentUser.GetRoles().Max(r => r.Position))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.insuf_perms_i);
|
||||
return;
|
||||
}
|
||||
|
||||
await gu.ModifyAsync(u => u.Nickname = newNick);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-"));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetStatus([Leftover] SettableUserStatus status)
|
||||
{
|
||||
await _client.SetStatusAsync(SettableUserStatusToUserStatus(status));
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.bot_status(Format.Bold(status.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetAvatar([Leftover] string img = null)
|
||||
{
|
||||
var success = await _service.SetAvatar(img);
|
||||
|
||||
if (success)
|
||||
await ReplyConfirmLocalizedAsync(strs.set_avatar);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetBanner([Leftover] string img = null)
|
||||
{
|
||||
var success = await _service.SetBanner(img);
|
||||
|
||||
if (success)
|
||||
await ReplyConfirmLocalizedAsync(strs.set_banner);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetGame(ActivityType type, [Leftover] string game = null)
|
||||
{
|
||||
// var rep = new ReplacementBuilder().WithDefault(Context).Build();
|
||||
|
||||
var repCtx = new ReplacementContext(ctx);
|
||||
await _service.SetGameAsync(game is null ? game : await repSvc.ReplaceAsync(game, repCtx), type);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.set_game);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task SetStream(string url, [Leftover] string name = null)
|
||||
{
|
||||
name ??= "";
|
||||
|
||||
await _service.SetStreamAsync(name, url);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.set_stream);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task Send(string where, [Leftover] SmartText text = null)
|
||||
{
|
||||
var ids = where.Split('|');
|
||||
if (ids.Length != 2)
|
||||
return;
|
||||
|
||||
var sid = ulong.Parse(ids[0]);
|
||||
var server = _client.Guilds.FirstOrDefault(s => s.Id == sid);
|
||||
|
||||
if (server is null)
|
||||
return;
|
||||
|
||||
// var repSvc = new ReplacementBuilder().WithDefault(Context).Build();
|
||||
var repCtx = new ReplacementContext(Context);
|
||||
|
||||
if (ids[1].ToUpperInvariant().StartsWith("C:", StringComparison.InvariantCulture))
|
||||
{
|
||||
var cid = ulong.Parse(ids[1][2..]);
|
||||
var ch = server.TextChannels.FirstOrDefault(c => c.Id == cid);
|
||||
if (ch is null)
|
||||
return;
|
||||
|
||||
text = await repSvc.ReplaceAsync(text, repCtx);
|
||||
await ch.SendAsync(text);
|
||||
}
|
||||
else if (ids[1].ToUpperInvariant().StartsWith("U:", StringComparison.InvariantCulture))
|
||||
{
|
||||
var uid = ulong.Parse(ids[1][2..]);
|
||||
var user = server.Users.FirstOrDefault(u => u.Id == uid);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
var ch = await user.CreateDMChannelAsync();
|
||||
text = await repSvc.ReplaceAsync(text, repCtx);
|
||||
await ch.SendAsync(text);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.invalid_format);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.message_sent);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task StringsReload()
|
||||
{
|
||||
_strings.Reload();
|
||||
await _medusaLoader.ReloadStrings();
|
||||
await ReplyConfirmLocalizedAsync(strs.bot_strings_reloaded);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task CoordReload()
|
||||
{
|
||||
await _coord.Reload();
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
private static UserStatus SettableUserStatusToUserStatus(SettableUserStatus sus)
|
||||
{
|
||||
switch (sus)
|
||||
{
|
||||
case SettableUserStatus.Online:
|
||||
return UserStatus.Online;
|
||||
case SettableUserStatus.Invisible:
|
||||
return UserStatus.Invisible;
|
||||
case SettableUserStatus.Idle:
|
||||
return UserStatus.AFK;
|
||||
case SettableUserStatus.Dnd:
|
||||
return UserStatus.DoNotDisturb;
|
||||
}
|
||||
|
||||
return UserStatus.Online;
|
||||
}
|
||||
}
|
||||
}
|
||||
486
src/NadekoBot/Modules/Administration/Self/SelfService.cs
Normal file
486
src/NadekoBot/Modules/Administration/Self/SelfService.cs
Normal file
@@ -0,0 +1,486 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using System.Collections.Immutable;
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
|
||||
{
|
||||
private readonly CommandHandler _cmdHandler;
|
||||
private readonly DbService _db;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
private readonly IBotCredentials _creds;
|
||||
|
||||
private ImmutableDictionary<ulong, IDMChannel> ownerChannels =
|
||||
new Dictionary<ulong, IDMChannel>().ToImmutableDictionary();
|
||||
|
||||
private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
|
||||
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly BotConfigService _bss;
|
||||
private readonly IPubSub _pubSub;
|
||||
private readonly IEmbedBuilderService _eb;
|
||||
|
||||
//keys
|
||||
private readonly TypedKey<ActivityPubData> _activitySetKey;
|
||||
private readonly TypedKey<string> _guildLeaveKey;
|
||||
|
||||
public SelfService(
|
||||
DiscordSocketClient client,
|
||||
CommandHandler cmdHandler,
|
||||
DbService db,
|
||||
IBotStrings strings,
|
||||
IBotCredentials creds,
|
||||
IHttpClientFactory factory,
|
||||
BotConfigService bss,
|
||||
IPubSub pubSub,
|
||||
IEmbedBuilderService eb)
|
||||
{
|
||||
_cmdHandler = cmdHandler;
|
||||
_db = db;
|
||||
_strings = strings;
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_httpFactory = factory;
|
||||
_bss = bss;
|
||||
_pubSub = pubSub;
|
||||
_eb = eb;
|
||||
_activitySetKey = new("activity.set");
|
||||
_guildLeaveKey = new("guild.leave");
|
||||
|
||||
HandleStatusChanges();
|
||||
|
||||
_pubSub.Sub(_guildLeaveKey,
|
||||
async input =>
|
||||
{
|
||||
var guildStr = input.ToString().Trim().ToUpperInvariant();
|
||||
if (string.IsNullOrWhiteSpace(guildStr))
|
||||
return;
|
||||
|
||||
var server = _client.Guilds.FirstOrDefault(g => g.Id.ToString() == guildStr
|
||||
|| g.Name.Trim().ToUpperInvariant() == guildStr);
|
||||
if (server is null)
|
||||
return;
|
||||
|
||||
if (server.OwnerId != _client.CurrentUser.Id)
|
||||
{
|
||||
await server.LeaveAsync();
|
||||
Log.Information("Left server {Name} [{Id}]", server.Name, server.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
await server.DeleteAsync();
|
||||
Log.Information("Deleted server {Name} [{Id}]", server.Name, server.Id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
autoCommands = uow.Set<AutoCommand>().AsNoTracking()
|
||||
.Where(x => x.Interval >= 5)
|
||||
.AsEnumerable()
|
||||
.GroupBy(x => x.GuildId)
|
||||
.ToDictionary(x => x.Key,
|
||||
y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent())
|
||||
.ToConcurrent();
|
||||
|
||||
var startupCommands = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0);
|
||||
foreach (var cmd in startupCommands)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExecuteCommand(cmd);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
if (_client.ShardId == 0)
|
||||
await LoadOwnerChannels();
|
||||
}
|
||||
|
||||
private Timer TimerFromAutoCommand(AutoCommand x)
|
||||
=> new(async obj => await ExecuteCommand((AutoCommand)obj), x, x.Interval * 1000, x.Interval * 1000);
|
||||
|
||||
private async Task ExecuteCommand(AutoCommand cmd)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (cmd.GuildId is null)
|
||||
return;
|
||||
|
||||
var guildShard = (int)((cmd.GuildId.Value >> 22) % (ulong)_creds.TotalShards);
|
||||
if (guildShard != _client.ShardId)
|
||||
return;
|
||||
var prefix = _cmdHandler.GetPrefix(cmd.GuildId);
|
||||
//if someone already has .die as their startup command, ignore it
|
||||
if (cmd.CommandText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
|
||||
return;
|
||||
await _cmdHandler.ExecuteExternal(cmd.GuildId, cmd.ChannelId, cmd.CommandText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in SelfService ExecuteCommand");
|
||||
}
|
||||
}
|
||||
|
||||
public void AddNewAutoCommand(AutoCommand cmd)
|
||||
{
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
uow.Set<AutoCommand>().Add(cmd);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
if (cmd.Interval >= 5)
|
||||
{
|
||||
var autos = autoCommands.GetOrAdd(cmd.GuildId, new ConcurrentDictionary<int, Timer>());
|
||||
autos.AddOrUpdate(cmd.Id,
|
||||
_ => TimerFromAutoCommand(cmd),
|
||||
(_, old) =>
|
||||
{
|
||||
old.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
return TimerFromAutoCommand(cmd);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<AutoCommand> GetStartupCommands()
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0).OrderBy(x => x.Id).ToList();
|
||||
}
|
||||
|
||||
public IEnumerable<AutoCommand> GetAutoCommands()
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval >= 5).OrderBy(x => x.Id).ToList();
|
||||
}
|
||||
|
||||
private async Task LoadOwnerChannels()
|
||||
{
|
||||
var channels = await _creds.OwnerIds.Select(id =>
|
||||
{
|
||||
var user = _client.GetUser(id);
|
||||
if (user is null)
|
||||
return Task.FromResult<IDMChannel>(null);
|
||||
|
||||
return user.CreateDMChannelAsync();
|
||||
})
|
||||
.WhenAll();
|
||||
|
||||
ownerChannels = channels.Where(x => x is not null)
|
||||
.ToDictionary(x => x.Recipient.Id, x => x)
|
||||
.ToImmutableDictionary();
|
||||
|
||||
if (!ownerChannels.Any())
|
||||
{
|
||||
Log.Warning(
|
||||
"No owner channels created! Make sure you've specified the correct OwnerId in the creds.yml file and invited the bot to a Discord server");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Created {OwnerChannelCount} out of {TotalOwnerChannelCount} owner message channels",
|
||||
ownerChannels.Count,
|
||||
_creds.OwnerIds.Count);
|
||||
}
|
||||
}
|
||||
|
||||
public Task LeaveGuild(string guildStr)
|
||||
=> _pubSub.Pub(_guildLeaveKey, guildStr);
|
||||
|
||||
// forwards dms
|
||||
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
|
||||
{
|
||||
var bs = _bss.Data;
|
||||
if (msg.Channel is IDMChannel && bs.ForwardMessages && (ownerChannels.Any() || bs.ForwardToChannel is not null))
|
||||
{
|
||||
var title = _strings.GetText(strs.dm_from) + $" [{msg.Author}]({msg.Author.Id})";
|
||||
|
||||
var attachamentsTxt = _strings.GetText(strs.attachments);
|
||||
|
||||
var toSend = msg.Content;
|
||||
|
||||
if (msg.Attachments.Count > 0)
|
||||
{
|
||||
toSend += $"\n\n{Format.Code(attachamentsTxt)}:\n"
|
||||
+ string.Join("\n", msg.Attachments.Select(a => a.ProxyUrl));
|
||||
}
|
||||
|
||||
if (bs.ForwardToAllOwners)
|
||||
{
|
||||
var allOwnerChannels = ownerChannels.Values;
|
||||
|
||||
foreach (var ownerCh in allOwnerChannels.Where(ch => ch.Recipient.Id != msg.Author.Id))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ownerCh.SendConfirmAsync(_eb, title, toSend);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Can't contact owner with id {OwnerId}", ownerCh.Recipient.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (bs.ForwardToChannel is ulong cid)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client.GetChannel(cid) is ITextChannel ch)
|
||||
await ch.SendConfirmAsync(_eb, title, toSend);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Error forwarding message to the channel");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var firstOwnerChannel = ownerChannels.Values.First();
|
||||
if (firstOwnerChannel.Recipient.Id != msg.Author.Id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await firstOwnerChannel.SendConfirmAsync(_eb, title, toSend);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveStartupCommand(int index, out AutoCommand cmd)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
cmd = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0).Skip(index).FirstOrDefault();
|
||||
|
||||
if (cmd is not null)
|
||||
{
|
||||
uow.Remove(cmd);
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool RemoveAutoCommand(int index, out AutoCommand cmd)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
cmd = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval >= 5).Skip(index).FirstOrDefault();
|
||||
|
||||
if (cmd is not null)
|
||||
{
|
||||
uow.Remove(cmd);
|
||||
if (autoCommands.TryGetValue(cmd.GuildId, out var autos))
|
||||
{
|
||||
if (autos.TryRemove(cmd.Id, out var timer))
|
||||
timer.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> SetAvatar(string img)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(img))
|
||||
return false;
|
||||
|
||||
if (!Uri.IsWellFormedUriString(img, UriKind.Absolute))
|
||||
return false;
|
||||
|
||||
var uri = new Uri(img);
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||
if (!sr.IsImage())
|
||||
return false;
|
||||
|
||||
// i can't just do ReadAsStreamAsync because dicord.net's image poops itself
|
||||
var imgData = await sr.Content.ReadAsByteArrayAsync();
|
||||
await using var imgStream = imgData.ToStream();
|
||||
await _client.CurrentUser.ModifyAsync(u => u.Avatar = new Image(imgStream));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SetBanner(string img)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(img))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.IsWellFormedUriString(img, UriKind.Absolute))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var uri = new Uri(img);
|
||||
|
||||
using var http = _httpFactory.CreateClient();
|
||||
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
if (!sr.IsImage())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (sr.GetContentLength() > 8.Megabytes().Bytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await using var imageStream = await sr.Content.ReadAsStreamAsync();
|
||||
|
||||
await _client.CurrentUser.ModifyAsync(x => x.Banner = new Image(imageStream));
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public void ClearStartupCommands()
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var toRemove = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0);
|
||||
|
||||
uow.Set<AutoCommand>().RemoveRange(toRemove);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
public bool ForwardMessages()
|
||||
{
|
||||
var isForwarding = false;
|
||||
_bss.ModifyConfig(config => { isForwarding = config.ForwardMessages = !config.ForwardMessages; });
|
||||
|
||||
return isForwarding;
|
||||
}
|
||||
|
||||
public bool ForwardToAll()
|
||||
{
|
||||
var isToAll = false;
|
||||
_bss.ModifyConfig(config => { isToAll = config.ForwardToAllOwners = !config.ForwardToAllOwners; });
|
||||
return isToAll;
|
||||
}
|
||||
|
||||
public bool ForwardToChannel(ulong? channelId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
|
||||
_bss.ModifyConfig(config =>
|
||||
{
|
||||
config.ForwardToChannel = channelId == config.ForwardToChannel
|
||||
? null
|
||||
: channelId;
|
||||
});
|
||||
|
||||
return channelId is not null;
|
||||
}
|
||||
|
||||
private void HandleStatusChanges()
|
||||
=> _pubSub.Sub(_activitySetKey,
|
||||
async data =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.SetGameAsync(data.Name, data.Link, data.Type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error setting activity");
|
||||
}
|
||||
});
|
||||
|
||||
public Task SetGameAsync(string game, ActivityType type)
|
||||
=> _pubSub.Pub(_activitySetKey,
|
||||
new()
|
||||
{
|
||||
Name = game,
|
||||
Link = null,
|
||||
Type = type
|
||||
});
|
||||
|
||||
public Task SetStreamAsync(string name, string link)
|
||||
=> _pubSub.Pub(_activitySetKey,
|
||||
new()
|
||||
{
|
||||
Name = name,
|
||||
Link = link,
|
||||
Type = ActivityType.Streaming
|
||||
});
|
||||
|
||||
private sealed class ActivityPubData
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public string Link { get; init; }
|
||||
public ActivityType Type { get; init; }
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <paramref name="users"/> to the database. If a database user with placeholder name
|
||||
/// and discriminator is present in <paramref name="users"/>, their name and discriminator get updated accordingly.
|
||||
/// </summary>
|
||||
/// <param name="ctx">This database context.</param>
|
||||
/// <param name="users">The users to add or update in the database.</param>
|
||||
/// <returns>A tuple with the amount of new users added and old users updated.</returns>
|
||||
public async Task<(long UsersAdded, long UsersUpdated)> RefreshUsersAsync(List<IUser> users)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var presentDbUsers = await ctx.GetTable<DiscordUser>()
|
||||
.Select(x => new { x.UserId, x.Username, x.Discriminator })
|
||||
.Where(x => users.Select(y => y.Id).Contains(x.UserId))
|
||||
.ToArrayAsyncEF();
|
||||
|
||||
var usersToAdd = users
|
||||
.Where(x => !presentDbUsers.Select(x => x.UserId).Contains(x.Id))
|
||||
.Select(x => new DiscordUser()
|
||||
{
|
||||
UserId = x.Id,
|
||||
AvatarId = x.AvatarId,
|
||||
Username = x.Username,
|
||||
Discriminator = x.Discriminator
|
||||
});
|
||||
|
||||
var added = (await ctx.BulkCopyAsync(usersToAdd)).RowsCopied;
|
||||
var toUpdateUserIds = presentDbUsers
|
||||
.Where(x => x.Username == "Unknown" && x.Discriminator == "????")
|
||||
.Select(x => x.UserId)
|
||||
.ToArray();
|
||||
|
||||
foreach (var user in users.Where(x => toUpdateUserIds.Contains(x.Id)))
|
||||
{
|
||||
await ctx.GetTable<DiscordUser>()
|
||||
.Where(x => x.UserId == user.Id)
|
||||
.UpdateAsync(x => new DiscordUser()
|
||||
{
|
||||
Username = user.Username,
|
||||
Discriminator = user.Discriminator,
|
||||
|
||||
// .award tends to set AvatarId and DateAdded to NULL, so account for that.
|
||||
AvatarId = user.AvatarId,
|
||||
DateAdded = x.DateAdded ?? DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
return (added, toUpdateUserIds.Length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using System.Text;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class SelfAssignedRolesCommands : NadekoModule<SelfAssignedRolesService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
[BotPerm(GuildPerm.ManageMessages)]
|
||||
public async Task AdSarm()
|
||||
{
|
||||
var newVal = _service.ToggleAdSarm(ctx.Guild.Id);
|
||||
|
||||
if (newVal)
|
||||
await ReplyConfirmLocalizedAsync(strs.adsarm_enable(prefix));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.adsarm_disable(prefix));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(1)]
|
||||
public Task Asar([Leftover] IRole role)
|
||||
=> Asar(0, role);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(0)]
|
||||
public async Task Asar(int group, [Leftover] IRole role)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
|
||||
return;
|
||||
|
||||
var succ = _service.AddNew(ctx.Guild.Id, role, group);
|
||||
|
||||
if (succ)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.role_added(Format.Bold(role.Name),
|
||||
Format.Bold(@group.ToString())));
|
||||
}
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.role_in_list(Format.Bold(role.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[Priority(0)]
|
||||
public async Task Sargn(int group, [Leftover] string name = null)
|
||||
{
|
||||
var set = await _service.SetNameAsync(ctx.Guild.Id, group, name);
|
||||
|
||||
if (set)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(
|
||||
strs.group_name_added(Format.Bold(@group.ToString()), Format.Bold(name)));
|
||||
}
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.group_name_removed(Format.Bold(group.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
public async Task Rsar([Leftover] IRole role)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
|
||||
return;
|
||||
|
||||
var success = _service.RemoveSar(role.Guild.Id, role.Id);
|
||||
if (!success)
|
||||
await ReplyErrorLocalizedAsync(strs.self_assign_not);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.self_assign_rem(Format.Bold(role.Name)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Lsar(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
|
||||
var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
cur =>
|
||||
{
|
||||
var rolesStr = new StringBuilder();
|
||||
var roleGroups = roles.OrderBy(x => x.Model.Group)
|
||||
.Skip(cur * 20)
|
||||
.Take(20)
|
||||
.GroupBy(x => x.Model.Group)
|
||||
.OrderBy(x => x.Key);
|
||||
|
||||
foreach (var kvp in roleGroups)
|
||||
{
|
||||
string groupNameText;
|
||||
if (!groups.TryGetValue(kvp.Key, out var name))
|
||||
groupNameText = Format.Bold(GetText(strs.self_assign_group(kvp.Key)));
|
||||
else
|
||||
groupNameText = Format.Bold($"{kvp.Key} - {name.TrimTo(25, true)}");
|
||||
|
||||
rolesStr.AppendLine("\t\t\t\t ⟪" + groupNameText + "⟫");
|
||||
foreach (var (model, role) in kvp.AsEnumerable())
|
||||
{
|
||||
if (role is null)
|
||||
{
|
||||
}
|
||||
else
|
||||
{
|
||||
// first character is invisible space
|
||||
if (model.LevelRequirement == 0)
|
||||
rolesStr.AppendLine(" " + role.Name);
|
||||
else
|
||||
rolesStr.AppendLine(" " + role.Name + $" (lvl {model.LevelRequirement}+)");
|
||||
}
|
||||
}
|
||||
|
||||
rolesStr.AppendLine();
|
||||
}
|
||||
|
||||
return _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(Format.Bold(GetText(strs.self_assign_list(roles.Count()))))
|
||||
.WithDescription(rolesStr.ToString())
|
||||
.WithFooter(exclusive
|
||||
? GetText(strs.self_assign_are_exclusive)
|
||||
: GetText(strs.self_assign_are_not_exclusive));
|
||||
},
|
||||
roles.Count(),
|
||||
20);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task Togglexclsar()
|
||||
{
|
||||
var areExclusive = _service.ToggleEsar(ctx.Guild.Id);
|
||||
if (areExclusive)
|
||||
await ReplyConfirmLocalizedAsync(strs.self_assign_excl);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.self_assign_no_excl);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task RoleLevelReq(int level, [Leftover] IRole role)
|
||||
{
|
||||
if (level < 0)
|
||||
return;
|
||||
|
||||
var succ = _service.SetLevelReq(ctx.Guild.Id, role, level);
|
||||
|
||||
if (!succ)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.self_assign_not);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.self_assign_level_req(Format.Bold(role.Name),
|
||||
Format.Bold(level.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Iam([Leftover] IRole role)
|
||||
{
|
||||
var guildUser = (IGuildUser)ctx.User;
|
||||
|
||||
var (result, autoDelete, extra) = await _service.Assign(guildUser, role);
|
||||
|
||||
IUserMessage msg;
|
||||
if (result == SelfAssignedRolesService.AssignResult.ErrNotAssignable)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_not);
|
||||
else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_not_level(Format.Bold(extra.ToString())));
|
||||
else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_already(Format.Bold(role.Name)));
|
||||
else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_perms);
|
||||
else
|
||||
msg = await ReplyConfirmLocalizedAsync(strs.self_assign_success(Format.Bold(role.Name)));
|
||||
|
||||
if (autoDelete)
|
||||
{
|
||||
msg.DeleteAfter(3);
|
||||
ctx.Message.DeleteAfter(3);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Iamnot([Leftover] IRole role)
|
||||
{
|
||||
var guildUser = (IGuildUser)ctx.User;
|
||||
|
||||
var (result, autoDelete) = await _service.Remove(guildUser, role);
|
||||
|
||||
IUserMessage msg;
|
||||
if (result == SelfAssignedRolesService.RemoveResult.ErrNotAssignable)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_not);
|
||||
else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_not_have(Format.Bold(role.Name)));
|
||||
else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms)
|
||||
msg = await ReplyErrorLocalizedAsync(strs.self_assign_perms);
|
||||
else
|
||||
msg = await ReplyConfirmLocalizedAsync(strs.self_assign_remove(Format.Bold(role.Name)));
|
||||
|
||||
if (autoDelete)
|
||||
{
|
||||
msg.DeleteAfter(3);
|
||||
ctx.Message.DeleteAfter(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class SelfAssignedRolesService : INService
|
||||
{
|
||||
public enum AssignResult
|
||||
{
|
||||
Assigned, // successfully removed
|
||||
ErrNotAssignable, // not assignable (error)
|
||||
ErrAlreadyHave, // you already have that role (error)
|
||||
ErrNotPerms, // bot doesn't have perms (error)
|
||||
ErrLvlReq // you are not required level (error)
|
||||
}
|
||||
|
||||
public enum RemoveResult
|
||||
{
|
||||
Removed, // successfully removed
|
||||
ErrNotAssignable, // not assignable (error)
|
||||
ErrNotHave, // you don't have a role you want to remove (error)
|
||||
ErrNotPerms // bot doesn't have perms (error)
|
||||
}
|
||||
|
||||
private readonly DbService _db;
|
||||
|
||||
public SelfAssignedRolesService(DbService db)
|
||||
=> _db = db;
|
||||
|
||||
public bool AddNew(ulong guildId, IRole role, int group)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
|
||||
if (roles.Any(s => s.RoleId == role.Id && s.GuildId == role.Guild.Id))
|
||||
return false;
|
||||
|
||||
uow.Set<SelfAssignedRole>().Add(new()
|
||||
{
|
||||
Group = group,
|
||||
RoleId = role.Id,
|
||||
GuildId = role.Guild.Id
|
||||
});
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ToggleAdSarm(ulong guildId)
|
||||
{
|
||||
bool newval;
|
||||
using var uow = _db.GetDbContext();
|
||||
var config = uow.GuildConfigsForId(guildId, set => set);
|
||||
newval = config.AutoDeleteSelfAssignedRoleMessages = !config.AutoDeleteSelfAssignedRoleMessages;
|
||||
uow.SaveChanges();
|
||||
return newval;
|
||||
}
|
||||
|
||||
public async Task<(AssignResult Result, bool AutoDelete, object extra)> Assign(IGuildUser guildUser, IRole role)
|
||||
{
|
||||
LevelStats userLevelData;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var stats = uow.GetOrCreateUserXpStats(guildUser.Guild.Id, guildUser.Id);
|
||||
userLevelData = new(stats.Xp + stats.AwardedXp);
|
||||
}
|
||||
|
||||
var (autoDelete, exclusive, roles) = GetAdAndRoles(guildUser.Guild.Id);
|
||||
|
||||
var theRoleYouWant = roles.FirstOrDefault(r => r.RoleId == role.Id);
|
||||
if (theRoleYouWant is null)
|
||||
return (AssignResult.ErrNotAssignable, autoDelete, null);
|
||||
if (theRoleYouWant.LevelRequirement > userLevelData.Level)
|
||||
return (AssignResult.ErrLvlReq, autoDelete, theRoleYouWant.LevelRequirement);
|
||||
if (guildUser.RoleIds.Contains(role.Id))
|
||||
return (AssignResult.ErrAlreadyHave, autoDelete, null);
|
||||
|
||||
var roleIds = roles.Where(x => x.Group == theRoleYouWant.Group).Select(x => x.RoleId).ToArray();
|
||||
if (exclusive)
|
||||
{
|
||||
var sameRoles = guildUser.RoleIds.Where(r => roleIds.Contains(r));
|
||||
|
||||
foreach (var roleId in sameRoles)
|
||||
{
|
||||
var sameRole = guildUser.Guild.GetRole(roleId);
|
||||
if (sameRole is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await guildUser.RemoveRoleAsync(sameRole);
|
||||
await Task.Delay(300);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await guildUser.AddRoleAsync(role);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (AssignResult.ErrNotPerms, autoDelete, ex);
|
||||
}
|
||||
|
||||
return (AssignResult.Assigned, autoDelete, null);
|
||||
}
|
||||
|
||||
public async Task<bool> SetNameAsync(ulong guildId, int group, string name)
|
||||
{
|
||||
var set = false;
|
||||
await using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, y => y.Include(x => x.SelfAssignableRoleGroupNames));
|
||||
var toUpdate = gc.SelfAssignableRoleGroupNames.FirstOrDefault(x => x.Number == group);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
if (toUpdate is not null)
|
||||
gc.SelfAssignableRoleGroupNames.Remove(toUpdate);
|
||||
}
|
||||
else if (toUpdate is null)
|
||||
{
|
||||
gc.SelfAssignableRoleGroupNames.Add(new()
|
||||
{
|
||||
Name = name,
|
||||
Number = group
|
||||
});
|
||||
set = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
toUpdate.Name = name;
|
||||
set = true;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
public async Task<(RemoveResult Result, bool AutoDelete)> Remove(IGuildUser guildUser, IRole role)
|
||||
{
|
||||
var (autoDelete, _, roles) = GetAdAndRoles(guildUser.Guild.Id);
|
||||
|
||||
if (roles.FirstOrDefault(r => r.RoleId == role.Id) is null)
|
||||
return (RemoveResult.ErrNotAssignable, autoDelete);
|
||||
if (!guildUser.RoleIds.Contains(role.Id))
|
||||
return (RemoveResult.ErrNotHave, autoDelete);
|
||||
try
|
||||
{
|
||||
await guildUser.RemoveRoleAsync(role);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (RemoveResult.ErrNotPerms, autoDelete);
|
||||
}
|
||||
|
||||
return (RemoveResult.Removed, autoDelete);
|
||||
}
|
||||
|
||||
public bool RemoveSar(ulong guildId, ulong roleId)
|
||||
{
|
||||
bool success;
|
||||
using var uow = _db.GetDbContext();
|
||||
success = uow.Set<SelfAssignedRole>().DeleteByGuildAndRoleId(guildId, roleId);
|
||||
uow.SaveChanges();
|
||||
return success;
|
||||
}
|
||||
|
||||
public (bool AutoDelete, bool Exclusive, IReadOnlyCollection<SelfAssignedRole>) GetAdAndRoles(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
var autoDelete = gc.AutoDeleteSelfAssignedRoleMessages;
|
||||
var exclusive = gc.ExclusiveSelfAssignedRoles;
|
||||
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
|
||||
|
||||
return (autoDelete, exclusive, roles);
|
||||
}
|
||||
|
||||
public bool SetLevelReq(ulong guildId, IRole role, int level)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
|
||||
var sar = roles.FirstOrDefault(x => x.RoleId == role.Id);
|
||||
if (sar is not null)
|
||||
{
|
||||
sar.LevelRequirement = level;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
else
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ToggleEsar(ulong guildId)
|
||||
{
|
||||
bool areExclusive;
|
||||
using var uow = _db.GetDbContext();
|
||||
var config = uow.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
areExclusive = config.ExclusiveSelfAssignedRoles = !config.ExclusiveSelfAssignedRoles;
|
||||
uow.SaveChanges();
|
||||
return areExclusive;
|
||||
}
|
||||
|
||||
public (bool Exclusive, IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> Roles, IDictionary<int, string>
|
||||
GroupNames
|
||||
) GetRoles(IGuild guild)
|
||||
{
|
||||
var exclusive = false;
|
||||
|
||||
IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> roles;
|
||||
IDictionary<int, string> groupNames;
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.SelfAssignableRoleGroupNames));
|
||||
exclusive = gc.ExclusiveSelfAssignedRoles;
|
||||
groupNames = gc.SelfAssignableRoleGroupNames.ToDictionary(x => x.Number, x => x.Name);
|
||||
var roleModels = uow.Set<SelfAssignedRole>().GetFromGuild(guild.Id);
|
||||
roles = roleModels.Select(x => (Model: x, Role: guild.GetRole(x.RoleId)))
|
||||
.ToList();
|
||||
uow.Set<SelfAssignedRole>().RemoveRange(roles.Where(x => x.Role is null).Select(x => x.Model).ToArray());
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
return (exclusive, roles.Where(x => x.Role is not null).ToList(), groupNames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public sealed class DummyLogCommandService : ILogCommandService
|
||||
#if GLOBAL_NADEKO
|
||||
, INService
|
||||
#endif
|
||||
{
|
||||
public void AddDeleteIgnore(ulong xId)
|
||||
{
|
||||
}
|
||||
|
||||
public Task LogServer(ulong guildId, ulong channelId, bool actionValue)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType)
|
||||
=> false;
|
||||
|
||||
public LogSetting? GetGuildLogSettings(ulong guildId)
|
||||
=> default;
|
||||
|
||||
public bool Log(ulong guildId, ulong? channelId, LogType type)
|
||||
=> false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
[NoPublicBot]
|
||||
public partial class LogCommands : NadekoModule<ILogCommandService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task LogServer(PermissionAction action)
|
||||
{
|
||||
await _service.LogServer(ctx.Guild.Id, ctx.Channel.Id, action.Value);
|
||||
if (action.Value)
|
||||
await ReplyConfirmLocalizedAsync(strs.log_all);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.log_disabled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task LogIgnore()
|
||||
{
|
||||
var settings = _service.GetGuildLogSettings(ctx.Guild.Id);
|
||||
|
||||
var chs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.Channel).ToList()
|
||||
?? new List<IgnoredLogItem>();
|
||||
var usrs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.User).ToList()
|
||||
?? new List<IgnoredLogItem>();
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.AddField(GetText(strs.log_ignored_channels),
|
||||
chs.Count == 0
|
||||
? "-"
|
||||
: string.Join('\n', chs.Select(x => $"{x.LogItemId} | <#{x.LogItemId}>")))
|
||||
.AddField(GetText(strs.log_ignored_users),
|
||||
usrs.Count == 0
|
||||
? "-"
|
||||
: string.Join('\n', usrs.Select(x => $"{x.LogItemId} | <@{x.LogItemId}>")));
|
||||
|
||||
await ctx.Channel.EmbedAsync(eb);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task LogIgnore([Leftover] ITextChannel target)
|
||||
{
|
||||
var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.Channel);
|
||||
|
||||
if (!removed)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(
|
||||
strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")")));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(
|
||||
strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")")));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task LogIgnore([Leftover] IUser target)
|
||||
{
|
||||
var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.User);
|
||||
|
||||
if (!removed)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(
|
||||
strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")")));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(
|
||||
strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")")));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task LogEvents()
|
||||
{
|
||||
var logSetting = _service.GetGuildLogSettings(ctx.Guild.Id);
|
||||
var str = string.Join("\n",
|
||||
Enum.GetNames<LogType>()
|
||||
.Select(x =>
|
||||
{
|
||||
var val = logSetting is null ? null : GetLogProperty(logSetting, Enum.Parse<LogType>(x));
|
||||
if (val is not null)
|
||||
return $"{Format.Bold(x)} <#{val}>";
|
||||
return Format.Bold(x);
|
||||
}));
|
||||
|
||||
await SendConfirmAsync(Format.Bold(GetText(strs.log_events)) + "\n" + str);
|
||||
}
|
||||
|
||||
private static ulong? GetLogProperty(LogSetting l, LogType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case LogType.Other:
|
||||
return l.LogOtherId;
|
||||
case LogType.MessageUpdated:
|
||||
return l.MessageUpdatedId;
|
||||
case LogType.MessageDeleted:
|
||||
return l.MessageDeletedId;
|
||||
case LogType.UserJoined:
|
||||
return l.UserJoinedId;
|
||||
case LogType.UserLeft:
|
||||
return l.UserLeftId;
|
||||
case LogType.UserBanned:
|
||||
return l.UserBannedId;
|
||||
case LogType.UserUnbanned:
|
||||
return l.UserUnbannedId;
|
||||
case LogType.UserUpdated:
|
||||
return l.UserUpdatedId;
|
||||
case LogType.ChannelCreated:
|
||||
return l.ChannelCreatedId;
|
||||
case LogType.ChannelDestroyed:
|
||||
return l.ChannelDestroyedId;
|
||||
case LogType.ChannelUpdated:
|
||||
return l.ChannelUpdatedId;
|
||||
case LogType.UserPresence:
|
||||
return l.LogUserPresenceId;
|
||||
case LogType.VoicePresence:
|
||||
return l.LogVoicePresenceId;
|
||||
case LogType.VoicePresenceTts:
|
||||
return l.LogVoicePresenceTTSId;
|
||||
case LogType.UserMuted:
|
||||
return l.UserMutedId;
|
||||
case LogType.UserWarned:
|
||||
return l.LogWarnsId;
|
||||
case LogType.ThreadDeleted:
|
||||
return l.ThreadDeletedId;
|
||||
case LogType.ThreadCreated:
|
||||
return l.ThreadCreatedId;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[OwnerOnly]
|
||||
public async Task Log(LogType type)
|
||||
{
|
||||
var val = _service.Log(ctx.Guild.Id, ctx.Channel.Id, type);
|
||||
|
||||
if (val)
|
||||
await ReplyConfirmLocalizedAsync(strs.log(Format.Bold(type.ToString())));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.log_stop(Format.Bold(type.ToString())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, INService
|
||||
{
|
||||
private readonly ConcurrentDictionary<ulong, TimeZoneInfo> _timezones;
|
||||
private readonly DbService _db;
|
||||
private readonly IReplacementPatternStore _repStore;
|
||||
|
||||
public GuildTimezoneService(IBot bot, DbService db, IReplacementPatternStore repStore)
|
||||
{
|
||||
_timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple)
|
||||
.Where(x => x.Timezone is not null)
|
||||
.ToDictionary(x => x.GuildId, x => x.Timezone)
|
||||
.ToConcurrent();
|
||||
|
||||
_db = db;
|
||||
_repStore = repStore;
|
||||
|
||||
bot.JoinedGuild += Bot_JoinedGuild;
|
||||
}
|
||||
|
||||
private Task Bot_JoinedGuild(GuildConfig arg)
|
||||
{
|
||||
var (guildId, tz) = GetTimzezoneTuple(arg);
|
||||
if (tz is not null)
|
||||
_timezones.TryAdd(guildId, tz);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ulong GuildId, TimeZoneInfo Timezone) GetTimzezoneTuple(GuildConfig x)
|
||||
{
|
||||
TimeZoneInfo tz;
|
||||
try
|
||||
{
|
||||
if (x.TimeZoneId is null)
|
||||
tz = null;
|
||||
else
|
||||
tz = TimeZoneInfo.FindSystemTimeZoneById(x.TimeZoneId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
tz = null;
|
||||
}
|
||||
|
||||
return (x.GuildId, Timezone: tz);
|
||||
}
|
||||
|
||||
public TimeZoneInfo GetTimeZoneOrDefault(ulong? guildId)
|
||||
{
|
||||
if (guildId is ulong gid && _timezones.TryGetValue(gid, out var tz))
|
||||
return tz;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public void SetTimeZone(ulong guildId, TimeZoneInfo tz)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var gc = uow.GuildConfigsForId(guildId, set => set);
|
||||
|
||||
gc.TimeZoneId = tz?.Id;
|
||||
uow.SaveChanges();
|
||||
|
||||
if (tz is null)
|
||||
_timezones.TryRemove(guildId, out tz);
|
||||
else
|
||||
_timezones.AddOrUpdate(guildId, tz, (_, _) => tz);
|
||||
}
|
||||
|
||||
public TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId)
|
||||
=> GetTimeZoneOrDefault(guildId) ?? TimeZoneInfo.Utc;
|
||||
|
||||
public Task OnReadyAsync()
|
||||
{
|
||||
_repStore.Register("%server.time%",
|
||||
(IGuild g) =>
|
||||
{
|
||||
var to = TimeZoneInfo.Local;
|
||||
if (g is not null)
|
||||
{
|
||||
to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local;
|
||||
}
|
||||
|
||||
return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ")
|
||||
+ to.StandardName.GetInitials();
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class TimeZoneCommands : NadekoModule<GuildTimezoneService>
|
||||
{
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Timezones(int page = 1)
|
||||
{
|
||||
page--;
|
||||
|
||||
if (page is < 0 or > 20)
|
||||
return;
|
||||
|
||||
var timezones = TimeZoneInfo.GetSystemTimeZones().OrderBy(x => x.BaseUtcOffset).ToArray();
|
||||
var timezonesPerPage = 20;
|
||||
|
||||
var curTime = DateTimeOffset.UtcNow;
|
||||
|
||||
var i = 0;
|
||||
var timezoneStrings = timezones.Select(x => (x, ++i % 2 == 0))
|
||||
.Select(data =>
|
||||
{
|
||||
var (tzInfo, flip) = data;
|
||||
var nameStr = $"{tzInfo.Id,-30}";
|
||||
var offset = curTime.ToOffset(tzInfo.GetUtcOffset(curTime))
|
||||
.ToString("zzz");
|
||||
if (flip)
|
||||
return $"{offset} {Format.Code(nameStr)}";
|
||||
return $"{Format.Code(offset)} {nameStr}";
|
||||
});
|
||||
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage => _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.timezones_available))
|
||||
.WithDescription(string.Join("\n",
|
||||
timezoneStrings.Skip(curPage * timezonesPerPage).Take(timezonesPerPage))),
|
||||
timezones.Length,
|
||||
timezonesPerPage);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Timezone()
|
||||
=> await ReplyConfirmLocalizedAsync(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id)));
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task Timezone([Leftover] string id)
|
||||
{
|
||||
TimeZoneInfo tz;
|
||||
try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); }
|
||||
catch { tz = null; }
|
||||
|
||||
|
||||
if (tz is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.timezone_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
_service.SetTimeZone(ctx.Guild.Id, tz);
|
||||
|
||||
await SendConfirmAsync(tz.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
#nullable disable
|
||||
using CommandLine;
|
||||
using Humanizer.Localisation;
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class UserPunishCommands : NadekoModule<UserPunishService>
|
||||
{
|
||||
public enum AddRole
|
||||
{
|
||||
AddRole
|
||||
}
|
||||
|
||||
private readonly MuteService _mute;
|
||||
|
||||
public UserPunishCommands(MuteService mute)
|
||||
{
|
||||
_mute = mute;
|
||||
}
|
||||
|
||||
private async Task<bool> CheckRoleHierarchy(IGuildUser target)
|
||||
{
|
||||
var curUser = ((SocketGuild)ctx.Guild).CurrentUser;
|
||||
var ownerId = ctx.Guild.OwnerId;
|
||||
var modMaxRole = ((IGuildUser)ctx.User).GetRoles().Max(r => r.Position);
|
||||
var targetMaxRole = target.GetRoles().Max(r => r.Position);
|
||||
var botMaxRole = curUser.GetRoles().Max(r => r.Position);
|
||||
// bot can't punish a user who is higher in the hierarchy. Discord will return 403
|
||||
// moderator can be owner, in which case role hierarchy doesn't matter
|
||||
// otherwise, moderator has to have a higher role
|
||||
if (botMaxRole <= targetMaxRole
|
||||
|| (ctx.User.Id != ownerId && targetMaxRole >= modMaxRole)
|
||||
|| target.Id == ownerId)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.hierarchy);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public Task Warn(IGuildUser user, [Leftover] string reason = null)
|
||||
=> Warn(1, user, reason);
|
||||
|
||||
[Cmd]
|
||||
[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;
|
||||
|
||||
var dmFailed = false;
|
||||
try
|
||||
{
|
||||
await user.EmbedAsync(_eb.Create()
|
||||
.WithErrorColor()
|
||||
.WithDescription(GetText(strs.warned_on(ctx.Guild.ToString())))
|
||||
.AddField(GetText(strs.moderator), ctx.User.ToString())
|
||||
.AddField(GetText(strs.reason), reason ?? "-"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
|
||||
WarningPunishment punishment;
|
||||
try
|
||||
{
|
||||
punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Exception occured while warning a user");
|
||||
var errorEmbed = _eb.Create().WithErrorColor().WithDescription(GetText(strs.cant_apply_punishment));
|
||||
|
||||
if (dmFailed)
|
||||
errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(errorEmbed);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create().WithOkColor();
|
||||
if (punishment is null)
|
||||
embed.WithDescription(GetText(strs.user_warned(Format.Bold(user.ToString()))));
|
||||
else
|
||||
{
|
||||
embed.WithDescription(GetText(strs.user_warned_and_punished(Format.Bold(user.ToString()),
|
||||
Format.Bold(punishment.Punishment.ToString()))));
|
||||
}
|
||||
|
||||
if (dmFailed)
|
||||
embed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[NadekoOptions<WarnExpireOptions>]
|
||||
[Priority(1)]
|
||||
public async Task WarnExpire()
|
||||
{
|
||||
var expireDays = await _service.GetWarnExpire(ctx.Guild.Id);
|
||||
|
||||
if (expireDays == 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.warns_dont_expire);
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.warns_expire_in(expireDays));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[NadekoOptions<WarnExpireOptions>]
|
||||
[Priority(2)]
|
||||
public async Task WarnExpire(int days, params string[] args)
|
||||
{
|
||||
if (days is < 0 or > 366)
|
||||
return;
|
||||
|
||||
var opts = OptionsParser.ParseFrom<WarnExpireOptions>(args);
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
await _service.WarnExpireAsync(ctx.Guild.Id, days, opts.Delete);
|
||||
if (days == 0)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_expire_reset);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.Delete)
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_expire_set_delete(Format.Bold(days.ToString())));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_expire_set_clear(Format.Bold(days.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[Priority(2)]
|
||||
public Task Warnlog(int page, [Leftover] IGuildUser user = null)
|
||||
{
|
||||
user ??= (IGuildUser)ctx.User;
|
||||
|
||||
return Warnlog(page, user.Id);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(3)]
|
||||
public Task Warnlog(IGuildUser user = null)
|
||||
{
|
||||
user ??= (IGuildUser)ctx.User;
|
||||
|
||||
return ctx.User.Id == user.Id || ((IGuildUser)ctx.User).GuildPermissions.BanMembers
|
||||
? Warnlog(user.Id)
|
||||
: Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[Priority(0)]
|
||||
public Task Warnlog(int page, ulong userId)
|
||||
=> InternalWarnlog(userId, page - 1);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[Priority(1)]
|
||||
public Task Warnlog(ulong userId)
|
||||
=> InternalWarnlog(userId, 0);
|
||||
|
||||
private async Task InternalWarnlog(ulong userId, int inputPage)
|
||||
{
|
||||
if (inputPage < 0)
|
||||
return;
|
||||
|
||||
var allWarnings = _service.UserWarnings(ctx.Guild.Id, userId);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(inputPage,
|
||||
page =>
|
||||
{
|
||||
var warnings = allWarnings.Skip(page * 9).Take(9).ToArray();
|
||||
|
||||
var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString();
|
||||
var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.warnlog_for(user)));
|
||||
|
||||
if (!warnings.Any())
|
||||
embed.WithDescription(GetText(strs.warnings_none));
|
||||
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?.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,
|
||||
Format.Code(GetText(strs.warn_weight(w.Weight))) + '\n' + w.Reason.TrimTo(1000));
|
||||
}
|
||||
}
|
||||
|
||||
return embed;
|
||||
},
|
||||
allWarnings.Length,
|
||||
9);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public async Task WarnlogAll(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return;
|
||||
var warnings = _service.WarnlogAll(ctx.Guild.Id);
|
||||
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var ws = warnings.Skip(curPage * 15)
|
||||
.Take(15)
|
||||
.ToArray()
|
||||
.Select(x =>
|
||||
{
|
||||
var all = x.Count();
|
||||
var forgiven = x.Count(y => y.Forgiven);
|
||||
var total = all - forgiven;
|
||||
var usr = ((SocketGuild)ctx.Guild).GetUser(x.Key);
|
||||
return (usr?.ToString() ?? x.Key.ToString())
|
||||
+ $" | {total} ({all} - {forgiven})";
|
||||
});
|
||||
|
||||
return _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.warnings_list))
|
||||
.WithDescription(string.Join("\n", ws));
|
||||
},
|
||||
warnings.Length,
|
||||
15);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public Task Warnclear(IGuildUser user, int index = 0)
|
||||
=> Warnclear(user.Id, index);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public async Task Warnclear(ulong userId, int index = 0)
|
||||
{
|
||||
if (index < 0)
|
||||
return;
|
||||
var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString());
|
||||
var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString());
|
||||
if (index == 0)
|
||||
await ReplyErrorLocalizedAsync(strs.warnings_cleared(userStr));
|
||||
else
|
||||
{
|
||||
if (success)
|
||||
await ReplyConfirmLocalizedAsync(strs.warning_cleared(Format.Bold(index.ToString()), userStr));
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.warning_clear_fail);
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[Priority(1)]
|
||||
public async Task WarnPunish(
|
||||
int number,
|
||||
AddRole _,
|
||||
IRole role,
|
||||
StoopidTime time = null)
|
||||
{
|
||||
var punish = PunishmentAction.AddRole;
|
||||
|
||||
if (ctx.Guild.OwnerId != ctx.User.Id
|
||||
&& role.Position >= ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.role_too_high);
|
||||
return;
|
||||
}
|
||||
|
||||
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role);
|
||||
|
||||
if (!success)
|
||||
return;
|
||||
|
||||
if (time is null)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_punish_set(Format.Bold(punish.ToString()),
|
||||
Format.Bold(number.ToString())));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_punish_set_timed(Format.Bold(punish.ToString()),
|
||||
Format.Bold(number.ToString()),
|
||||
Format.Bold(time.Input)));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public async Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null)
|
||||
{
|
||||
// this should never happen. Addrole has its own method with higher priority
|
||||
// also disallow warn punishment for getting warned
|
||||
if (punish is PunishmentAction.AddRole or PunishmentAction.Warn)
|
||||
return;
|
||||
|
||||
// you must specify the time for timeout
|
||||
if (punish is PunishmentAction.TimeOut && time is null)
|
||||
return;
|
||||
|
||||
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time);
|
||||
|
||||
if (!success)
|
||||
return;
|
||||
|
||||
if (time is null)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_punish_set(Format.Bold(punish.ToString()),
|
||||
Format.Bold(number.ToString())));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_punish_set_timed(Format.Bold(punish.ToString()),
|
||||
Format.Bold(number.ToString()),
|
||||
Format.Bold(time.Input)));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
public async Task WarnPunish(int number)
|
||||
{
|
||||
if (!_service.WarnPunishRemove(ctx.Guild.Id, number))
|
||||
return;
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.warn_punish_rem(Format.Bold(number.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task WarnPunishList()
|
||||
{
|
||||
var ps = _service.WarnPunishList(ctx.Guild.Id);
|
||||
|
||||
string list;
|
||||
if (ps.Any())
|
||||
{
|
||||
list = string.Join("\n",
|
||||
ps.Select(x
|
||||
=> $"{x.Count} -> {x.Punishment} {(x.Punishment == PunishmentAction.AddRole ? $"<@&{x.RoleId}>" : "")} {(x.Time <= 0 ? "" : x.Time + "m")} "));
|
||||
}
|
||||
else
|
||||
list = GetText(strs.warnpl_none);
|
||||
|
||||
await SendConfirmAsync(GetText(strs.warn_punish_list), list);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(1)]
|
||||
public Task Ban(StoopidTime time, IUser user, [Leftover] string msg = null)
|
||||
=> Ban(time, user.Id, msg);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(0)]
|
||||
public async Task Ban(StoopidTime time, ulong userId, [Leftover] string msg = null)
|
||||
{
|
||||
if (time.Time > TimeSpan.FromDays(49))
|
||||
return;
|
||||
|
||||
var guildUser = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
|
||||
|
||||
|
||||
if (guildUser is not null && !await CheckRoleHierarchy(guildUser))
|
||||
return;
|
||||
|
||||
var dmFailed = false;
|
||||
|
||||
if (guildUser is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg));
|
||||
var embed = await _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time);
|
||||
if (embed is not null)
|
||||
await guildUser.SendAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
var user = await ctx.Client.GetUserAsync(userId);
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune);
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("⛔️ " + GetText(strs.banned_user))
|
||||
.AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true)
|
||||
.AddField("ID", userId.ToString(), true)
|
||||
.AddField(GetText(strs.duration),
|
||||
time.Time.Humanize(3, minUnit: TimeUnit.Minute, culture: Culture),
|
||||
true);
|
||||
|
||||
if (dmFailed)
|
||||
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(toSend);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(0)]
|
||||
public async Task Ban(ulong userId, [Leftover] string msg = null)
|
||||
{
|
||||
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
|
||||
if (user is null)
|
||||
{
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512));
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("⛔️ " + GetText(strs.banned_user))
|
||||
.AddField("ID", userId.ToString(), true));
|
||||
}
|
||||
else
|
||||
await Ban(user, msg);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(2)]
|
||||
public async Task Ban(IGuildUser user, [Leftover] string msg = null)
|
||||
{
|
||||
if (!await CheckRoleHierarchy(user))
|
||||
return;
|
||||
|
||||
var dmFailed = false;
|
||||
|
||||
try
|
||||
{
|
||||
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg));
|
||||
var embed = await _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null);
|
||||
if (embed is not null)
|
||||
await user.SendAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512));
|
||||
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("⛔️ " + GetText(strs.banned_user))
|
||||
.AddField(GetText(strs.username), user.ToString(), true)
|
||||
.AddField("ID", user.Id.ToString(), true);
|
||||
|
||||
if (dmFailed)
|
||||
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(toSend);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task BanPrune(int days)
|
||||
{
|
||||
if (days < 0 || days > 7)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.invalid_input);
|
||||
return;
|
||||
}
|
||||
|
||||
await _service.SetBanPruneAsync(ctx.Guild.Id, days);
|
||||
|
||||
if (days == 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.ban_prune_disabled);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.ban_prune(days));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task BanMessage([Leftover] string message = null)
|
||||
{
|
||||
if (message is null)
|
||||
{
|
||||
var template = _service.GetBanTemplate(ctx.Guild.Id);
|
||||
if (template is null)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.banmsg_default);
|
||||
return;
|
||||
}
|
||||
|
||||
await SendConfirmAsync(template);
|
||||
return;
|
||||
}
|
||||
|
||||
_service.SetBanTemplate(ctx.Guild.Id, message);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task BanMsgReset()
|
||||
{
|
||||
_service.SetBanTemplate(ctx.Guild.Id, null);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(0)]
|
||||
public Task BanMessageTest([Leftover] string reason = null)
|
||||
=> InternalBanMessageTest(reason, null);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Priority(1)]
|
||||
public Task BanMessageTest(StoopidTime duration, [Leftover] string reason = null)
|
||||
=> InternalBanMessageTest(reason, duration.Time);
|
||||
|
||||
private async Task InternalBanMessageTest(string reason, TimeSpan? duration)
|
||||
{
|
||||
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), reason));
|
||||
var embed = await _service.GetBanUserDmEmbed(Context, (IGuildUser)ctx.User, defaultMessage, reason, duration);
|
||||
|
||||
if (embed is null)
|
||||
await ConfirmLocalizedAsync(strs.banmsg_disabled);
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await ctx.User.SendAsync(embed);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.unable_to_dm_user);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task Unban([Leftover] string user)
|
||||
{
|
||||
var bans = await ctx.Guild.GetBansAsync().FlattenAsync();
|
||||
|
||||
var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant());
|
||||
|
||||
if (bun is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.user_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
await UnbanInternal(bun.User);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task Unban(ulong userId)
|
||||
{
|
||||
var bun = await ctx.Guild.GetBanAsync(userId);
|
||||
|
||||
if (bun is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.user_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
await UnbanInternal(bun.User);
|
||||
}
|
||||
|
||||
private async Task UnbanInternal(IUser user)
|
||||
{
|
||||
await ctx.Guild.RemoveBanAsync(user);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.unbanned_user(Format.Bold(user.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public Task Softban(IGuildUser user, [Leftover] string msg = null)
|
||||
=> SoftbanInternal(user, msg);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
public async Task Softban(ulong userId, [Leftover] string msg = null)
|
||||
{
|
||||
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
await SoftbanInternal(user, msg);
|
||||
}
|
||||
|
||||
private async Task SoftbanInternal(IGuildUser user, [Leftover] string msg = null)
|
||||
{
|
||||
if (!await CheckRoleHierarchy(user))
|
||||
return;
|
||||
|
||||
var dmFailed = false;
|
||||
|
||||
try
|
||||
{
|
||||
await user.SendErrorAsync(_eb, GetText(strs.sbdm(Format.Bold(ctx.Guild.Name), msg)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
await ctx.Guild.AddBanAsync(user, banPrune, ("Softban | " + ctx.User + " | " + msg).TrimTo(512));
|
||||
try { await ctx.Guild.RemoveBanAsync(user); }
|
||||
catch { await ctx.Guild.RemoveBanAsync(user); }
|
||||
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("☣ " + GetText(strs.sb_user))
|
||||
.AddField(GetText(strs.username), user.ToString(), true)
|
||||
.AddField("ID", user.Id.ToString(), true);
|
||||
|
||||
if (dmFailed)
|
||||
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(toSend);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.KickMembers)]
|
||||
[BotPerm(GuildPerm.KickMembers)]
|
||||
[Priority(1)]
|
||||
public Task Kick(IGuildUser user, [Leftover] string msg = null)
|
||||
=> KickInternal(user, msg);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.KickMembers)]
|
||||
[BotPerm(GuildPerm.KickMembers)]
|
||||
[Priority(0)]
|
||||
public async Task Kick(ulong userId, [Leftover] string msg = null)
|
||||
{
|
||||
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
await KickInternal(user, msg);
|
||||
}
|
||||
|
||||
private async Task KickInternal(IGuildUser user, string msg = null)
|
||||
{
|
||||
if (!await CheckRoleHierarchy(user))
|
||||
return;
|
||||
|
||||
var dmFailed = false;
|
||||
|
||||
try
|
||||
{
|
||||
await user.SendErrorAsync(_eb, GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg)));
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
|
||||
await user.KickAsync((ctx.User + " | " + msg).TrimTo(512));
|
||||
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.kicked_user))
|
||||
.AddField(GetText(strs.username), user.ToString(), true)
|
||||
.AddField("ID", user.Id.ToString(), true);
|
||||
|
||||
if (dmFailed)
|
||||
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(toSend);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ModerateMembers)]
|
||||
[BotPerm(GuildPerm.ModerateMembers)]
|
||||
[Priority(2)]
|
||||
public async Task Timeout(IUser globalUser, StoopidTime time, [Leftover] string msg = null)
|
||||
{
|
||||
var user = await ctx.Guild.GetUserAsync(globalUser.Id);
|
||||
|
||||
if (user is null)
|
||||
return;
|
||||
|
||||
if (!await CheckRoleHierarchy(user))
|
||||
return;
|
||||
|
||||
var dmFailed = false;
|
||||
|
||||
try
|
||||
{
|
||||
var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg));
|
||||
await user.EmbedAsync(_eb.Create(ctx)
|
||||
.WithPendingColor()
|
||||
.WithDescription(dmMessage));
|
||||
}
|
||||
catch
|
||||
{
|
||||
dmFailed = true;
|
||||
}
|
||||
|
||||
await user.SetTimeOutAsync(time.Time);
|
||||
|
||||
var toSend = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("⏳ " + GetText(strs.timedout_user))
|
||||
.AddField(GetText(strs.username), user.ToString(), true)
|
||||
.AddField("ID", user.Id.ToString(), true);
|
||||
|
||||
if (dmFailed)
|
||||
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
|
||||
|
||||
await ctx.Channel.EmbedAsync(toSend);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[Ratelimit(30)]
|
||||
public async Task MassBan(params string[] userStrings)
|
||||
{
|
||||
if (userStrings.Length == 0)
|
||||
return;
|
||||
|
||||
var missing = new List<string>();
|
||||
var banning = new HashSet<IUser>();
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
foreach (var userStr in userStrings)
|
||||
{
|
||||
if (ulong.TryParse(userStr, out var userId))
|
||||
{
|
||||
IUser user = await ctx.Guild.GetUserAsync(userId)
|
||||
?? await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id,
|
||||
userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
//Hierachy checks only if the user is in the guild
|
||||
if (user is IGuildUser gu && !await CheckRoleHierarchy(gu))
|
||||
return;
|
||||
|
||||
banning.Add(user);
|
||||
}
|
||||
else
|
||||
missing.Add(userStr);
|
||||
}
|
||||
|
||||
var missStr = string.Join("\n", missing);
|
||||
if (string.IsNullOrWhiteSpace(missStr))
|
||||
missStr = "-";
|
||||
|
||||
var toSend = _eb.Create(ctx)
|
||||
.WithDescription(GetText(strs.mass_ban_in_progress(banning.Count)))
|
||||
.AddField(GetText(strs.invalid(missing.Count)), missStr)
|
||||
.WithPendingColor();
|
||||
|
||||
var banningMessage = await ctx.Channel.EmbedAsync(toSend);
|
||||
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
foreach (var toBan in banning)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ctx.Guild.AddBanAsync(toBan.Id, banPrune, $"{ctx.User} | Massban");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error banning {User} user in {GuildId} server", toBan.Id, ctx.Guild.Id);
|
||||
}
|
||||
}
|
||||
|
||||
await banningMessage.ModifyAsync(x => x.Embed = _eb.Create()
|
||||
.WithDescription(
|
||||
GetText(strs.mass_ban_completed(banning.Count())))
|
||||
.AddField(GetText(strs.invalid(missing.Count)), missStr)
|
||||
.WithOkColor()
|
||||
.Build());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.BanMembers)]
|
||||
[BotPerm(GuildPerm.BanMembers)]
|
||||
[OwnerOnly]
|
||||
public async Task MassKill([Leftover] string people)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(people))
|
||||
return;
|
||||
|
||||
var (bans, missing) = _service.MassKill((SocketGuild)ctx.Guild, people);
|
||||
|
||||
var missStr = string.Join("\n", missing);
|
||||
if (string.IsNullOrWhiteSpace(missStr))
|
||||
missStr = "-";
|
||||
|
||||
//send a message but don't wait for it
|
||||
var banningMessageTask = ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithDescription(
|
||||
GetText(strs.mass_kill_in_progress(bans.Count())))
|
||||
.AddField(GetText(strs.invalid(missing)), missStr)
|
||||
.WithPendingColor());
|
||||
|
||||
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
|
||||
//do the banning
|
||||
await Task.WhenAll(bans.Where(x => x.Id.HasValue)
|
||||
.Select(x => ctx.Guild.AddBanAsync(x.Id.Value,
|
||||
banPrune,
|
||||
x.Reason,
|
||||
new()
|
||||
{
|
||||
RetryMode = RetryMode.AlwaysRetry
|
||||
})));
|
||||
|
||||
//wait for the message and edit it
|
||||
var banningMessage = await banningMessageTask;
|
||||
|
||||
await banningMessage.ModifyAsync(x => x.Embed = _eb.Create()
|
||||
.WithDescription(
|
||||
GetText(strs.mass_kill_completed(bans.Count())))
|
||||
.AddField(GetText(strs.invalid(missing)), missStr)
|
||||
.WithOkColor()
|
||||
.Build());
|
||||
}
|
||||
|
||||
public class WarnExpireOptions : INadekoCommandOptions
|
||||
{
|
||||
[Option('d', "delete", Default = false, HelpText = "Delete warnings instead of clearing them.")]
|
||||
public bool Delete { get; set; } = false;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Common.TypeReaders.Models;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Modules.Permissions.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class UserPunishService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly MuteService _mute;
|
||||
private readonly DbService _db;
|
||||
private readonly BlacklistService _blacklistService;
|
||||
private readonly BotConfigService _bcs;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IReplacementService _repSvc;
|
||||
|
||||
public event Func<Warning, Task> OnUserWarned = static delegate { return Task.CompletedTask; };
|
||||
|
||||
public UserPunishService(
|
||||
MuteService mute,
|
||||
DbService db,
|
||||
BlacklistService blacklistService,
|
||||
BotConfigService bcs,
|
||||
DiscordSocketClient client,
|
||||
IReplacementService repSvc)
|
||||
{
|
||||
_mute = mute;
|
||||
_db = db;
|
||||
_blacklistService = blacklistService;
|
||||
_bcs = bcs;
|
||||
_client = client;
|
||||
_repSvc = repSvc;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12));
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
await CheckAllWarnExpiresAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
} while (await expiryTimer.WaitForNextTickAsync());
|
||||
}
|
||||
|
||||
public async Task<WarningPunishment> Warn(
|
||||
IGuild guild,
|
||||
ulong userId,
|
||||
IUser mod,
|
||||
long weight,
|
||||
string reason)
|
||||
{
|
||||
if (weight <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(weight));
|
||||
|
||||
var modName = mod.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
reason = "-";
|
||||
|
||||
var guildId = guild.Id;
|
||||
|
||||
var warn = new Warning
|
||||
{
|
||||
UserId = userId,
|
||||
GuildId = guildId,
|
||||
Forgiven = false,
|
||||
Reason = reason,
|
||||
Moderator = modName,
|
||||
Weight = weight
|
||||
};
|
||||
|
||||
long previousCount;
|
||||
List<WarningPunishment> ps;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
||||
|
||||
previousCount = uow.Set<Warning>().ForId(guildId, userId)
|
||||
.Where(w => !w.Forgiven && w.UserId == userId)
|
||||
.Sum(x => x.Weight);
|
||||
|
||||
uow.Set<Warning>().Add(warn);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_ = OnUserWarned(warn);
|
||||
|
||||
var totalCount = previousCount + weight;
|
||||
|
||||
var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount)
|
||||
.MaxBy(x => x.Count);
|
||||
|
||||
if (p is not null)
|
||||
{
|
||||
var user = await guild.GetUserAsync(userId);
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times.");
|
||||
return p;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task ApplyPunishment(
|
||||
IGuild guild,
|
||||
IGuildUser user,
|
||||
IUser mod,
|
||||
PunishmentAction p,
|
||||
int minutes,
|
||||
ulong? roleId,
|
||||
string reason)
|
||||
{
|
||||
if (!await CheckPermission(guild, p))
|
||||
return;
|
||||
|
||||
int banPrune;
|
||||
switch (p)
|
||||
{
|
||||
case PunishmentAction.Mute:
|
||||
if (minutes == 0)
|
||||
await _mute.MuteUser(user, mod, reason: reason);
|
||||
else
|
||||
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason);
|
||||
break;
|
||||
case PunishmentAction.VoiceMute:
|
||||
if (minutes == 0)
|
||||
await _mute.MuteUser(user, mod, MuteType.Voice, reason);
|
||||
else
|
||||
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason);
|
||||
break;
|
||||
case PunishmentAction.ChatMute:
|
||||
if (minutes == 0)
|
||||
await _mute.MuteUser(user, mod, MuteType.Chat, reason);
|
||||
else
|
||||
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason);
|
||||
break;
|
||||
case PunishmentAction.Kick:
|
||||
await user.KickAsync(reason);
|
||||
break;
|
||||
case PunishmentAction.Ban:
|
||||
banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
|
||||
if (minutes == 0)
|
||||
await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune);
|
||||
else
|
||||
await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune);
|
||||
break;
|
||||
case PunishmentAction.Softban:
|
||||
banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
|
||||
await guild.AddBanAsync(user, banPrune, $"Softban | {reason}");
|
||||
try
|
||||
{
|
||||
await guild.RemoveBanAsync(user);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await guild.RemoveBanAsync(user);
|
||||
}
|
||||
|
||||
break;
|
||||
case PunishmentAction.RemoveRoles:
|
||||
await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole));
|
||||
break;
|
||||
case PunishmentAction.AddRole:
|
||||
if (roleId is null)
|
||||
return;
|
||||
var role = guild.GetRole(roleId.Value);
|
||||
if (role is not null)
|
||||
{
|
||||
if (minutes == 0)
|
||||
await user.AddRoleAsync(role);
|
||||
else
|
||||
await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment",
|
||||
roleId.Value,
|
||||
guild.Id);
|
||||
}
|
||||
|
||||
break;
|
||||
case PunishmentAction.Warn:
|
||||
await Warn(guild, user.Id, mod, 1, reason);
|
||||
break;
|
||||
case PunishmentAction.TimeOut:
|
||||
await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to prevent the bot from hitting 403's when it needs to
|
||||
/// apply punishments with insufficient permissions
|
||||
/// </summary>
|
||||
/// <param name="guild">Guild the punishment is applied in</param>
|
||||
/// <param name="punish">Punishment to apply</param>
|
||||
/// <returns>Whether the bot has sufficient permissions</returns>
|
||||
private async Task<bool> CheckPermission(IGuild guild, PunishmentAction punish)
|
||||
{
|
||||
var botUser = await guild.GetCurrentUserAsync();
|
||||
switch (punish)
|
||||
{
|
||||
case PunishmentAction.Mute:
|
||||
return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles;
|
||||
case PunishmentAction.Kick:
|
||||
return botUser.GuildPermissions.KickMembers;
|
||||
case PunishmentAction.Ban:
|
||||
return botUser.GuildPermissions.BanMembers;
|
||||
case PunishmentAction.Softban:
|
||||
return botUser.GuildPermissions.BanMembers; // ban + unban
|
||||
case PunishmentAction.RemoveRoles:
|
||||
return botUser.GuildPermissions.ManageRoles;
|
||||
case PunishmentAction.ChatMute:
|
||||
return botUser.GuildPermissions.ManageRoles; // adds nadeko-mute role
|
||||
case PunishmentAction.VoiceMute:
|
||||
return botUser.GuildPermissions.MuteMembers;
|
||||
case PunishmentAction.AddRole:
|
||||
return botUser.GuildPermissions.ManageRoles;
|
||||
case PunishmentAction.TimeOut:
|
||||
return botUser.GuildPermissions.ModerateMembers;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckAllWarnExpiresAsync()
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var cleared = await uow.Set<Warning>()
|
||||
.Where(x => uow.Set<GuildConfig>()
|
||||
.Any(y => y.GuildId == x.GuildId
|
||||
&& y.WarnExpireHours > 0
|
||||
&& y.WarnExpireAction == WarnExpireAction.Clear)
|
||||
&& x.Forgiven == false
|
||||
&& x.DateAdded
|
||||
< DateTime.UtcNow.AddHours(-uow.Set<GuildConfig>()
|
||||
.Where(y => x.GuildId == y.GuildId)
|
||||
.Select(y => y.WarnExpireHours)
|
||||
.First()))
|
||||
.UpdateAsync(_ => new()
|
||||
{
|
||||
Forgiven = true,
|
||||
ForgivenBy = "expiry"
|
||||
});
|
||||
|
||||
var deleted = await uow.Set<Warning>()
|
||||
.Where(x => uow.Set<GuildConfig>()
|
||||
.Any(y => y.GuildId == x.GuildId
|
||||
&& y.WarnExpireHours > 0
|
||||
&& y.WarnExpireAction == WarnExpireAction.Delete)
|
||||
&& x.DateAdded
|
||||
< DateTime.UtcNow.AddHours(-uow.Set<GuildConfig>()
|
||||
.Where(y => x.GuildId == y.GuildId)
|
||||
.Select(y => y.WarnExpireHours)
|
||||
.First()))
|
||||
.DeleteAsync();
|
||||
|
||||
if (cleared > 0 || deleted > 0)
|
||||
{
|
||||
Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry",
|
||||
cleared,
|
||||
deleted);
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task CheckWarnExpiresAsync(ulong guildId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var config = uow.GuildConfigsForId(guildId, inc => inc);
|
||||
|
||||
if (config.WarnExpireHours == 0)
|
||||
return;
|
||||
|
||||
if (config.WarnExpireAction == WarnExpireAction.Clear)
|
||||
{
|
||||
await uow.Set<Warning>()
|
||||
.Where(x => x.GuildId == guildId
|
||||
&& x.Forgiven == false
|
||||
&& x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
|
||||
.UpdateAsync(_ => new()
|
||||
{
|
||||
Forgiven = true,
|
||||
ForgivenBy = "expiry"
|
||||
});
|
||||
}
|
||||
else if (config.WarnExpireAction == WarnExpireAction.Delete)
|
||||
{
|
||||
await uow.Set<Warning>()
|
||||
.Where(x => x.GuildId == guildId
|
||||
&& x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
|
||||
.DeleteAsync();
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<int> GetWarnExpire(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var config = uow.GuildConfigsForId(guildId, set => set);
|
||||
return Task.FromResult(config.WarnExpireHours / 24);
|
||||
}
|
||||
|
||||
public async Task WarnExpireAsync(ulong guildId, int days, bool delete)
|
||||
{
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(guildId, inc => inc);
|
||||
|
||||
config.WarnExpireHours = days * 24;
|
||||
config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear;
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
// no need to check for warn expires
|
||||
if (config.WarnExpireHours == 0)
|
||||
return;
|
||||
}
|
||||
|
||||
await CheckWarnExpiresAsync(guildId);
|
||||
}
|
||||
|
||||
public IGrouping<ulong, Warning>[] WarnlogAll(ulong gid)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<Warning>().GetForGuild(gid).GroupBy(x => x.UserId).ToArray();
|
||||
}
|
||||
|
||||
public Warning[] UserWarnings(ulong gid, ulong userId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<Warning>().ForId(gid, userId);
|
||||
}
|
||||
|
||||
public async Task<bool> WarnClearAsync(
|
||||
ulong guildId,
|
||||
ulong userId,
|
||||
int index,
|
||||
string moderator)
|
||||
{
|
||||
var toReturn = true;
|
||||
await using var uow = _db.GetDbContext();
|
||||
if (index == 0)
|
||||
await uow.Set<Warning>().ForgiveAll(guildId, userId, moderator);
|
||||
else
|
||||
toReturn = uow.Set<Warning>().Forgive(guildId, userId, moderator, index - 1);
|
||||
await uow.SaveChangesAsync();
|
||||
return toReturn;
|
||||
}
|
||||
|
||||
public bool WarnPunish(
|
||||
ulong guildId,
|
||||
int number,
|
||||
PunishmentAction punish,
|
||||
StoopidTime time,
|
||||
IRole role = null)
|
||||
{
|
||||
// these 3 don't make sense with time
|
||||
if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles
|
||||
&& time is not null)
|
||||
return false;
|
||||
if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49)))
|
||||
return false;
|
||||
|
||||
if (punish is PunishmentAction.AddRole && role is null)
|
||||
return false;
|
||||
|
||||
if (punish is PunishmentAction.TimeOut && time is null)
|
||||
return false;
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
||||
var toDelete = ps.Where(x => x.Count == number);
|
||||
|
||||
uow.RemoveRange(toDelete);
|
||||
|
||||
ps.Add(new()
|
||||
{
|
||||
Count = number,
|
||||
Punishment = punish,
|
||||
Time = (int?)time?.Time.TotalMinutes ?? 0,
|
||||
RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?)
|
||||
});
|
||||
uow.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool WarnPunishRemove(ulong guildId, int number)
|
||||
{
|
||||
if (number <= 0)
|
||||
return false;
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
||||
var p = ps.FirstOrDefault(x => x.Count == number);
|
||||
|
||||
if (p is not null)
|
||||
{
|
||||
uow.Remove(p);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public WarningPunishment[] WarnPunishList(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments))
|
||||
.WarnPunishments.OrderBy(x => x.Count)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill(
|
||||
SocketGuild guild,
|
||||
string people)
|
||||
{
|
||||
var gusers = guild.Users;
|
||||
//get user objects and reasons
|
||||
var bans = people.Split("\n")
|
||||
.Select(x =>
|
||||
{
|
||||
var split = x.Trim().Split(" ");
|
||||
|
||||
var reason = string.Join(" ", split.Skip(1));
|
||||
|
||||
if (ulong.TryParse(split[0], out var id))
|
||||
return (Original: split[0], Id: id, Reason: reason);
|
||||
|
||||
return (Original: split[0],
|
||||
gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id,
|
||||
Reason: reason);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
//if user is null, means that person couldn't be found
|
||||
var missing = bans.Count(x => !x.Id.HasValue);
|
||||
|
||||
//get only data for found users
|
||||
var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList();
|
||||
|
||||
_ = _blacklistService.BlacklistUsers(found);
|
||||
|
||||
return (bans, missing);
|
||||
}
|
||||
|
||||
public string GetBanTemplate(ulong guildId)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);
|
||||
return template?.Text;
|
||||
}
|
||||
|
||||
public void SetBanTemplate(ulong guildId, string text)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);
|
||||
|
||||
if (text is null)
|
||||
{
|
||||
if (template is null)
|
||||
return;
|
||||
|
||||
uow.Remove(template);
|
||||
}
|
||||
else if (template is null)
|
||||
{
|
||||
uow.Set<BanTemplate>().Add(new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
Text = text
|
||||
});
|
||||
}
|
||||
else
|
||||
template.Text = text;
|
||||
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
public async Task SetBanPruneAsync(ulong guildId, int? pruneDays)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.Set<BanTemplate>()
|
||||
.ToLinqToDBTable()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
GuildId = guildId,
|
||||
Text = null,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
PruneDays = pruneDays
|
||||
},
|
||||
old => new()
|
||||
{
|
||||
PruneDays = pruneDays
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
GuildId = guildId
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<int?> GetBanPruneAsync(ulong guildId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.Set<BanTemplate>()
|
||||
.Where(x => x.GuildId == guildId)
|
||||
.Select(x => x.PruneDays)
|
||||
.FirstOrDefaultAsyncLinqToDB();
|
||||
}
|
||||
|
||||
public Task<SmartText> GetBanUserDmEmbed(
|
||||
ICommandContext context,
|
||||
IGuildUser target,
|
||||
string defaultMessage,
|
||||
string banReason,
|
||||
TimeSpan? duration)
|
||||
=> GetBanUserDmEmbed((DiscordSocketClient)context.Client,
|
||||
(SocketGuild)context.Guild,
|
||||
(IGuildUser)context.User,
|
||||
target,
|
||||
defaultMessage,
|
||||
banReason,
|
||||
duration);
|
||||
|
||||
public async Task<SmartText> GetBanUserDmEmbed(
|
||||
DiscordSocketClient client,
|
||||
SocketGuild guild,
|
||||
IGuildUser moderator,
|
||||
IGuildUser target,
|
||||
string defaultMessage,
|
||||
string banReason,
|
||||
TimeSpan? duration)
|
||||
{
|
||||
var template = GetBanTemplate(guild.Id);
|
||||
|
||||
banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason;
|
||||
|
||||
var repCtx = new ReplacementContext(client, guild)
|
||||
.WithOverride("%ban.mod%", () => moderator.ToString())
|
||||
.WithOverride("%ban.mod.fullname%", () => moderator.ToString())
|
||||
.WithOverride("%ban.mod.name%", () => moderator.Username)
|
||||
.WithOverride("%ban.mod.discrim%", () => moderator.Discriminator)
|
||||
.WithOverride("%ban.user%", () => target.ToString())
|
||||
.WithOverride("%ban.user.fullname%", () => target.ToString())
|
||||
.WithOverride("%ban.user.name%", () => target.Username)
|
||||
.WithOverride("%ban.user.discrim%", () => target.Discriminator)
|
||||
.WithOverride("%reason%", () => banReason)
|
||||
.WithOverride("%ban.reason%", () => banReason)
|
||||
.WithOverride("%ban.duration%",
|
||||
() => duration?.ToString(@"d\.hh\:mm") ?? "perma");
|
||||
|
||||
|
||||
// if template isn't set, use the old message style
|
||||
if (string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
template = JsonConvert.SerializeObject(new
|
||||
{
|
||||
color = _bcs.Data.Color.Error.PackedValue >> 8,
|
||||
description = defaultMessage
|
||||
});
|
||||
}
|
||||
// if template is set to "-" do not dm the user
|
||||
else if (template == "-")
|
||||
return default;
|
||||
// if template is an embed, send that embed with replacements
|
||||
// otherwise, treat template as a regular string with replacements
|
||||
else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true })
|
||||
{
|
||||
template = JsonConvert.SerializeObject(new
|
||||
{
|
||||
color = _bcs.Data.Color.Error.PackedValue >> 8,
|
||||
description = template
|
||||
});
|
||||
}
|
||||
|
||||
var output = SmartText.CreateFrom(template);
|
||||
return await _repSvc.ReplaceAsync(output, repCtx);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Administration.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Administration;
|
||||
|
||||
public partial class Administration
|
||||
{
|
||||
[Group]
|
||||
public partial class VcRoleCommands : NadekoModule<VcRoleService>
|
||||
{
|
||||
[Cmd]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task VcRoleRm(ulong vcId)
|
||||
{
|
||||
if (_service.RemoveVcRole(ctx.Guild.Id, vcId))
|
||||
await ReplyConfirmLocalizedAsync(strs.vcrole_removed(Format.Bold(vcId.ToString())));
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.vcrole_not_found);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[UserPerm(GuildPerm.ManageRoles)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task VcRole([Leftover] IRole role = null)
|
||||
{
|
||||
var user = (IGuildUser)ctx.User;
|
||||
|
||||
var vc = user.VoiceChannel;
|
||||
|
||||
if (vc is null || vc.GuildId != user.GuildId)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.must_be_in_voice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (role is null)
|
||||
{
|
||||
if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id))
|
||||
await ReplyConfirmLocalizedAsync(strs.vcrole_removed(Format.Bold(vc.Name)));
|
||||
}
|
||||
else
|
||||
{
|
||||
_service.AddVcRole(ctx.Guild.Id, role, vc.Id);
|
||||
await ReplyConfirmLocalizedAsync(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name)));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task VcRoleList()
|
||||
{
|
||||
var guild = (SocketGuild)ctx.Guild;
|
||||
string text;
|
||||
if (_service.VcRoles.TryGetValue(ctx.Guild.Id, out var roles))
|
||||
{
|
||||
if (!roles.Any())
|
||||
text = GetText(strs.no_vcroles);
|
||||
else
|
||||
{
|
||||
text = string.Join("\n",
|
||||
roles.Select(x
|
||||
=> $"{Format.Bold(guild.GetVoiceChannel(x.Key)?.Name ?? x.Key.ToString())} => {x.Value}"));
|
||||
}
|
||||
}
|
||||
else
|
||||
text = GetText(strs.no_vcroles);
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.vc_role_list))
|
||||
.WithDescription(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
209
src/NadekoBot/Modules/Administration/VcRole/VcRoleService.cs
Normal file
209
src/NadekoBot/Modules/Administration/VcRole/VcRoleService.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Administration.Services;
|
||||
|
||||
public class VcRoleService : INService
|
||||
{
|
||||
public ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, IRole>> VcRoles { get; }
|
||||
public ConcurrentDictionary<ulong, System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>> ToAssign { get; }
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public VcRoleService(DiscordSocketClient client, IBot bot, DbService db)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
|
||||
_client.UserVoiceStateUpdated += ClientOnUserVoiceStateUpdated;
|
||||
VcRoles = new();
|
||||
ToAssign = new();
|
||||
|
||||
using (var uow = db.GetDbContext())
|
||||
{
|
||||
var guildIds = client.Guilds.Select(x => x.Id).ToList();
|
||||
uow.Set<GuildConfig>()
|
||||
.AsQueryable()
|
||||
.Include(x => x.VcRoleInfos)
|
||||
.Where(x => guildIds.Contains(x.GuildId))
|
||||
.AsEnumerable()
|
||||
.Select(InitializeVcRole)
|
||||
.WhenAll();
|
||||
}
|
||||
|
||||
Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task Selector(System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)> queue)
|
||||
{
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
while (queue.TryDequeue(out var item))
|
||||
{
|
||||
var (add, user, role) = item;
|
||||
|
||||
try
|
||||
{
|
||||
if (add)
|
||||
{
|
||||
if (!user.RoleIds.Contains(role.Id))
|
||||
await user.AddRoleAsync(role);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (user.RoleIds.Contains(role.Id))
|
||||
await user.RemoveRoleAsync(role);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
await Task.Delay(250);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await ToAssign.Values.Select(Selector).Append(Task.Delay(1000)).WhenAll();
|
||||
}
|
||||
});
|
||||
|
||||
_client.LeftGuild += _client_LeftGuild;
|
||||
bot.JoinedGuild += Bot_JoinedGuild;
|
||||
}
|
||||
|
||||
private Task Bot_JoinedGuild(GuildConfig arg)
|
||||
{
|
||||
// includeall no longer loads vcrole
|
||||
// need to load new guildconfig with vc role included
|
||||
using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var configWithVcRole = uow.GuildConfigsForId(arg.GuildId, set => set.Include(x => x.VcRoleInfos));
|
||||
_ = InitializeVcRole(configWithVcRole);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task _client_LeftGuild(SocketGuild arg)
|
||||
{
|
||||
VcRoles.TryRemove(arg.Id, out _);
|
||||
ToAssign.TryRemove(arg.Id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task InitializeVcRole(GuildConfig gconf)
|
||||
{
|
||||
var g = _client.GetGuild(gconf.GuildId);
|
||||
if (g is null)
|
||||
return;
|
||||
|
||||
var infos = new ConcurrentDictionary<ulong, IRole>();
|
||||
var missingRoles = new List<VcRoleInfo>();
|
||||
VcRoles.AddOrUpdate(gconf.GuildId, infos, delegate { return infos; });
|
||||
foreach (var ri in gconf.VcRoleInfos)
|
||||
{
|
||||
var role = g.GetRole(ri.RoleId);
|
||||
if (role is null)
|
||||
{
|
||||
missingRoles.Add(ri);
|
||||
continue;
|
||||
}
|
||||
|
||||
infos.TryAdd(ri.VoiceChannelId, role);
|
||||
}
|
||||
|
||||
if (missingRoles.Any())
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
uow.RemoveRange(missingRoles);
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}",
|
||||
missingRoles.Count,
|
||||
nameof(VcRoleService));
|
||||
}
|
||||
}
|
||||
|
||||
public void AddVcRole(ulong guildId, IRole role, ulong vcId)
|
||||
{
|
||||
if (role is null)
|
||||
throw new ArgumentNullException(nameof(role));
|
||||
|
||||
var guildVcRoles = VcRoles.GetOrAdd(guildId, new ConcurrentDictionary<ulong, IRole>());
|
||||
|
||||
guildVcRoles.AddOrUpdate(vcId, role, (_, _) => role);
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos));
|
||||
var toDelete = conf.VcRoleInfos.FirstOrDefault(x => x.VoiceChannelId == vcId); // remove old one
|
||||
if (toDelete is not null)
|
||||
uow.Remove(toDelete);
|
||||
conf.VcRoleInfos.Add(new()
|
||||
{
|
||||
VoiceChannelId = vcId,
|
||||
RoleId = role.Id
|
||||
}); // add new one
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
public bool RemoveVcRole(ulong guildId, ulong vcId)
|
||||
{
|
||||
if (!VcRoles.TryGetValue(guildId, out var guildVcRoles))
|
||||
return false;
|
||||
|
||||
if (!guildVcRoles.TryRemove(vcId, out _))
|
||||
return false;
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos));
|
||||
var toRemove = conf.VcRoleInfos.Where(x => x.VoiceChannelId == vcId).ToList();
|
||||
uow.RemoveRange(toRemove);
|
||||
uow.SaveChanges();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Task ClientOnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState)
|
||||
{
|
||||
if (usr is not SocketGuildUser gusr)
|
||||
return Task.CompletedTask;
|
||||
|
||||
var oldVc = oldState.VoiceChannel;
|
||||
var newVc = newState.VoiceChannel;
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (oldVc != newVc)
|
||||
{
|
||||
ulong guildId;
|
||||
guildId = newVc?.Guild.Id ?? oldVc.Guild.Id;
|
||||
|
||||
if (VcRoles.TryGetValue(guildId, out var guildVcRoles))
|
||||
{
|
||||
//remove old
|
||||
if (oldVc is not null && guildVcRoles.TryGetValue(oldVc.Id, out var role))
|
||||
Assign(false, gusr, role);
|
||||
//add new
|
||||
if (newVc is not null && guildVcRoles.TryGetValue(newVc.Id, out role))
|
||||
Assign(true, gusr, role);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in VcRoleService VoiceStateUpdate");
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void Assign(bool v, SocketGuildUser gusr, IRole role)
|
||||
{
|
||||
var queue = ToAssign.GetOrAdd(gusr.Guild.Id, new System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>());
|
||||
queue.Enqueue((v, gusr, role));
|
||||
}
|
||||
}
|
||||
154
src/NadekoBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
Normal file
154
src/NadekoBot/Modules/Gambling/AnimalRacing/AnimalRace.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
using NadekoBot.Modules.Games.Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing;
|
||||
|
||||
public sealed class AnimalRace : IDisposable
|
||||
{
|
||||
public enum Phase
|
||||
{
|
||||
WaitingForPlayers,
|
||||
Running,
|
||||
Ended
|
||||
}
|
||||
|
||||
public event Func<AnimalRace, Task> OnStarted = delegate { return Task.CompletedTask; };
|
||||
public event Func<AnimalRace, Task> OnStartingFailed = delegate { return Task.CompletedTask; };
|
||||
public event Func<AnimalRace, Task> OnStateUpdate = delegate { return Task.CompletedTask; };
|
||||
public event Func<AnimalRace, Task> OnEnded = delegate { return Task.CompletedTask; };
|
||||
|
||||
public Phase CurrentPhase { get; private set; } = Phase.WaitingForPlayers;
|
||||
|
||||
public IReadOnlyCollection<AnimalRacingUser> Users
|
||||
=> _users.ToList();
|
||||
|
||||
public List<AnimalRacingUser> FinishedUsers { get; } = new();
|
||||
public int MaxUsers { get; }
|
||||
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
private readonly HashSet<AnimalRacingUser> _users = new();
|
||||
private readonly ICurrencyService _currency;
|
||||
private readonly RaceOptions _options;
|
||||
private readonly Queue<RaceAnimal> _animalsQueue;
|
||||
|
||||
public AnimalRace(RaceOptions options, ICurrencyService currency, IEnumerable<RaceAnimal> availableAnimals)
|
||||
{
|
||||
_currency = currency;
|
||||
_options = options;
|
||||
_animalsQueue = new(availableAnimals);
|
||||
MaxUsers = _animalsQueue.Count;
|
||||
|
||||
if (_animalsQueue.Count == 0)
|
||||
CurrentPhase = Phase.Ended;
|
||||
}
|
||||
|
||||
public void Initialize() //lame name
|
||||
=> _ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(_options.StartTime * 1000);
|
||||
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (CurrentPhase != Phase.WaitingForPlayers)
|
||||
return;
|
||||
|
||||
await Start();
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
});
|
||||
|
||||
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, long bet = 0)
|
||||
{
|
||||
if (bet < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(bet));
|
||||
|
||||
var user = new AnimalRacingUser(userName, userId, bet);
|
||||
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_users.Count == MaxUsers)
|
||||
throw new AnimalRaceFullException();
|
||||
|
||||
if (CurrentPhase != Phase.WaitingForPlayers)
|
||||
throw new AlreadyStartedException();
|
||||
|
||||
if (!await _currency.RemoveAsync(userId, bet, new("animalrace", "bet")))
|
||||
throw new NotEnoughFundsException();
|
||||
|
||||
if (_users.Contains(user))
|
||||
throw new AlreadyJoinedException();
|
||||
|
||||
var animal = _animalsQueue.Dequeue();
|
||||
user.Animal = animal;
|
||||
_users.Add(user);
|
||||
|
||||
if (_animalsQueue.Count == 0) //start if no more spots left
|
||||
await Start();
|
||||
|
||||
return user;
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
private async Task Start()
|
||||
{
|
||||
CurrentPhase = Phase.Running;
|
||||
if (_users.Count <= 1)
|
||||
{
|
||||
foreach (var user in _users)
|
||||
{
|
||||
if (user.Bet > 0)
|
||||
await _currency.AddAsync(user.UserId, user.Bet, new("animalrace", "refund"));
|
||||
}
|
||||
|
||||
_ = OnStartingFailed?.Invoke(this);
|
||||
CurrentPhase = Phase.Ended;
|
||||
return;
|
||||
}
|
||||
|
||||
_ = OnStarted?.Invoke(this);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var rng = new NadekoRandom();
|
||||
while (!_users.All(x => x.Progress >= 60))
|
||||
{
|
||||
foreach (var user in _users)
|
||||
{
|
||||
user.Progress += rng.Next(1, 11);
|
||||
if (user.Progress >= 60)
|
||||
user.Progress = 60;
|
||||
}
|
||||
|
||||
var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x)).Shuffle();
|
||||
|
||||
FinishedUsers.AddRange(finished);
|
||||
|
||||
_ = OnStateUpdate?.Invoke(this);
|
||||
await Task.Delay(2500);
|
||||
}
|
||||
|
||||
if (FinishedUsers[0].Bet > 0)
|
||||
{
|
||||
await _currency.AddAsync(FinishedUsers[0].UserId,
|
||||
FinishedUsers[0].Bet * (_users.Count - 1),
|
||||
new("animalrace", "win"));
|
||||
}
|
||||
|
||||
_ = OnEnded?.Invoke(this);
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
CurrentPhase = Phase.Ended;
|
||||
OnStarted = null;
|
||||
OnEnded = null;
|
||||
OnStartingFailed = null;
|
||||
OnStateUpdate = null;
|
||||
_locker.Dispose();
|
||||
_users.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common.AnimalRacing;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class AnimalRaceService : INService
|
||||
{
|
||||
public ConcurrentDictionary<ulong, AnimalRace> AnimalRaces { get; } = new();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.AnimalRacing;
|
||||
using NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using NadekoBot.Modules.Games.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
// wth is this, needs full rewrite
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class AnimalRacingCommands : GamblingSubmodule<AnimalRaceService>
|
||||
{
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly GamesConfigService _gamesConf;
|
||||
|
||||
private IUserMessage raceMessage;
|
||||
|
||||
public AnimalRacingCommands(
|
||||
ICurrencyService cs,
|
||||
DiscordSocketClient client,
|
||||
GamblingConfigService gamblingConf,
|
||||
GamesConfigService gamesConf)
|
||||
: base(gamblingConf)
|
||||
{
|
||||
_cs = cs;
|
||||
_client = client;
|
||||
_gamesConf = gamesConf;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NadekoOptions<RaceOptions>]
|
||||
public Task Race(params string[] args)
|
||||
{
|
||||
var (options, _) = OptionsParser.ParseFrom(new RaceOptions(), args);
|
||||
|
||||
var ar = new AnimalRace(options, _cs, _gamesConf.Data.RaceAnimals.Shuffle());
|
||||
if (!_service.AnimalRaces.TryAdd(ctx.Guild.Id, ar))
|
||||
return SendErrorAsync(GetText(strs.animal_race), GetText(strs.animal_race_already_started));
|
||||
|
||||
ar.Initialize();
|
||||
|
||||
var count = 0;
|
||||
|
||||
Task ClientMessageReceived(SocketMessage arg)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (arg.Channel.Id == ctx.Channel.Id)
|
||||
{
|
||||
if (ar.CurrentPhase == AnimalRace.Phase.Running && ++count % 9 == 0)
|
||||
raceMessage = null;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task ArOnEnded(AnimalRace race)
|
||||
{
|
||||
_client.MessageReceived -= ClientMessageReceived;
|
||||
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
|
||||
var winner = race.FinishedUsers[0];
|
||||
if (race.FinishedUsers[0].Bet > 0)
|
||||
{
|
||||
return SendConfirmAsync(GetText(strs.animal_race),
|
||||
GetText(strs.animal_race_won_money(Format.Bold(winner.Username),
|
||||
winner.Animal.Icon,
|
||||
(race.FinishedUsers[0].Bet * (race.Users.Count - 1)) + CurrencySign)));
|
||||
}
|
||||
|
||||
ar.Dispose();
|
||||
return SendConfirmAsync(GetText(strs.animal_race),
|
||||
GetText(strs.animal_race_won(Format.Bold(winner.Username), winner.Animal.Icon)));
|
||||
}
|
||||
|
||||
ar.OnStartingFailed += Ar_OnStartingFailed;
|
||||
ar.OnStateUpdate += Ar_OnStateUpdate;
|
||||
ar.OnEnded += ArOnEnded;
|
||||
ar.OnStarted += Ar_OnStarted;
|
||||
_client.MessageReceived += ClientMessageReceived;
|
||||
|
||||
return SendConfirmAsync(GetText(strs.animal_race),
|
||||
GetText(strs.animal_race_starting(options.StartTime)),
|
||||
footer: GetText(strs.animal_race_join_instr(prefix)));
|
||||
}
|
||||
|
||||
private Task Ar_OnStarted(AnimalRace race)
|
||||
{
|
||||
if (race.Users.Count == race.MaxUsers)
|
||||
return SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
|
||||
return SendConfirmAsync(GetText(strs.animal_race),
|
||||
GetText(strs.animal_race_starting_with_x(race.Users.Count)));
|
||||
}
|
||||
|
||||
private async Task Ar_OnStateUpdate(AnimalRace race)
|
||||
{
|
||||
var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
|
||||
{string.Join("\n", race.Users.Select(p =>
|
||||
{
|
||||
var index = race.FinishedUsers.IndexOf(p);
|
||||
var extra = index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}";
|
||||
return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}";
|
||||
}))}
|
||||
|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
|
||||
|
||||
var msg = raceMessage;
|
||||
|
||||
if (msg is null)
|
||||
raceMessage = await SendConfirmAsync(text);
|
||||
else
|
||||
{
|
||||
await msg.ModifyAsync(x => x.Embed = _eb.Create()
|
||||
.WithTitle(GetText(strs.animal_race))
|
||||
.WithDescription(text)
|
||||
.WithOkColor()
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private Task Ar_OnStartingFailed(AnimalRace race)
|
||||
{
|
||||
_service.AnimalRaces.TryRemove(ctx.Guild.Id, out _);
|
||||
race.Dispose();
|
||||
return ReplyErrorLocalizedAsync(strs.animal_race_failed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task JoinRace([OverrideTypeReader(typeof(BalanceTypeReader))] long amount = default)
|
||||
{
|
||||
if (!await CheckBetOptional(amount))
|
||||
return;
|
||||
|
||||
if (!_service.AnimalRaces.TryGetValue(ctx.Guild.Id, out var ar))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.race_not_exist);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var user = await ar.JoinRace(ctx.User.Id, ctx.User.ToString(), amount);
|
||||
if (amount > 0)
|
||||
{
|
||||
await SendConfirmAsync(GetText(strs.animal_race_join_bet(ctx.User.Mention,
|
||||
user.Animal.Icon,
|
||||
amount + CurrencySign)));
|
||||
}
|
||||
else
|
||||
await SendConfirmAsync(GetText(strs.animal_race_join(ctx.User.Mention, user.Animal.Icon)));
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
//ignore if user inputed an invalid amount
|
||||
}
|
||||
catch (AlreadyJoinedException)
|
||||
{
|
||||
// just ignore this
|
||||
}
|
||||
catch (AlreadyStartedException)
|
||||
{
|
||||
//ignore
|
||||
}
|
||||
catch (AnimalRaceFullException)
|
||||
{
|
||||
await SendConfirmAsync(GetText(strs.animal_race), GetText(strs.animal_race_full));
|
||||
}
|
||||
catch (NotEnoughFundsException)
|
||||
{
|
||||
await SendErrorAsync(GetText(strs.not_enough(CurrencySign)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Games.Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing;
|
||||
|
||||
public class AnimalRacingUser
|
||||
{
|
||||
public long Bet { get; }
|
||||
public string Username { get; }
|
||||
public ulong UserId { get; }
|
||||
public RaceAnimal Animal { get; set; }
|
||||
public int Progress { get; set; }
|
||||
|
||||
public AnimalRacingUser(string username, ulong userId, long bet)
|
||||
{
|
||||
Bet = bet;
|
||||
Username = username;
|
||||
UserId = userId;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
=> obj is AnimalRacingUser x ? x.UserId == UserId : false;
|
||||
|
||||
public override int GetHashCode()
|
||||
=> UserId.GetHashCode();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
|
||||
public class AlreadyJoinedException : Exception
|
||||
{
|
||||
public AlreadyJoinedException()
|
||||
{
|
||||
}
|
||||
|
||||
public AlreadyJoinedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AlreadyJoinedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
|
||||
public class AlreadyStartedException : Exception
|
||||
{
|
||||
public AlreadyStartedException()
|
||||
{
|
||||
}
|
||||
|
||||
public AlreadyStartedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AlreadyStartedException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
|
||||
public class AnimalRaceFullException : Exception
|
||||
{
|
||||
public AnimalRaceFullException()
|
||||
{
|
||||
}
|
||||
|
||||
public AnimalRaceFullException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public AnimalRaceFullException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
|
||||
|
||||
public class NotEnoughFundsException : Exception
|
||||
{
|
||||
public NotEnoughFundsException()
|
||||
{
|
||||
}
|
||||
|
||||
public NotEnoughFundsException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public NotEnoughFundsException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
16
src/NadekoBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
Normal file
16
src/NadekoBot/Modules/Gambling/AnimalRacing/RaceOptions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
#nullable disable
|
||||
using CommandLine;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing;
|
||||
|
||||
public class RaceOptions : INadekoCommandOptions
|
||||
{
|
||||
[Option('s', "start-time", Default = 20, Required = false)]
|
||||
public int StartTime { get; set; } = 20;
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (StartTime is < 10 or > 120)
|
||||
StartTime = 20;
|
||||
}
|
||||
}
|
||||
118
src/NadekoBot/Modules/Gambling/Bank/BankCommands.cs
Normal file
118
src/NadekoBot/Modules/Gambling/Bank/BankCommands.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Bank;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Name("Bank")]
|
||||
[Group("bank")]
|
||||
public partial class BankCommands : GamblingModule<IBankService>
|
||||
{
|
||||
private readonly IBankService _bank;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public BankCommands(GamblingConfigService gcs,
|
||||
IBankService bank,
|
||||
DiscordSocketClient client) : base(gcs)
|
||||
{
|
||||
_bank = bank;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task BankDeposit([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
return;
|
||||
|
||||
if (await _bank.DepositAsync(ctx.User.Id, amount))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.bank_deposited(N(amount)));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task BankWithdraw([OverrideTypeReader(typeof(BankBalanceTypeReader))] long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
return;
|
||||
|
||||
if (await _bank.WithdrawAsync(ctx.User.Id, amount))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.bank_withdrew(N(amount)));
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.bank_withdraw_insuff(CurrencySign));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task BankBalance()
|
||||
{
|
||||
var bal = await _bank.GetBalanceAsync(ctx.User.Id);
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithDescription(GetText(strs.bank_balance(N(bal))));
|
||||
|
||||
try
|
||||
{
|
||||
await ctx.User.EmbedAsync(eb);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.cant_dm);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BankTakeInternalAsync(long amount, ulong userId)
|
||||
{
|
||||
if (await _bank.TakeAsync(userId, amount))
|
||||
{
|
||||
await ctx.OkAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.take_fail(N(amount),
|
||||
_client.GetUser(userId)?.ToString()
|
||||
?? userId.ToString(),
|
||||
CurrencySign));
|
||||
}
|
||||
|
||||
private async Task BankAwardInternalAsync(long amount, ulong userId)
|
||||
{
|
||||
if (await _bank.AwardAsync(userId, amount))
|
||||
{
|
||||
await ctx.OkAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
[Priority(1)]
|
||||
public async Task BankTake(long amount, [Leftover] IUser user)
|
||||
=> await BankTakeInternalAsync(amount, user.Id);
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
[Priority(0)]
|
||||
public async Task BankTake(long amount, ulong userId)
|
||||
=> await BankTakeInternalAsync(amount, userId);
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task BankAward(long amount, [Leftover] IUser user)
|
||||
=> await BankAwardInternalAsync(amount, user.Id);
|
||||
}
|
||||
}
|
||||
119
src/NadekoBot/Modules/Gambling/Bank/BankService.cs
Normal file
119
src/NadekoBot/Modules/Gambling/Bank/BankService.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Bank;
|
||||
|
||||
public sealed class BankService : IBankService, INService
|
||||
{
|
||||
private readonly ICurrencyService _cur;
|
||||
private readonly DbService _db;
|
||||
|
||||
public BankService(ICurrencyService cur, DbService db)
|
||||
{
|
||||
_cur = cur;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<bool> AwardAsync(ulong userId, long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.GetTable<BankUser>()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
Balance = amount
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
Balance = old.Balance + amount
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> TakeAsync(ulong userId, long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var rows = await ctx.Set<BankUser>()
|
||||
.ToLinqToDBTable()
|
||||
.Where(x => x.UserId == userId && x.Balance >= amount)
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
Balance = old.Balance - amount
|
||||
});
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> DepositAsync(ulong userId, long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
|
||||
if (!await _cur.RemoveAsync(userId, amount, new("bank", "deposit")))
|
||||
return false;
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
await ctx.Set<BankUser>()
|
||||
.ToLinqToDBTable()
|
||||
.InsertOrUpdateAsync(() => new()
|
||||
{
|
||||
UserId = userId,
|
||||
Balance = amount
|
||||
},
|
||||
(old) => new()
|
||||
{
|
||||
Balance = old.Balance + amount
|
||||
},
|
||||
() => new()
|
||||
{
|
||||
UserId = userId
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> WithdrawAsync(ulong userId, long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(amount));
|
||||
|
||||
await using var ctx = _db.GetDbContext();
|
||||
var rows = await ctx.Set<BankUser>()
|
||||
.ToLinqToDBTable()
|
||||
.Where(x => x.UserId == userId && x.Balance >= amount)
|
||||
.UpdateAsync((old) => new()
|
||||
{
|
||||
Balance = old.Balance - amount
|
||||
});
|
||||
|
||||
if (rows > 0)
|
||||
{
|
||||
await _cur.AddAsync(userId, amount, new("bank", "withdraw"));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<long> GetBalanceAsync(ulong userId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return (await ctx.Set<BankUser>()
|
||||
.ToLinqToDBTable()
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId))
|
||||
?.Balance
|
||||
?? 0;
|
||||
}
|
||||
}
|
||||
183
src/NadekoBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
Normal file
183
src/NadekoBot/Modules/Gambling/BlackJack/BlackJackCommands.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Blackjack;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
public partial class BlackJackCommands : GamblingSubmodule<BlackJackService>
|
||||
{
|
||||
public enum BjAction
|
||||
{
|
||||
Hit = int.MinValue,
|
||||
Stand,
|
||||
Double
|
||||
}
|
||||
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly DbService _db;
|
||||
private IUserMessage msg;
|
||||
|
||||
public BlackJackCommands(ICurrencyService cs, DbService db, GamblingConfigService gamblingConf)
|
||||
: base(gamblingConf)
|
||||
{
|
||||
_cs = cs;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task BlackJack([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount))
|
||||
return;
|
||||
|
||||
var newBj = new Blackjack(_cs);
|
||||
Blackjack bj;
|
||||
if (newBj == (bj = _service.Games.GetOrAdd(ctx.Channel.Id, newBj)))
|
||||
{
|
||||
if (!await bj.Join(ctx.User, amount))
|
||||
{
|
||||
_service.Games.TryRemove(ctx.Channel.Id, out _);
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
bj.StateUpdated += Bj_StateUpdated;
|
||||
bj.GameEnded += Bj_GameEnded;
|
||||
bj.Start();
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.bj_created);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (await bj.Join(ctx.User, amount))
|
||||
await ReplyConfirmLocalizedAsync(strs.bj_joined);
|
||||
else
|
||||
{
|
||||
Log.Information("{User} can't join a blackjack game as it's in {BlackjackState} state already",
|
||||
ctx.User,
|
||||
bj.State);
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.Message.DeleteAsync();
|
||||
}
|
||||
|
||||
private Task Bj_GameEnded(Blackjack arg)
|
||||
{
|
||||
_service.Games.TryRemove(ctx.Channel.Id, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task Bj_StateUpdated(Blackjack bj)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (msg is not null)
|
||||
_ = msg.DeleteAsync();
|
||||
|
||||
var c = bj.Dealer.Cards.Select(x => x.GetEmojiString())
|
||||
.ToList();
|
||||
var dealerIcon = "❔ ";
|
||||
if (bj.State == Blackjack.GameState.Ended)
|
||||
{
|
||||
if (bj.Dealer.GetHandValue() == 21)
|
||||
dealerIcon = "💰 ";
|
||||
else if (bj.Dealer.GetHandValue() > 21)
|
||||
dealerIcon = "💥 ";
|
||||
else
|
||||
dealerIcon = "🏁 ";
|
||||
}
|
||||
|
||||
var cStr = string.Concat(c.Select(x => x[..^1] + " "));
|
||||
cStr += "\n" + string.Concat(c.Select(x => x.Last() + " "));
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle("BlackJack")
|
||||
.AddField($"{dealerIcon} Dealer's Hand | Value: {bj.Dealer.GetHandValue()}", cStr);
|
||||
|
||||
if (bj.CurrentUser is not null)
|
||||
embed.WithFooter($"Player to make a choice: {bj.CurrentUser.DiscordUser}");
|
||||
|
||||
foreach (var p in bj.Players)
|
||||
{
|
||||
c = p.Cards.Select(x => x.GetEmojiString()).ToList();
|
||||
cStr = "-\t" + string.Concat(c.Select(x => x[..^1] + " "));
|
||||
cStr += "\n-\t" + string.Concat(c.Select(x => x.Last() + " "));
|
||||
var full = $"{p.DiscordUser.ToString().TrimTo(20)} | Bet: {N(p.Bet)} | Value: {p.GetHandValue()}";
|
||||
if (bj.State == Blackjack.GameState.Ended)
|
||||
{
|
||||
if (p.State == User.UserState.Lost)
|
||||
full = "❌ " + full;
|
||||
else
|
||||
full = "✅ " + full;
|
||||
}
|
||||
else if (p == bj.CurrentUser)
|
||||
full = "▶ " + full;
|
||||
else if (p.State == User.UserState.Stand)
|
||||
full = "⏹ " + full;
|
||||
else if (p.State == User.UserState.Bust)
|
||||
full = "💥 " + full;
|
||||
else if (p.State == User.UserState.Blackjack)
|
||||
full = "💰 " + full;
|
||||
|
||||
embed.AddField(full, cStr);
|
||||
}
|
||||
|
||||
msg = await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private string UserToString(User x)
|
||||
{
|
||||
var playerName = x.State == User.UserState.Bust
|
||||
? Format.Strikethrough(x.DiscordUser.ToString().TrimTo(30))
|
||||
: x.DiscordUser.ToString();
|
||||
|
||||
// var hand = $"{string.Concat(x.Cards.Select(y => "〖" + y.GetEmojiString() + "〗"))}";
|
||||
|
||||
|
||||
return $"{playerName} | Bet: {x.Bet}\n";
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Hit()
|
||||
=> InternalBlackJack(BjAction.Hit);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Stand()
|
||||
=> InternalBlackJack(BjAction.Stand);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Double()
|
||||
=> InternalBlackJack(BjAction.Double);
|
||||
|
||||
private async Task InternalBlackJack(BjAction a)
|
||||
{
|
||||
if (!_service.Games.TryGetValue(ctx.Channel.Id, out var bj))
|
||||
return;
|
||||
|
||||
if (a == BjAction.Hit)
|
||||
await bj.Hit(ctx.User);
|
||||
else if (a == BjAction.Stand)
|
||||
await bj.Stand(ctx.User);
|
||||
else if (a == BjAction.Double)
|
||||
{
|
||||
if (!await bj.Double(ctx.User))
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
}
|
||||
|
||||
await ctx.Message.DeleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common.Blackjack;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class BlackJackService : INService
|
||||
{
|
||||
public ConcurrentDictionary<ulong, Blackjack> Games { get; } = new();
|
||||
}
|
||||
329
src/NadekoBot/Modules/Gambling/BlackJack/Blackjack.cs
Normal file
329
src/NadekoBot/Modules/Gambling/BlackJack/Blackjack.cs
Normal file
@@ -0,0 +1,329 @@
|
||||
#nullable disable
|
||||
using Nadeko.Econ;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Blackjack;
|
||||
|
||||
public class Blackjack
|
||||
{
|
||||
public enum GameState
|
||||
{
|
||||
Starting,
|
||||
Playing,
|
||||
Ended
|
||||
}
|
||||
|
||||
public event Func<Blackjack, Task> StateUpdated;
|
||||
public event Func<Blackjack, Task> GameEnded;
|
||||
|
||||
private Deck Deck { get; } = new QuadDeck();
|
||||
public Dealer Dealer { get; set; }
|
||||
|
||||
|
||||
public List<User> Players { get; set; } = new();
|
||||
public GameState State { get; set; } = GameState.Starting;
|
||||
public User CurrentUser { get; private set; }
|
||||
|
||||
private TaskCompletionSource<bool> currentUserMove;
|
||||
private readonly ICurrencyService _cs;
|
||||
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
|
||||
public Blackjack(ICurrencyService cs)
|
||||
{
|
||||
_cs = cs;
|
||||
Dealer = new();
|
||||
}
|
||||
|
||||
public void Start()
|
||||
=> _ = GameLoop();
|
||||
|
||||
public async Task GameLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
//wait for players to join
|
||||
await Task.Delay(20000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
State = GameState.Playing;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
|
||||
await PrintState();
|
||||
//if no users joined the game, end it
|
||||
if (!Players.Any())
|
||||
{
|
||||
State = GameState.Ended;
|
||||
_ = GameEnded?.Invoke(this);
|
||||
return;
|
||||
}
|
||||
|
||||
//give 1 card to the dealer and 2 to each player
|
||||
Dealer.Cards.Add(Deck.Draw());
|
||||
foreach (var usr in Players)
|
||||
{
|
||||
usr.Cards.Add(Deck.Draw());
|
||||
usr.Cards.Add(Deck.Draw());
|
||||
|
||||
if (usr.GetHandValue() == 21)
|
||||
usr.State = User.UserState.Blackjack;
|
||||
}
|
||||
|
||||
//go through all users and ask them what they want to do
|
||||
foreach (var usr in Players.Where(x => !x.Done))
|
||||
{
|
||||
while (!usr.Done)
|
||||
{
|
||||
Log.Information("Waiting for {DiscordUser}'s move", usr.DiscordUser);
|
||||
await PromptUserMove(usr);
|
||||
}
|
||||
}
|
||||
|
||||
await PrintState();
|
||||
State = GameState.Ended;
|
||||
await Task.Delay(2500);
|
||||
Log.Information("Dealer moves");
|
||||
await DealerMoves();
|
||||
await PrintState();
|
||||
_ = GameEnded?.Invoke(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "REPORT THE MESSAGE BELOW IN #NadekoLog SERVER PLEASE");
|
||||
State = GameState.Ended;
|
||||
_ = GameEnded?.Invoke(this);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PromptUserMove(User usr)
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
var pause = Task.Delay(20000, cts.Token); //10 seconds to decide
|
||||
CurrentUser = usr;
|
||||
currentUserMove = new();
|
||||
await PrintState();
|
||||
// either wait for the user to make an action and
|
||||
// if he doesn't - stand
|
||||
var finished = await Task.WhenAny(pause, currentUserMove.Task);
|
||||
if (finished == pause)
|
||||
await Stand(usr);
|
||||
else
|
||||
cts.Cancel();
|
||||
|
||||
CurrentUser = null;
|
||||
currentUserMove = null;
|
||||
}
|
||||
|
||||
public async Task<bool> Join(IUser user, long bet)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (State != GameState.Starting)
|
||||
return false;
|
||||
|
||||
if (Players.Count >= 5)
|
||||
return false;
|
||||
|
||||
if (!await _cs.RemoveAsync(user, bet, new("blackjack", "gamble")))
|
||||
return false;
|
||||
|
||||
Players.Add(new(user, bet));
|
||||
_ = PrintState();
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Stand(IUser u)
|
||||
{
|
||||
var cu = CurrentUser;
|
||||
|
||||
if (cu is not null && cu.DiscordUser == u)
|
||||
return await Stand(cu);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> Stand(User u)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (State != GameState.Playing)
|
||||
return false;
|
||||
|
||||
if (CurrentUser != u)
|
||||
return false;
|
||||
|
||||
u.State = User.UserState.Stand;
|
||||
currentUserMove.TrySetResult(true);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DealerMoves()
|
||||
{
|
||||
var hw = Dealer.GetHandValue();
|
||||
while (hw < 17
|
||||
|| (hw == 17
|
||||
&& Dealer.Cards.Count(x => x.Number == 1) > (Dealer.GetRawHandValue() - 17) / 10)) // hit on soft 17
|
||||
{
|
||||
/* Dealer has
|
||||
A 6
|
||||
That's 17, soft
|
||||
hw == 17 => true
|
||||
number of aces = 1
|
||||
1 > 17-17 /10 => true
|
||||
|
||||
AA 5
|
||||
That's 17, again soft, since one ace is worth 11, even though another one is 1
|
||||
hw == 17 => true
|
||||
number of aces = 2
|
||||
2 > 27 - 17 / 10 => true
|
||||
|
||||
AA Q 5
|
||||
That's 17, but not soft, since both aces are worth 1
|
||||
hw == 17 => true
|
||||
number of aces = 2
|
||||
2 > 37 - 17 / 10 => false
|
||||
* */
|
||||
Dealer.Cards.Add(Deck.Draw());
|
||||
hw = Dealer.GetHandValue();
|
||||
}
|
||||
|
||||
if (hw > 21)
|
||||
{
|
||||
foreach (var usr in Players)
|
||||
{
|
||||
if (usr.State is User.UserState.Stand or User.UserState.Blackjack)
|
||||
usr.State = User.UserState.Won;
|
||||
else
|
||||
usr.State = User.UserState.Lost;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var usr in Players)
|
||||
{
|
||||
if (usr.State == User.UserState.Blackjack)
|
||||
usr.State = User.UserState.Won;
|
||||
else if (usr.State == User.UserState.Stand)
|
||||
usr.State = hw < usr.GetHandValue() ? User.UserState.Won : User.UserState.Lost;
|
||||
else
|
||||
usr.State = User.UserState.Lost;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var usr in Players)
|
||||
{
|
||||
if (usr.State is User.UserState.Won or User.UserState.Blackjack)
|
||||
await _cs.AddAsync(usr.DiscordUser.Id, usr.Bet * 2, new("blackjack", "win"));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Double(IUser u)
|
||||
{
|
||||
var cu = CurrentUser;
|
||||
|
||||
if (cu is not null && cu.DiscordUser == u)
|
||||
return await Double(cu);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> Double(User u)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (State != GameState.Playing)
|
||||
return false;
|
||||
|
||||
if (CurrentUser != u)
|
||||
return false;
|
||||
|
||||
if (!await _cs.RemoveAsync(u.DiscordUser.Id, u.Bet, new("blackjack", "double")))
|
||||
return false;
|
||||
|
||||
u.Bet *= 2;
|
||||
|
||||
u.Cards.Add(Deck.Draw());
|
||||
|
||||
if (u.GetHandValue() == 21)
|
||||
//blackjack
|
||||
u.State = User.UserState.Blackjack;
|
||||
else if (u.GetHandValue() > 21)
|
||||
// user busted
|
||||
u.State = User.UserState.Bust;
|
||||
else
|
||||
//with double you just get one card, and then you're done
|
||||
u.State = User.UserState.Stand;
|
||||
currentUserMove.TrySetResult(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Hit(IUser u)
|
||||
{
|
||||
var cu = CurrentUser;
|
||||
|
||||
if (cu is not null && cu.DiscordUser == u)
|
||||
return await Hit(cu);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> Hit(User u)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (State != GameState.Playing)
|
||||
return false;
|
||||
|
||||
if (CurrentUser != u)
|
||||
return false;
|
||||
|
||||
u.Cards.Add(Deck.Draw());
|
||||
|
||||
if (u.GetHandValue() == 21)
|
||||
//blackjack
|
||||
u.State = User.UserState.Blackjack;
|
||||
else if (u.GetHandValue() > 21)
|
||||
// user busted
|
||||
u.State = User.UserState.Bust;
|
||||
|
||||
currentUserMove.TrySetResult(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public Task PrintState()
|
||||
{
|
||||
if (StateUpdated is null)
|
||||
return Task.CompletedTask;
|
||||
return StateUpdated.Invoke(this);
|
||||
}
|
||||
}
|
||||
58
src/NadekoBot/Modules/Gambling/BlackJack/Player.cs
Normal file
58
src/NadekoBot/Modules/Gambling/BlackJack/Player.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
#nullable disable
|
||||
using Nadeko.Econ;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Blackjack;
|
||||
|
||||
public abstract class Player
|
||||
{
|
||||
public List<Deck.Card> Cards { get; } = new();
|
||||
|
||||
public int GetHandValue()
|
||||
{
|
||||
var val = GetRawHandValue();
|
||||
|
||||
// while the hand value is greater than 21, for each ace you have in the deck
|
||||
// reduce the value by 10 until it drops below 22
|
||||
// (emulating the fact that ace is either a 1 or a 11)
|
||||
var i = Cards.Count(x => x.Number == 1);
|
||||
while (val > 21 && i-- > 0)
|
||||
val -= 10;
|
||||
return val;
|
||||
}
|
||||
|
||||
public int GetRawHandValue()
|
||||
=> Cards.Sum(x => x.Number == 1 ? 11 : x.Number >= 10 ? 10 : x.Number);
|
||||
}
|
||||
|
||||
public class Dealer : Player
|
||||
{
|
||||
}
|
||||
|
||||
public class User : Player
|
||||
{
|
||||
public enum UserState
|
||||
{
|
||||
Waiting,
|
||||
Stand,
|
||||
Bust,
|
||||
Blackjack,
|
||||
Won,
|
||||
Lost
|
||||
}
|
||||
|
||||
public UserState State { get; set; } = UserState.Waiting;
|
||||
public long Bet { get; set; }
|
||||
public IUser DiscordUser { get; }
|
||||
|
||||
public bool Done
|
||||
=> State != UserState.Waiting;
|
||||
|
||||
public User(IUser user, long bet)
|
||||
{
|
||||
if (bet <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(bet));
|
||||
|
||||
Bet = bet;
|
||||
DiscordUser = user;
|
||||
}
|
||||
}
|
||||
39
src/NadekoBot/Modules/Gambling/CleanupCommands.cs
Normal file
39
src/NadekoBot/Modules/Gambling/CleanupCommands.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Nadeko.Bot.Modules.Gambling.Gambling._Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public class CleanupCommands : CleanupModuleBase
|
||||
{
|
||||
private readonly IGamblingCleanupService _gcs;
|
||||
|
||||
public CleanupCommands(IGamblingCleanupService gcs)
|
||||
{
|
||||
_gcs = gcs;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task DeleteWaifus()
|
||||
=> ConfirmActionInternalAsync("Delete Waifus", () => _gcs.DeleteWaifus());
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public async Task DeleteWaifu(IUser user)
|
||||
=> await DeleteWaifu(user.Id);
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task DeleteWaifu(ulong userId)
|
||||
=> ConfirmActionInternalAsync($"Delete Waifu {userId}", () => _gcs.DeleteWaifu(userId));
|
||||
|
||||
|
||||
[Cmd]
|
||||
[OwnerOnly]
|
||||
public Task DeleteCurrency()
|
||||
=> ConfirmActionInternalAsync("Delete Currency", () => _gcs.DeleteCurrency());
|
||||
|
||||
}
|
||||
}
|
||||
409
src/NadekoBot/Modules/Gambling/Connect4/Connect4.cs
Normal file
409
src/NadekoBot/Modules/Gambling/Connect4/Connect4.cs
Normal file
@@ -0,0 +1,409 @@
|
||||
#nullable disable
|
||||
using CommandLine;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Connect4;
|
||||
|
||||
public sealed class Connect4Game : IDisposable
|
||||
{
|
||||
public enum Field //temporary most likely
|
||||
{
|
||||
Empty,
|
||||
P1,
|
||||
P2
|
||||
}
|
||||
|
||||
public enum Phase
|
||||
{
|
||||
Joining, // waiting for second player to join
|
||||
P1Move,
|
||||
P2Move,
|
||||
Ended
|
||||
}
|
||||
|
||||
public enum Result
|
||||
{
|
||||
Draw,
|
||||
CurrentPlayerWon,
|
||||
OtherPlayerWon
|
||||
}
|
||||
|
||||
public const int NUMBER_OF_COLUMNS = 7;
|
||||
public const int NUMBER_OF_ROWS = 6;
|
||||
|
||||
//public event Func<Connect4Game, Task> OnGameStarted;
|
||||
public event Func<Connect4Game, Task> OnGameStateUpdated;
|
||||
public event Func<Connect4Game, Task> OnGameFailedToStart;
|
||||
public event Func<Connect4Game, Result, Task> OnGameEnded;
|
||||
|
||||
public Phase CurrentPhase { get; private set; } = Phase.Joining;
|
||||
|
||||
public ImmutableArray<Field> GameState
|
||||
=> _gameState.ToImmutableArray();
|
||||
|
||||
public ImmutableArray<(ulong UserId, string Username)?> Players
|
||||
=> _players.ToImmutableArray();
|
||||
|
||||
public (ulong UserId, string Username) CurrentPlayer
|
||||
=> CurrentPhase == Phase.P1Move ? _players[0].Value : _players[1].Value;
|
||||
|
||||
public (ulong UserId, string Username) OtherPlayer
|
||||
=> CurrentPhase == Phase.P2Move ? _players[0].Value : _players[1].Value;
|
||||
|
||||
//state is bottom to top, left to right
|
||||
private readonly Field[] _gameState = new Field[NUMBER_OF_ROWS * NUMBER_OF_COLUMNS];
|
||||
private readonly (ulong UserId, string Username)?[] _players = new (ulong, string)?[2];
|
||||
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
private readonly Options _options;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly NadekoRandom _rng;
|
||||
|
||||
private Timer playerTimeoutTimer;
|
||||
|
||||
/* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
* [ ][ ][ ][ ][ ][ ]
|
||||
*/
|
||||
|
||||
public Connect4Game(
|
||||
ulong userId,
|
||||
string userName,
|
||||
Options options,
|
||||
ICurrencyService cs)
|
||||
{
|
||||
_players[0] = (userId, userName);
|
||||
_options = options;
|
||||
_cs = cs;
|
||||
|
||||
_rng = new();
|
||||
for (var i = 0; i < NUMBER_OF_COLUMNS * NUMBER_OF_ROWS; i++)
|
||||
_gameState[i] = Field.Empty;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (CurrentPhase != Phase.Joining)
|
||||
return;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(15000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_players[1] is null)
|
||||
{
|
||||
_ = OnGameFailedToStart?.Invoke(this);
|
||||
CurrentPhase = Phase.Ended;
|
||||
await _cs.AddAsync(_players[0].Value.UserId, _options.Bet, new("connect4", "refund"));
|
||||
}
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> Join(ulong userId, string userName, int bet)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (CurrentPhase != Phase.Joining) //can't join if its not a joining phase
|
||||
return false;
|
||||
|
||||
if (_players[0].Value.UserId == userId) // same user can't join own game
|
||||
return false;
|
||||
|
||||
if (bet != _options.Bet) // can't join if bet amount is not the same
|
||||
return false;
|
||||
|
||||
if (!await _cs.RemoveAsync(userId, bet, new("connect4", "bet"))) // user doesn't have enough money to gamble
|
||||
return false;
|
||||
|
||||
if (_rng.Next(0, 2) == 0) //rolling from 0-1, if number is 0, join as first player
|
||||
{
|
||||
_players[1] = _players[0];
|
||||
_players[0] = (userId, userName);
|
||||
}
|
||||
else //else join as a second player
|
||||
_players[1] = (userId, userName);
|
||||
|
||||
CurrentPhase = Phase.P1Move; //start the game
|
||||
playerTimeoutTimer = new(async _ =>
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
EndGame(Result.OtherPlayerWon, OtherPlayer.UserId);
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
},
|
||||
null,
|
||||
TimeSpan.FromSeconds(_options.TurnTimer),
|
||||
TimeSpan.FromSeconds(_options.TurnTimer));
|
||||
_ = OnGameStateUpdated?.Invoke(this);
|
||||
|
||||
return true;
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
public async Task<bool> Input(ulong userId, int inputCol)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
inputCol -= 1;
|
||||
if (CurrentPhase is Phase.Ended or Phase.Joining)
|
||||
return false;
|
||||
|
||||
if (!((_players[0].Value.UserId == userId && CurrentPhase == Phase.P1Move)
|
||||
|| (_players[1].Value.UserId == userId && CurrentPhase == Phase.P2Move)))
|
||||
return false;
|
||||
|
||||
if (inputCol is < 0 or > NUMBER_OF_COLUMNS) //invalid input
|
||||
return false;
|
||||
|
||||
if (IsColumnFull(inputCol)) //can't play there event?
|
||||
return false;
|
||||
|
||||
var start = NUMBER_OF_ROWS * inputCol;
|
||||
for (var i = start; i < start + NUMBER_OF_ROWS; i++)
|
||||
{
|
||||
if (_gameState[i] == Field.Empty)
|
||||
{
|
||||
_gameState[i] = GetPlayerPiece(userId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//check winnning condition
|
||||
// ok, i'll go from [0-2] in rows (and through all columns) and check upward if 4 are connected
|
||||
|
||||
for (var i = 0; i < NUMBER_OF_ROWS - 3; i++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
for (var j = 0; j < NUMBER_OF_COLUMNS; j++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
var first = _gameState[i + (j * NUMBER_OF_ROWS)];
|
||||
if (first != Field.Empty)
|
||||
{
|
||||
for (var k = 1; k < 4; k++)
|
||||
{
|
||||
var next = _gameState[i + k + (j * NUMBER_OF_ROWS)];
|
||||
if (next == first)
|
||||
{
|
||||
if (k == 3)
|
||||
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||
else
|
||||
continue;
|
||||
}
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// i'll go [0-1] in columns (and through all rows) and check to the right if 4 are connected
|
||||
for (var i = 0; i < NUMBER_OF_COLUMNS - 3; i++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
for (var j = 0; j < NUMBER_OF_ROWS; j++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
var first = _gameState[j + (i * NUMBER_OF_ROWS)];
|
||||
if (first != Field.Empty)
|
||||
{
|
||||
for (var k = 1; k < 4; k++)
|
||||
{
|
||||
var next = _gameState[j + ((i + k) * NUMBER_OF_ROWS)];
|
||||
if (next == first)
|
||||
{
|
||||
if (k == 3)
|
||||
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||
else
|
||||
continue;
|
||||
}
|
||||
else
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//need to check diagonal now
|
||||
for (var col = 0; col < NUMBER_OF_COLUMNS; col++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
for (var row = 0; row < NUMBER_OF_ROWS; row++)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
break;
|
||||
|
||||
var first = _gameState[row + (col * NUMBER_OF_ROWS)];
|
||||
|
||||
if (first != Field.Empty)
|
||||
{
|
||||
var same = 1;
|
||||
|
||||
//top left
|
||||
for (var i = 1; i < 4; i++)
|
||||
{
|
||||
//while going top left, rows are increasing, columns are decreasing
|
||||
var curRow = row + i;
|
||||
var curCol = col - i;
|
||||
|
||||
//check if current values are in range
|
||||
if (curRow is >= NUMBER_OF_ROWS or < 0)
|
||||
break;
|
||||
if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
|
||||
break;
|
||||
|
||||
var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
|
||||
if (cur == first)
|
||||
same++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
if (same == 4)
|
||||
{
|
||||
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||
break;
|
||||
}
|
||||
|
||||
same = 1;
|
||||
|
||||
//top right
|
||||
for (var i = 1; i < 4; i++)
|
||||
{
|
||||
//while going top right, rows are increasing, columns are increasing
|
||||
var curRow = row + i;
|
||||
var curCol = col + i;
|
||||
|
||||
//check if current values are in range
|
||||
if (curRow is >= NUMBER_OF_ROWS or < 0)
|
||||
break;
|
||||
if (curCol is < 0 or >= NUMBER_OF_COLUMNS)
|
||||
break;
|
||||
|
||||
var cur = _gameState[curRow + (curCol * NUMBER_OF_ROWS)];
|
||||
if (cur == first)
|
||||
same++;
|
||||
else
|
||||
break;
|
||||
}
|
||||
|
||||
if (same == 4)
|
||||
{
|
||||
EndGame(Result.CurrentPlayerWon, CurrentPlayer.UserId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//check draw? if it's even possible
|
||||
if (_gameState.All(x => x != Field.Empty))
|
||||
EndGame(Result.Draw, null);
|
||||
|
||||
if (CurrentPhase != Phase.Ended)
|
||||
{
|
||||
if (CurrentPhase == Phase.P1Move)
|
||||
CurrentPhase = Phase.P2Move;
|
||||
else
|
||||
CurrentPhase = Phase.P1Move;
|
||||
|
||||
ResetTimer();
|
||||
}
|
||||
|
||||
_ = OnGameStateUpdated?.Invoke(this);
|
||||
return true;
|
||||
}
|
||||
finally { _locker.Release(); }
|
||||
}
|
||||
|
||||
private void ResetTimer()
|
||||
=> playerTimeoutTimer.Change(TimeSpan.FromSeconds(_options.TurnTimer),
|
||||
TimeSpan.FromSeconds(_options.TurnTimer));
|
||||
|
||||
private void EndGame(Result result, ulong? winId)
|
||||
{
|
||||
if (CurrentPhase == Phase.Ended)
|
||||
return;
|
||||
_ = OnGameEnded?.Invoke(this, result);
|
||||
CurrentPhase = Phase.Ended;
|
||||
|
||||
if (result == Result.Draw)
|
||||
{
|
||||
_cs.AddAsync(CurrentPlayer.UserId, _options.Bet, new("connect4", "draw"));
|
||||
_cs.AddAsync(OtherPlayer.UserId, _options.Bet, new("connect4", "draw"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (winId is not null)
|
||||
_cs.AddAsync(winId.Value, (long)(_options.Bet * 1.98), new("connect4", "win"));
|
||||
}
|
||||
|
||||
private Field GetPlayerPiece(ulong userId)
|
||||
=> _players[0].Value.UserId == userId ? Field.P1 : Field.P2;
|
||||
|
||||
//column is full if there are no empty fields
|
||||
private bool IsColumnFull(int column)
|
||||
{
|
||||
var start = NUMBER_OF_ROWS * column;
|
||||
for (var i = start; i < start + NUMBER_OF_ROWS; i++)
|
||||
{
|
||||
if (_gameState[i] == Field.Empty)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
OnGameFailedToStart = null;
|
||||
OnGameStateUpdated = null;
|
||||
OnGameEnded = null;
|
||||
playerTimeoutTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
}
|
||||
|
||||
|
||||
public class Options : INadekoCommandOptions
|
||||
{
|
||||
[Option('t',
|
||||
"turn-timer",
|
||||
Required = false,
|
||||
Default = 15,
|
||||
HelpText = "Turn time in seconds. It has to be between 5 and 60. Default 15.")]
|
||||
public int TurnTimer { get; set; } = 15;
|
||||
|
||||
[Option('b', "bet", Required = false, Default = 0, HelpText = "Amount you bet. Default 0.")]
|
||||
public int Bet { get; set; }
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (TurnTimer is < 5 or > 60)
|
||||
TurnTimer = 15;
|
||||
|
||||
if (Bet < 0)
|
||||
Bet = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
204
src/NadekoBot/Modules/Gambling/Connect4/Connect4Commands.cs
Normal file
204
src/NadekoBot/Modules/Gambling/Connect4/Connect4Commands.cs
Normal file
@@ -0,0 +1,204 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Connect4;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using System.Text;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class Connect4Commands : GamblingSubmodule<GamblingService>
|
||||
{
|
||||
private static readonly string[] _numbers =
|
||||
{
|
||||
":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:"
|
||||
};
|
||||
|
||||
private int RepostCounter
|
||||
{
|
||||
get => repostCounter;
|
||||
set
|
||||
{
|
||||
if (value is < 0 or > 7)
|
||||
repostCounter = 0;
|
||||
else
|
||||
repostCounter = value;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ICurrencyService _cs;
|
||||
|
||||
private IUserMessage msg;
|
||||
|
||||
private int repostCounter;
|
||||
|
||||
public Connect4Commands(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService gamb)
|
||||
: base(gamb)
|
||||
{
|
||||
_client = client;
|
||||
_cs = cs;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NadekoOptions<Connect4Game.Options>]
|
||||
public async Task Connect4(params string[] args)
|
||||
{
|
||||
var (options, _) = OptionsParser.ParseFrom(new Connect4Game.Options(), args);
|
||||
if (!await CheckBetOptional(options.Bet))
|
||||
return;
|
||||
|
||||
var newGame = new Connect4Game(ctx.User.Id, ctx.User.ToString(), options, _cs);
|
||||
Connect4Game game;
|
||||
if ((game = _service.Connect4Games.GetOrAdd(ctx.Channel.Id, newGame)) != newGame)
|
||||
{
|
||||
if (game.CurrentPhase != Connect4Game.Phase.Joining)
|
||||
return;
|
||||
|
||||
newGame.Dispose();
|
||||
//means game already exists, try to join
|
||||
await game.Join(ctx.User.Id, ctx.User.ToString(), options.Bet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.Bet > 0)
|
||||
{
|
||||
if (!await _cs.RemoveAsync(ctx.User.Id, options.Bet, new("connect4", "bet")))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
_service.Connect4Games.TryRemove(ctx.Channel.Id, out _);
|
||||
game.Dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
game.OnGameStateUpdated += Game_OnGameStateUpdated;
|
||||
game.OnGameFailedToStart += GameOnGameFailedToStart;
|
||||
game.OnGameEnded += GameOnGameEnded;
|
||||
_client.MessageReceived += ClientMessageReceived;
|
||||
|
||||
game.Initialize();
|
||||
if (options.Bet == 0)
|
||||
await ReplyConfirmLocalizedAsync(strs.connect4_created);
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.connect4_created_bet(N(options.Bet)));
|
||||
|
||||
Task ClientMessageReceived(SocketMessage arg)
|
||||
{
|
||||
if (ctx.Channel.Id != arg.Channel.Id)
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var success = false;
|
||||
if (int.TryParse(arg.Content, out var col))
|
||||
success = await game.Input(arg.Author.Id, col);
|
||||
|
||||
if (success)
|
||||
{
|
||||
try { await arg.DeleteAsync(); }
|
||||
catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
if (game.CurrentPhase is Connect4Game.Phase.Joining or Connect4Game.Phase.Ended)
|
||||
return;
|
||||
RepostCounter++;
|
||||
if (RepostCounter == 0)
|
||||
{
|
||||
try { msg = await ctx.Channel.SendMessageAsync("", embed: (Embed)msg.Embeds.First()); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task GameOnGameFailedToStart(Connect4Game arg)
|
||||
{
|
||||
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
|
||||
{
|
||||
_client.MessageReceived -= ClientMessageReceived;
|
||||
toDispose.Dispose();
|
||||
}
|
||||
|
||||
return ErrorLocalizedAsync(strs.connect4_failed_to_start);
|
||||
}
|
||||
|
||||
Task GameOnGameEnded(Connect4Game arg, Connect4Game.Result result)
|
||||
{
|
||||
if (_service.Connect4Games.TryRemove(ctx.Channel.Id, out var toDispose))
|
||||
{
|
||||
_client.MessageReceived -= ClientMessageReceived;
|
||||
toDispose.Dispose();
|
||||
}
|
||||
|
||||
string title;
|
||||
if (result == Connect4Game.Result.CurrentPlayerWon)
|
||||
{
|
||||
title = GetText(strs.connect4_won(Format.Bold(arg.CurrentPlayer.Username),
|
||||
Format.Bold(arg.OtherPlayer.Username)));
|
||||
}
|
||||
else if (result == Connect4Game.Result.OtherPlayerWon)
|
||||
{
|
||||
title = GetText(strs.connect4_won(Format.Bold(arg.OtherPlayer.Username),
|
||||
Format.Bold(arg.CurrentPlayer.Username)));
|
||||
}
|
||||
else
|
||||
title = GetText(strs.connect4_draw);
|
||||
|
||||
return msg.ModifyAsync(x => x.Embed = _eb.Create()
|
||||
.WithTitle(title)
|
||||
.WithDescription(GetGameStateText(game))
|
||||
.WithOkColor()
|
||||
.Build());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Game_OnGameStateUpdated(Connect4Game game)
|
||||
{
|
||||
var embed = _eb.Create()
|
||||
.WithTitle($"{game.CurrentPlayer.Username} vs {game.OtherPlayer.Username}")
|
||||
.WithDescription(GetGameStateText(game))
|
||||
.WithOkColor();
|
||||
|
||||
|
||||
if (msg is null)
|
||||
msg = await ctx.Channel.EmbedAsync(embed);
|
||||
else
|
||||
await msg.ModifyAsync(x => x.Embed = embed.Build());
|
||||
}
|
||||
|
||||
private string GetGameStateText(Connect4Game game)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
if (game.CurrentPhase is Connect4Game.Phase.P1Move or Connect4Game.Phase.P2Move)
|
||||
sb.AppendLine(GetText(strs.connect4_player_to_move(Format.Bold(game.CurrentPlayer.Username))));
|
||||
|
||||
for (var i = Connect4Game.NUMBER_OF_ROWS; i > 0; i--)
|
||||
{
|
||||
for (var j = 0; j < Connect4Game.NUMBER_OF_COLUMNS; j++)
|
||||
{
|
||||
var cur = game.GameState[i + (j * Connect4Game.NUMBER_OF_ROWS) - 1];
|
||||
|
||||
if (cur == Connect4Game.Field.Empty)
|
||||
sb.Append("⚫"); //black circle
|
||||
else if (cur == Connect4Game.Field.P1)
|
||||
sb.Append("🔴"); //red circle
|
||||
else
|
||||
sb.Append("🔵"); //blue circle
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
for (var i = 0; i < Connect4Game.NUMBER_OF_COLUMNS; i++)
|
||||
sb.Append(_numbers[i]);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
224
src/NadekoBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
Normal file
224
src/NadekoBot/Modules/Gambling/DiceRoll/DiceRollCommands.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
#nullable disable
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Text.RegularExpressions;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class DiceRollCommands : NadekoModule
|
||||
{
|
||||
private static readonly Regex _dndRegex = new(@"^(?<n1>\d+)d(?<n2>\d+)(?:\+(?<add>\d+))?(?:\-(?<sub>\d+))?$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex _fudgeRegex = new(@"^(?<n1>\d+)d(?:F|f)$", RegexOptions.Compiled);
|
||||
|
||||
private static readonly char[] _fateRolls = { '-', ' ', '+' };
|
||||
private readonly IImageCache _images;
|
||||
|
||||
public DiceRollCommands(IImageCache images)
|
||||
=> _images = images;
|
||||
|
||||
[Cmd]
|
||||
public async Task Roll()
|
||||
{
|
||||
var rng = new NadekoRandom();
|
||||
var gen = rng.Next(1, 101);
|
||||
|
||||
var num1 = gen / 10;
|
||||
var num2 = gen % 10;
|
||||
|
||||
using var img1 = await GetDiceAsync(num1);
|
||||
using var img2 = await GetDiceAsync(num2);
|
||||
using var img = new[] { img1, img2 }.Merge(out var format);
|
||||
await using var ms = await img.ToStreamAsync(format);
|
||||
|
||||
var fileName = $"dice.{format.FileExtensions.First()}";
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.AddField(GetText(strs.roll2), gen)
|
||||
.WithImageUrl($"attachment://{fileName}");
|
||||
|
||||
await ctx.Channel.SendFileAsync(ms,
|
||||
fileName,
|
||||
embed: eb.Build());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[Priority(1)]
|
||||
public async Task Roll(int num)
|
||||
=> await InternalRoll(num, true);
|
||||
|
||||
|
||||
[Cmd]
|
||||
[Priority(1)]
|
||||
public async Task Rolluo(int num = 1)
|
||||
=> await InternalRoll(num, false);
|
||||
|
||||
[Cmd]
|
||||
[Priority(0)]
|
||||
public async Task Roll(string arg)
|
||||
=> await InternallDndRoll(arg, true);
|
||||
|
||||
[Cmd]
|
||||
[Priority(0)]
|
||||
public async Task Rolluo(string arg)
|
||||
=> await InternallDndRoll(arg, false);
|
||||
|
||||
private async Task InternalRoll(int num, bool ordered)
|
||||
{
|
||||
if (num is < 1 or > 30)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.dice_invalid_number(1, 30));
|
||||
return;
|
||||
}
|
||||
|
||||
var rng = new NadekoRandom();
|
||||
|
||||
var dice = new List<Image<Rgba32>>(num);
|
||||
var values = new List<int>(num);
|
||||
for (var i = 0; i < num; i++)
|
||||
{
|
||||
var randomNumber = rng.Next(1, 7);
|
||||
var toInsert = dice.Count;
|
||||
if (ordered)
|
||||
{
|
||||
if (randomNumber == 6 || dice.Count == 0)
|
||||
toInsert = 0;
|
||||
else if (randomNumber != 1)
|
||||
{
|
||||
for (var j = 0; j < dice.Count; j++)
|
||||
{
|
||||
if (values[j] < randomNumber)
|
||||
{
|
||||
toInsert = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
toInsert = dice.Count;
|
||||
|
||||
dice.Insert(toInsert, await GetDiceAsync(randomNumber));
|
||||
values.Insert(toInsert, randomNumber);
|
||||
}
|
||||
|
||||
using var bitmap = dice.Merge(out var format);
|
||||
await using var ms = bitmap.ToStream(format);
|
||||
foreach (var d in dice)
|
||||
d.Dispose();
|
||||
|
||||
var imageName = $"dice.{format.FileExtensions.First()}";
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.AddField(GetText(strs.rolls), values.Select(x => Format.Code(x.ToString())).Join(' '), true)
|
||||
.AddField(GetText(strs.total), values.Sum(), true)
|
||||
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(values.Count.ToString()))))
|
||||
.WithImageUrl($"attachment://{imageName}");
|
||||
|
||||
await ctx.Channel.SendFileAsync(ms,
|
||||
imageName,
|
||||
embed: eb.Build());
|
||||
}
|
||||
|
||||
private async Task InternallDndRoll(string arg, bool ordered)
|
||||
{
|
||||
Match match;
|
||||
if ((match = _fudgeRegex.Match(arg)).Length != 0
|
||||
&& int.TryParse(match.Groups["n1"].ToString(), out var n1)
|
||||
&& n1 is > 0 and < 500)
|
||||
{
|
||||
var rng = new NadekoRandom();
|
||||
|
||||
var rolls = new List<char>();
|
||||
|
||||
for (var i = 0; i < n1; i++)
|
||||
rolls.Add(_fateRolls[rng.Next(0, _fateRolls.Length)]);
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(GetText(strs.dice_rolled_num(Format.Bold(n1.ToString()))))
|
||||
.AddField(Format.Bold("Result"),
|
||||
string.Join(" ", rolls.Select(c => Format.Code($"[{c}]"))));
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
else if ((match = _dndRegex.Match(arg)).Length != 0)
|
||||
{
|
||||
var rng = new NadekoRandom();
|
||||
if (int.TryParse(match.Groups["n1"].ToString(), out n1)
|
||||
&& int.TryParse(match.Groups["n2"].ToString(), out var n2)
|
||||
&& n1 <= 50
|
||||
&& n2 <= 100000
|
||||
&& n1 > 0
|
||||
&& n2 > 0)
|
||||
{
|
||||
if (!int.TryParse(match.Groups["add"].Value, out var add))
|
||||
add = 0;
|
||||
if (!int.TryParse(match.Groups["sub"].Value, out var sub))
|
||||
sub = 0;
|
||||
|
||||
var arr = new int[n1];
|
||||
for (var i = 0; i < n1; i++)
|
||||
arr[i] = rng.Next(1, n2 + 1);
|
||||
|
||||
var sum = arr.Sum();
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(GetText(strs.dice_rolled_num(n1 + $"`1 - {n2}`")))
|
||||
.AddField(Format.Bold(GetText(strs.rolls)),
|
||||
string.Join(" ",
|
||||
(ordered ? arr.OrderBy(x => x).AsEnumerable() : arr).Select(x
|
||||
=> Format.Code(x.ToString()))))
|
||||
.AddField(Format.Bold("Sum"),
|
||||
sum + " + " + add + " - " + sub + " = " + (sum + add - sub));
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task NRoll([Leftover] string range)
|
||||
{
|
||||
int rolled;
|
||||
if (range.Contains("-"))
|
||||
{
|
||||
var arr = range.Split('-').Take(2).Select(int.Parse).ToArray();
|
||||
if (arr[0] > arr[1])
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.second_larger_than_first);
|
||||
return;
|
||||
}
|
||||
|
||||
rolled = new NadekoRandom().Next(arr[0], arr[1] + 1);
|
||||
}
|
||||
else
|
||||
rolled = new NadekoRandom().Next(0, int.Parse(range) + 1);
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.dice_rolled(Format.Bold(rolled.ToString())));
|
||||
}
|
||||
|
||||
private async Task<Image<Rgba32>> GetDiceAsync(int num)
|
||||
{
|
||||
if (num is < 0 or > 10)
|
||||
throw new ArgumentOutOfRangeException(nameof(num));
|
||||
|
||||
if (num == 10)
|
||||
{
|
||||
using var imgOne = Image.Load<Rgba32>(await _images.GetDiceAsync(1));
|
||||
using var imgZero = Image.Load<Rgba32>(await _images.GetDiceAsync(0));
|
||||
return new[] { imgOne, imgZero }.Merge();
|
||||
}
|
||||
|
||||
return Image.Load<Rgba32>(await _images.GetDiceAsync(num));
|
||||
}
|
||||
}
|
||||
}
|
||||
234
src/NadekoBot/Modules/Gambling/Draw/DrawCommands.cs
Normal file
234
src/NadekoBot/Modules/Gambling/Draw/DrawCommands.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
#nullable disable
|
||||
using Nadeko.Econ;
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class DrawCommands : GamblingSubmodule<IGamblingService>
|
||||
{
|
||||
private static readonly ConcurrentDictionary<IGuild, Deck> _allDecks = new();
|
||||
private readonly IImageCache _images;
|
||||
|
||||
public DrawCommands(IImageCache images, GamblingConfigService gcs) : base(gcs)
|
||||
=> _images = images;
|
||||
|
||||
private async Task InternalDraw(int count, ulong? guildId = null)
|
||||
{
|
||||
if (count is < 1 or > 10)
|
||||
throw new ArgumentOutOfRangeException(nameof(count));
|
||||
|
||||
var cards = guildId is null ? new() : _allDecks.GetOrAdd(ctx.Guild, _ => new());
|
||||
var images = new List<Image<Rgba32>>();
|
||||
var cardObjects = new List<Deck.Card>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (cards.CardPool.Count == 0 && i != 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.no_more_cards);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
var currentCard = cards.Draw();
|
||||
cardObjects.Add(currentCard);
|
||||
var image = await GetCardImageAsync(currentCard);
|
||||
images.Add(image);
|
||||
}
|
||||
|
||||
var imgName = "cards.jpg";
|
||||
using var img = images.Merge();
|
||||
foreach (var i in images)
|
||||
i.Dispose();
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor();
|
||||
|
||||
var toSend = string.Empty;
|
||||
if (cardObjects.Count == 5)
|
||||
eb.AddField(GetText(strs.hand_value), Deck.GetHandValue(cardObjects), true);
|
||||
|
||||
if (guildId is not null)
|
||||
toSend += GetText(strs.cards_left(Format.Bold(cards.CardPool.Count.ToString())));
|
||||
|
||||
eb.WithDescription(toSend)
|
||||
.WithAuthor(ctx.User)
|
||||
.WithImageUrl($"attachment://{imgName}");
|
||||
|
||||
if (count > 1)
|
||||
eb.AddField(GetText(strs.cards), count.ToString(), true);
|
||||
|
||||
await using var imageStream = await img.ToStreamAsync();
|
||||
await ctx.Channel.SendFileAsync(imageStream,
|
||||
imgName,
|
||||
embed: eb.Build());
|
||||
}
|
||||
|
||||
private async Task<Image<Rgba32>> GetCardImageAsync(RegularCard currentCard)
|
||||
{
|
||||
var cardName = currentCard.GetName().ToLowerInvariant().Replace(' ', '_');
|
||||
var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
|
||||
return Image.Load<Rgba32>(cardBytes);
|
||||
}
|
||||
|
||||
private async Task<Image<Rgba32>> GetCardImageAsync(Deck.Card currentCard)
|
||||
{
|
||||
var cardName = currentCard.ToString().ToLowerInvariant().Replace(' ', '_');
|
||||
var cardBytes = await File.ReadAllBytesAsync($"data/images/cards/{cardName}.jpg");
|
||||
return Image.Load<Rgba32>(cardBytes);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Draw(int num = 1)
|
||||
{
|
||||
if (num < 1)
|
||||
return;
|
||||
|
||||
if (num > 10)
|
||||
num = 10;
|
||||
|
||||
await InternalDraw(num, ctx.Guild.Id);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task DrawNew(int num = 1)
|
||||
{
|
||||
if (num < 1)
|
||||
return;
|
||||
|
||||
if (num > 10)
|
||||
num = 10;
|
||||
|
||||
await InternalDraw(num);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task DeckShuffle()
|
||||
{
|
||||
//var channel = (ITextChannel)ctx.Channel;
|
||||
|
||||
_allDecks.AddOrUpdate(ctx.Guild,
|
||||
_ => new(),
|
||||
(_, c) =>
|
||||
{
|
||||
c.Restart();
|
||||
return c;
|
||||
});
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.deck_reshuffled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputValueGuess val, InputColorGuess? col = null)
|
||||
=> BetDrawInternal(amount, val, col);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task BetDraw([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, InputColorGuess col, InputValueGuess? val = null)
|
||||
=> BetDrawInternal(amount, val, col);
|
||||
|
||||
public async Task BetDrawInternal(long amount, InputValueGuess? val, InputColorGuess? col)
|
||||
{
|
||||
if (amount <= 0)
|
||||
return;
|
||||
|
||||
var res = await _service.BetDrawAsync(ctx.User.Id,
|
||||
amount,
|
||||
(byte?)val,
|
||||
(byte?)col);
|
||||
|
||||
if (!res.TryPickT0(out var result, out _))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(result.Card.GetEmoji())
|
||||
.AddField(GetText(strs.guess), GetGuessInfo(val, col), true)
|
||||
.AddField(GetText(strs.card), GetCardInfo(result.Card), true)
|
||||
.AddField(GetText(strs.won), N((long)result.Won), false)
|
||||
.WithImageUrl("attachment://card.png");
|
||||
|
||||
using var img = await GetCardImageAsync(result.Card);
|
||||
await using var imgStream = await img.ToStreamAsync();
|
||||
await ctx.Channel.SendFileAsync(imgStream, "card.png", embed: eb.Build());
|
||||
}
|
||||
|
||||
private string GetGuessInfo(InputValueGuess? valG, InputColorGuess? colG)
|
||||
{
|
||||
var val = valG switch
|
||||
{
|
||||
InputValueGuess.H => "Hi ⬆️",
|
||||
InputValueGuess.L => "Lo ⬇️",
|
||||
_ => "❓"
|
||||
};
|
||||
|
||||
var col = colG switch
|
||||
{
|
||||
InputColorGuess.Red => "R 🔴",
|
||||
InputColorGuess.Black => "B ⚫",
|
||||
_ => "❓"
|
||||
};
|
||||
|
||||
return $"{val} / {col}";
|
||||
}
|
||||
private string GetCardInfo(RegularCard card)
|
||||
{
|
||||
var val = (int)card.Value switch
|
||||
{
|
||||
< 7 => "Lo ⬇️",
|
||||
> 7 => "Hi ⬆️",
|
||||
_ => "7 💀"
|
||||
};
|
||||
|
||||
var col = card.Value == RegularValue.Seven
|
||||
? "7 💀"
|
||||
: card.Suit switch
|
||||
{
|
||||
RegularSuit.Diamonds or RegularSuit.Hearts => "R 🔴",
|
||||
_ => "B ⚫"
|
||||
};
|
||||
|
||||
return $"{val} / {col}";
|
||||
}
|
||||
|
||||
public enum InputValueGuess
|
||||
{
|
||||
High = 0,
|
||||
H = 0,
|
||||
Hi = 0,
|
||||
Low = 1,
|
||||
L = 1,
|
||||
Lo = 1,
|
||||
}
|
||||
|
||||
public enum InputColorGuess
|
||||
{
|
||||
R = 0,
|
||||
Red = 0,
|
||||
B = 1,
|
||||
Bl = 1,
|
||||
Black = 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/NadekoBot/Modules/Gambling/EconomyResult.cs
Normal file
12
src/NadekoBot/Modules/Gambling/EconomyResult.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public sealed class EconomyResult
|
||||
{
|
||||
public decimal Cash { get; init; }
|
||||
public decimal Planted { get; init; }
|
||||
public decimal Waifus { get; init; }
|
||||
public decimal OnePercent { get; init; }
|
||||
public decimal Bank { get; init; }
|
||||
public long Bot { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Events;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class CurrencyEventsCommands : GamblingSubmodule<CurrencyEventsService>
|
||||
{
|
||||
public CurrencyEventsCommands(GamblingConfigService gamblingConf)
|
||||
: base(gamblingConf)
|
||||
{
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[NadekoOptions<EventOptions>]
|
||||
[OwnerOnly]
|
||||
public async Task EventStart(CurrencyEvent.Type ev, params string[] options)
|
||||
{
|
||||
var (opts, _) = OptionsParser.ParseFrom(new EventOptions(), options);
|
||||
if (!await _service.TryCreateEventAsync(ctx.Guild.Id, ctx.Channel.Id, ev, opts, GetEmbed))
|
||||
await ReplyErrorLocalizedAsync(strs.start_event_fail);
|
||||
}
|
||||
|
||||
private IEmbedBuilder GetEmbed(CurrencyEvent.Type type, EventOptions opts, long currentPot)
|
||||
=> type switch
|
||||
{
|
||||
CurrencyEvent.Type.Reaction => _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.event_title(type.ToString())))
|
||||
.WithDescription(GetReactionDescription(opts.Amount, currentPot))
|
||||
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
|
||||
CurrencyEvent.Type.GameStatus => _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.event_title(type.ToString())))
|
||||
.WithDescription(GetGameStatusDescription(opts.Amount, currentPot))
|
||||
.WithFooter(GetText(strs.event_duration_footer(opts.Hours))),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(type))
|
||||
};
|
||||
|
||||
private string GetReactionDescription(long amount, long potSize)
|
||||
{
|
||||
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : N(potSize));
|
||||
|
||||
return GetText(strs.new_reaction_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
|
||||
}
|
||||
|
||||
private string GetGameStatusDescription(long amount, long potSize)
|
||||
{
|
||||
var potSizeStr = Format.Bold(potSize == 0 ? "∞" + CurrencySign : potSize + CurrencySign);
|
||||
|
||||
return GetText(strs.new_gamestatus_event(CurrencySign, Format.Bold(N(amount)), potSizeStr));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Events;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class CurrencyEventsService : INService
|
||||
{
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly GamblingConfigService _configService;
|
||||
|
||||
private readonly ConcurrentDictionary<ulong, ICurrencyEvent> _events = new();
|
||||
|
||||
public CurrencyEventsService(DiscordSocketClient client, ICurrencyService cs, GamblingConfigService configService)
|
||||
{
|
||||
_client = client;
|
||||
_cs = cs;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
public async Task<bool> TryCreateEventAsync(
|
||||
ulong guildId,
|
||||
ulong channelId,
|
||||
CurrencyEvent.Type type,
|
||||
EventOptions opts,
|
||||
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embed)
|
||||
{
|
||||
var g = _client.GetGuild(guildId);
|
||||
if (g?.GetChannel(channelId) is not ITextChannel ch)
|
||||
return false;
|
||||
|
||||
ICurrencyEvent ce;
|
||||
|
||||
if (type == CurrencyEvent.Type.Reaction)
|
||||
ce = new ReactionEvent(_client, _cs, g, ch, opts, _configService.Data, embed);
|
||||
else if (type == CurrencyEvent.Type.GameStatus)
|
||||
ce = new GameStatusEvent(_client, _cs, g, ch, opts, embed);
|
||||
else
|
||||
return false;
|
||||
|
||||
var added = _events.TryAdd(guildId, ce);
|
||||
if (added)
|
||||
{
|
||||
try
|
||||
{
|
||||
ce.OnEnded += OnEventEnded;
|
||||
await ce.StartEvent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error starting event");
|
||||
_events.TryRemove(guildId, out ce);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
private Task OnEventEnded(ulong gid)
|
||||
{
|
||||
_events.TryRemove(gid, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
39
src/NadekoBot/Modules/Gambling/Events/EventOptions.cs
Normal file
39
src/NadekoBot/Modules/Gambling/Events/EventOptions.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
#nullable disable
|
||||
using CommandLine;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Events;
|
||||
|
||||
public class EventOptions : INadekoCommandOptions
|
||||
{
|
||||
[Option('a', "amount", Required = false, Default = 100, HelpText = "Amount of currency each user receives.")]
|
||||
public long Amount { get; set; } = 100;
|
||||
|
||||
[Option('p',
|
||||
"pot-size",
|
||||
Required = false,
|
||||
Default = 0,
|
||||
HelpText = "The maximum amount of currency that can be rewarded. 0 means no limit.")]
|
||||
public long PotSize { get; set; }
|
||||
|
||||
//[Option('t', "type", Required = false, Default = "reaction", HelpText = "Type of the event. reaction, gamestatus or joinserver.")]
|
||||
//public string TypeString { get; set; } = "reaction";
|
||||
[Option('d',
|
||||
"duration",
|
||||
Required = false,
|
||||
Default = 24,
|
||||
HelpText = "Number of hours the event should run for. Default 24.")]
|
||||
public int Hours { get; set; } = 24;
|
||||
|
||||
|
||||
public void NormalizeOptions()
|
||||
{
|
||||
if (Amount < 0)
|
||||
Amount = 100;
|
||||
if (PotSize < 0)
|
||||
PotSize = 0;
|
||||
if (Hours <= 0)
|
||||
Hours = 24;
|
||||
if (PotSize != 0 && PotSize < Amount)
|
||||
PotSize = 0;
|
||||
}
|
||||
}
|
||||
195
src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs
Normal file
195
src/NadekoBot/Modules/Gambling/Events/GameStatusEvent.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Events;
|
||||
|
||||
public class GameStatusEvent : ICurrencyEvent
|
||||
{
|
||||
public event Func<ulong, Task> OnEnded;
|
||||
private long PotSize { get; set; }
|
||||
public bool Stopped { get; private set; }
|
||||
public bool PotEmptied { get; private set; }
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IGuild _guild;
|
||||
private IUserMessage msg;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly long _amount;
|
||||
|
||||
private readonly Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> _embedFunc;
|
||||
private readonly bool _isPotLimited;
|
||||
private readonly ITextChannel _channel;
|
||||
private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
|
||||
private readonly ConcurrentQueue<ulong> _toAward = new();
|
||||
private readonly Timer _t;
|
||||
private readonly Timer _timeout;
|
||||
private readonly EventOptions _opts;
|
||||
|
||||
private readonly string _code;
|
||||
|
||||
private readonly char[] _sneakyGameStatusChars = Enumerable.Range(48, 10)
|
||||
.Concat(Enumerable.Range(65, 26))
|
||||
.Concat(Enumerable.Range(97, 26))
|
||||
.Select(x => (char)x)
|
||||
.ToArray();
|
||||
|
||||
private readonly object _stopLock = new();
|
||||
|
||||
private readonly object _potLock = new();
|
||||
|
||||
public GameStatusEvent(
|
||||
DiscordSocketClient client,
|
||||
ICurrencyService cs,
|
||||
SocketGuild g,
|
||||
ITextChannel ch,
|
||||
EventOptions opt,
|
||||
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embedFunc)
|
||||
{
|
||||
_client = client;
|
||||
_guild = g;
|
||||
_cs = cs;
|
||||
_amount = opt.Amount;
|
||||
PotSize = opt.PotSize;
|
||||
_embedFunc = embedFunc;
|
||||
_isPotLimited = PotSize > 0;
|
||||
_channel = ch;
|
||||
_opts = opt;
|
||||
// generate code
|
||||
_code = new(_sneakyGameStatusChars.Shuffle().Take(5).ToArray());
|
||||
|
||||
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
|
||||
if (_opts.Hours > 0)
|
||||
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
private void EventTimeout(object state)
|
||||
=> _ = StopEvent();
|
||||
|
||||
private async void OnTimerTick(object state)
|
||||
{
|
||||
var potEmpty = PotEmptied;
|
||||
var toAward = new List<ulong>();
|
||||
while (_toAward.TryDequeue(out var x))
|
||||
toAward.Add(x);
|
||||
|
||||
if (!toAward.Any())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _cs.AddBulkAsync(toAward,
|
||||
_amount,
|
||||
new("event", "gamestatus")
|
||||
);
|
||||
|
||||
if (_isPotLimited)
|
||||
{
|
||||
await msg.ModifyAsync(m =>
|
||||
{
|
||||
m.Embed = GetEmbed(PotSize).Build();
|
||||
});
|
||||
}
|
||||
|
||||
Log.Information("Game status event awarded {Count} users {Amount} currency.{Remaining}",
|
||||
toAward.Count,
|
||||
_amount,
|
||||
_isPotLimited ? $" {PotSize} left." : "");
|
||||
|
||||
if (potEmpty)
|
||||
_ = StopEvent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in OnTimerTick in gamestatusevent");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartEvent()
|
||||
{
|
||||
msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize));
|
||||
await _client.SetGameAsync(_code);
|
||||
_client.MessageDeleted += OnMessageDeleted;
|
||||
_client.MessageReceived += HandleMessage;
|
||||
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private IEmbedBuilder GetEmbed(long pot)
|
||||
=> _embedFunc(CurrencyEvent.Type.GameStatus, _opts, pot);
|
||||
|
||||
private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> cacheable)
|
||||
{
|
||||
if (message.Id == msg.Id)
|
||||
await StopEvent();
|
||||
}
|
||||
|
||||
public Task StopEvent()
|
||||
{
|
||||
lock (_stopLock)
|
||||
{
|
||||
if (Stopped)
|
||||
return Task.CompletedTask;
|
||||
Stopped = true;
|
||||
_client.MessageDeleted -= OnMessageDeleted;
|
||||
_client.MessageReceived -= HandleMessage;
|
||||
_t.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_timeout?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_ = _client.SetGameAsync(null);
|
||||
try
|
||||
{
|
||||
_ = msg.DeleteAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
_ = OnEnded?.Invoke(_guild.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleMessage(SocketMessage message)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
if (message.Author is not IGuildUser gu // no unknown users, as they could be bots, or alts
|
||||
|| gu.IsBot // no bots
|
||||
|| message.Content != _code // code has to be the same
|
||||
|| (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5) // no recently created accounts
|
||||
return;
|
||||
// there has to be money left in the pot
|
||||
// and the user wasn't rewarded
|
||||
if (_awardedUsers.Add(message.Author.Id) && TryTakeFromPot())
|
||||
{
|
||||
_toAward.Enqueue(message.Author.Id);
|
||||
if (_isPotLimited && PotSize < _amount)
|
||||
PotEmptied = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await message.DeleteAsync(new()
|
||||
{
|
||||
RetryMode = RetryMode.AlwaysFail
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryTakeFromPot()
|
||||
{
|
||||
if (_isPotLimited)
|
||||
{
|
||||
lock (_potLock)
|
||||
{
|
||||
if (PotSize < _amount)
|
||||
return false;
|
||||
|
||||
PotSize -= _amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
9
src/NadekoBot/Modules/Gambling/Events/ICurrencyEvent.cs
Normal file
9
src/NadekoBot/Modules/Gambling/Events/ICurrencyEvent.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
public interface ICurrencyEvent
|
||||
{
|
||||
event Func<ulong, Task> OnEnded;
|
||||
Task StopEvent();
|
||||
Task StartEvent();
|
||||
}
|
||||
194
src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs
Normal file
194
src/NadekoBot/Modules/Gambling/Events/ReactionEvent.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common.Events;
|
||||
|
||||
public class ReactionEvent : ICurrencyEvent
|
||||
{
|
||||
public event Func<ulong, Task> OnEnded;
|
||||
private long PotSize { get; set; }
|
||||
public bool Stopped { get; private set; }
|
||||
public bool PotEmptied { get; private set; }
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IGuild _guild;
|
||||
private IUserMessage msg;
|
||||
private IEmote emote;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly long _amount;
|
||||
|
||||
private readonly Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> _embedFunc;
|
||||
private readonly bool _isPotLimited;
|
||||
private readonly ITextChannel _channel;
|
||||
private readonly ConcurrentHashSet<ulong> _awardedUsers = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentQueue<ulong> _toAward = new();
|
||||
private readonly Timer _t;
|
||||
private readonly Timer _timeout;
|
||||
private readonly bool _noRecentlyJoinedServer;
|
||||
private readonly EventOptions _opts;
|
||||
private readonly GamblingConfig _config;
|
||||
|
||||
private readonly object _stopLock = new();
|
||||
|
||||
private readonly object _potLock = new();
|
||||
|
||||
public ReactionEvent(
|
||||
DiscordSocketClient client,
|
||||
ICurrencyService cs,
|
||||
SocketGuild g,
|
||||
ITextChannel ch,
|
||||
EventOptions opt,
|
||||
GamblingConfig config,
|
||||
Func<CurrencyEvent.Type, EventOptions, long, IEmbedBuilder> embedFunc)
|
||||
{
|
||||
_client = client;
|
||||
_guild = g;
|
||||
_cs = cs;
|
||||
_amount = opt.Amount;
|
||||
PotSize = opt.PotSize;
|
||||
_embedFunc = embedFunc;
|
||||
_isPotLimited = PotSize > 0;
|
||||
_channel = ch;
|
||||
_noRecentlyJoinedServer = false;
|
||||
_opts = opt;
|
||||
_config = config;
|
||||
|
||||
_t = new(OnTimerTick, null, Timeout.InfiniteTimeSpan, TimeSpan.FromSeconds(2));
|
||||
if (_opts.Hours > 0)
|
||||
_timeout = new(EventTimeout, null, TimeSpan.FromHours(_opts.Hours), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
private void EventTimeout(object state)
|
||||
=> _ = StopEvent();
|
||||
|
||||
private async void OnTimerTick(object state)
|
||||
{
|
||||
var potEmpty = PotEmptied;
|
||||
var toAward = new List<ulong>();
|
||||
while (_toAward.TryDequeue(out var x))
|
||||
toAward.Add(x);
|
||||
|
||||
if (!toAward.Any())
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _cs.AddBulkAsync(toAward, _amount, new("event", "reaction"));
|
||||
|
||||
if (_isPotLimited)
|
||||
{
|
||||
await msg.ModifyAsync(m =>
|
||||
{
|
||||
m.Embed = GetEmbed(PotSize).Build();
|
||||
});
|
||||
}
|
||||
|
||||
Log.Information("Reaction Event awarded {Count} users {Amount} currency.{Remaining}",
|
||||
toAward.Count,
|
||||
_amount,
|
||||
_isPotLimited ? $" {PotSize} left." : "");
|
||||
|
||||
if (potEmpty)
|
||||
_ = StopEvent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error adding bulk currency to users");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StartEvent()
|
||||
{
|
||||
if (Emote.TryParse(_config.Currency.Sign, out var parsedEmote))
|
||||
emote = parsedEmote;
|
||||
else
|
||||
emote = new Emoji(_config.Currency.Sign);
|
||||
msg = await _channel.EmbedAsync(GetEmbed(_opts.PotSize));
|
||||
await msg.AddReactionAsync(emote);
|
||||
_client.MessageDeleted += OnMessageDeleted;
|
||||
_client.ReactionAdded += HandleReaction;
|
||||
_t.Change(TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
private IEmbedBuilder GetEmbed(long pot)
|
||||
=> _embedFunc(CurrencyEvent.Type.Reaction, _opts, pot);
|
||||
|
||||
private async Task OnMessageDeleted(Cacheable<IMessage, ulong> message, Cacheable<IMessageChannel, ulong> cacheable)
|
||||
{
|
||||
if (message.Id == msg.Id)
|
||||
await StopEvent();
|
||||
}
|
||||
|
||||
public Task StopEvent()
|
||||
{
|
||||
lock (_stopLock)
|
||||
{
|
||||
if (Stopped)
|
||||
return Task.CompletedTask;
|
||||
|
||||
Stopped = true;
|
||||
_client.MessageDeleted -= OnMessageDeleted;
|
||||
_client.ReactionAdded -= HandleReaction;
|
||||
_t.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
_timeout?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
try
|
||||
{
|
||||
_ = msg.DeleteAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
_ = OnEnded?.Invoke(_guild.Id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task HandleReaction(
|
||||
Cacheable<IUserMessage, ulong> message,
|
||||
Cacheable<IMessageChannel, ulong> cacheable,
|
||||
SocketReaction r)
|
||||
{
|
||||
_ = Task.Run(() =>
|
||||
{
|
||||
if (emote.Name != r.Emote.Name)
|
||||
return;
|
||||
if ((r.User.IsSpecified
|
||||
? r.User.Value
|
||||
: null) is not IGuildUser gu // no unknown users, as they could be bots, or alts
|
||||
|| message.Id != msg.Id // same message
|
||||
|| gu.IsBot // no bots
|
||||
|| (DateTime.UtcNow - gu.CreatedAt).TotalDays <= 5 // no recently created accounts
|
||||
|| (_noRecentlyJoinedServer
|
||||
&& // if specified, no users who joined the server in the last 24h
|
||||
(gu.JoinedAt is null
|
||||
|| (DateTime.UtcNow - gu.JoinedAt.Value).TotalDays
|
||||
< 1))) // and no users for who we don't know when they joined
|
||||
return;
|
||||
// there has to be money left in the pot
|
||||
// and the user wasn't rewarded
|
||||
if (_awardedUsers.Add(r.UserId) && TryTakeFromPot())
|
||||
{
|
||||
_toAward.Enqueue(r.UserId);
|
||||
if (_isPotLimited && PotSize < _amount)
|
||||
PotEmptied = true;
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool TryTakeFromPot()
|
||||
{
|
||||
if (_isPotLimited)
|
||||
{
|
||||
lock (_potLock)
|
||||
{
|
||||
if (PotSize < _amount)
|
||||
return false;
|
||||
|
||||
PotSize -= _amount;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
140
src/NadekoBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
Normal file
140
src/NadekoBot/Modules/Gambling/FlipCoin/FlipCoinCommands.cs
Normal file
@@ -0,0 +1,140 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class FlipCoinCommands : GamblingSubmodule<IGamblingService>
|
||||
{
|
||||
public enum BetFlipGuess : byte
|
||||
{
|
||||
H = 0,
|
||||
Head = 0,
|
||||
Heads = 0,
|
||||
T = 1,
|
||||
Tail = 1,
|
||||
Tails = 1
|
||||
}
|
||||
|
||||
private static readonly NadekoRandom _rng = new();
|
||||
private readonly IImageCache _images;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly ImagesConfig _ic;
|
||||
|
||||
public FlipCoinCommands(
|
||||
IImageCache images,
|
||||
ImagesConfig ic,
|
||||
ICurrencyService cs,
|
||||
GamblingConfigService gss)
|
||||
: base(gss)
|
||||
{
|
||||
_ic = ic;
|
||||
_images = images;
|
||||
_cs = cs;
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Flip(int count = 1)
|
||||
{
|
||||
if (count is > 10 or < 1)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.flip_invalid(10));
|
||||
return;
|
||||
}
|
||||
|
||||
var headCount = 0;
|
||||
var tailCount = 0;
|
||||
var imgs = new Image<Rgba32>[count];
|
||||
var headsArr = await _images.GetHeadsImageAsync();
|
||||
var tailsArr = await _images.GetTailsImageAsync();
|
||||
|
||||
var result = await _service.FlipAsync(count);
|
||||
|
||||
for (var i = 0; i < result.Length; i++)
|
||||
{
|
||||
if (result[i].Side == 0)
|
||||
{
|
||||
imgs[i] = Image.Load<Rgba32>(headsArr);
|
||||
headCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
imgs[i] = Image.Load<Rgba32>(tailsArr);
|
||||
tailCount++;
|
||||
}
|
||||
}
|
||||
|
||||
using var img = imgs.Merge(out var format);
|
||||
await using var stream = await img.ToStreamAsync(format);
|
||||
foreach (var i in imgs)
|
||||
i.Dispose();
|
||||
|
||||
var imgName = $"coins.{format.FileExtensions.First()}";
|
||||
|
||||
var msg = count != 1
|
||||
? Format.Bold(GetText(strs.flip_results(count, headCount, tailCount)))
|
||||
: GetText(strs.flipped(headCount > 0
|
||||
? Format.Bold(GetText(strs.heads))
|
||||
: Format.Bold(GetText(strs.tails))));
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithOkColor()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(msg)
|
||||
.WithImageUrl($"attachment://{imgName}");
|
||||
|
||||
await ctx.Channel.SendFileAsync(stream,
|
||||
imgName,
|
||||
embed: eb.Build());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task Betflip([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, BetFlipGuess guess)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount) || amount == 1)
|
||||
return;
|
||||
|
||||
var res = await _service.BetFlipAsync(ctx.User.Id, amount, (byte)guess);
|
||||
if (!res.TryPickT0(out var result, out _))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
Uri imageToSend;
|
||||
var coins = _ic.Data.Coins;
|
||||
if (result.Side == 0)
|
||||
{
|
||||
imageToSend = coins.Heads[_rng.Next(0, coins.Heads.Length)];
|
||||
}
|
||||
else
|
||||
{
|
||||
imageToSend = coins.Tails[_rng.Next(0, coins.Tails.Length)];
|
||||
}
|
||||
|
||||
string str;
|
||||
var won = (long)result.Won;
|
||||
if (won > 0)
|
||||
{
|
||||
str = Format.Bold(GetText(strs.flip_guess(N(won))));
|
||||
}
|
||||
else
|
||||
{
|
||||
str = Format.Bold(GetText(strs.better_luck));
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(_eb.Create()
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(str)
|
||||
.WithOkColor()
|
||||
.WithImageUrl(imageToSend.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/NadekoBot/Modules/Gambling/FlipCoin/FlipResult.cs
Normal file
7
src/NadekoBot/Modules/Gambling/FlipCoin/FlipResult.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Nadeko.Econ.Gambling;
|
||||
|
||||
public readonly struct FlipResult
|
||||
{
|
||||
public long Won { get; init; }
|
||||
public int Side { get; init; }
|
||||
}
|
||||
1014
src/NadekoBot/Modules/Gambling/Gambling.cs
Normal file
1014
src/NadekoBot/Modules/Gambling/Gambling.cs
Normal file
File diff suppressed because it is too large
Load Diff
387
src/NadekoBot/Modules/Gambling/GamblingConfig.cs
Normal file
387
src/NadekoBot/Modules/Gambling/GamblingConfig.cs
Normal file
@@ -0,0 +1,387 @@
|
||||
#nullable disable
|
||||
using Cloneable;
|
||||
using NadekoBot.Common.Yml;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using YamlDotNet.Serialization;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class GamblingConfig : ICloneable<GamblingConfig>
|
||||
{
|
||||
[Comment("""DO NOT CHANGE""")]
|
||||
public int Version { get; set; } = 2;
|
||||
|
||||
[Comment("""Currency settings""")]
|
||||
public CurrencyConfig Currency { get; set; }
|
||||
|
||||
[Comment("""Minimum amount users can bet (>=0)""")]
|
||||
public int MinBet { get; set; } = 0;
|
||||
|
||||
[Comment("""
|
||||
Maximum amount users can bet
|
||||
Set 0 for unlimited
|
||||
""")]
|
||||
public int MaxBet { get; set; } = 0;
|
||||
|
||||
[Comment("""Settings for betflip command""")]
|
||||
public BetFlipConfig BetFlip { get; set; }
|
||||
|
||||
[Comment("""Settings for betroll command""")]
|
||||
public BetRollConfig BetRoll { get; set; }
|
||||
|
||||
[Comment("""Automatic currency generation settings.""")]
|
||||
public GenerationConfig Generation { get; set; }
|
||||
|
||||
[Comment("""
|
||||
Settings for timely command
|
||||
(letting people claim X amount of currency every Y hours)
|
||||
""")]
|
||||
public TimelyConfig Timely { get; set; }
|
||||
|
||||
[Comment("""How much will each user's owned currency decay over time.""")]
|
||||
public DecayConfig Decay { get; set; }
|
||||
|
||||
[Comment("""Settings for LuckyLadder command""")]
|
||||
public LuckyLadderSettings LuckyLadder { get; set; }
|
||||
|
||||
[Comment("""Settings related to waifus""")]
|
||||
public WaifuConfig Waifu { get; set; }
|
||||
|
||||
[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;
|
||||
|
||||
[Comment("""Slot config""")]
|
||||
public SlotsConfig Slots { get; set; }
|
||||
|
||||
public GamblingConfig()
|
||||
{
|
||||
BetRoll = new();
|
||||
Waifu = new();
|
||||
Currency = new();
|
||||
BetFlip = new();
|
||||
Generation = new();
|
||||
Timely = new();
|
||||
Decay = new();
|
||||
Slots = new();
|
||||
LuckyLadder = new();
|
||||
}
|
||||
}
|
||||
|
||||
public class CurrencyConfig
|
||||
{
|
||||
[Comment("""What is the emoji/character which represents the currency""")]
|
||||
public string Sign { get; set; } = "🌸";
|
||||
|
||||
[Comment("""What is the name of the currency""")]
|
||||
public string Name { get; set; } = "Nadeko Flower";
|
||||
|
||||
[Comment("""
|
||||
For how long (in days) will the transactions be kept in the database (curtrs)
|
||||
Set 0 to disable cleanup (keep transactions forever)
|
||||
""")]
|
||||
public int TransactionsLifetime { get; set; } = 0;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class TimelyConfig
|
||||
{
|
||||
[Comment("""
|
||||
How much currency will the users get every time they run .timely command
|
||||
setting to 0 or less will disable this feature
|
||||
""")]
|
||||
public int Amount { get; set; } = 0;
|
||||
|
||||
[Comment("""
|
||||
How often (in hours) can users claim currency with .timely command
|
||||
setting to 0 or less will disable this feature
|
||||
""")]
|
||||
public int Cooldown { get; set; } = 24;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class BetFlipConfig
|
||||
{
|
||||
[Comment("""Bet multiplier if user guesses correctly""")]
|
||||
public decimal Multiplier { get; set; } = 1.95M;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class BetRollConfig
|
||||
{
|
||||
[Comment("""
|
||||
When betroll is played, user will roll a number 0-100.
|
||||
This setting will describe which multiplier is used for when the roll is higher than the given number.
|
||||
Doesn't have to be ordered.
|
||||
""")]
|
||||
public BetRollPair[] Pairs { get; set; } = Array.Empty<BetRollPair>();
|
||||
|
||||
public BetRollConfig()
|
||||
=> Pairs = new BetRollPair[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
WhenAbove = 99,
|
||||
MultiplyBy = 10
|
||||
},
|
||||
new()
|
||||
{
|
||||
WhenAbove = 90,
|
||||
MultiplyBy = 4
|
||||
},
|
||||
new()
|
||||
{
|
||||
WhenAbove = 66,
|
||||
MultiplyBy = 2
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class GenerationConfig
|
||||
{
|
||||
[Comment("""
|
||||
when currency is generated, should it also have a random password
|
||||
associated with it which users have to type after the .pick command
|
||||
in order to get it
|
||||
""")]
|
||||
public bool HasPassword { get; set; } = true;
|
||||
|
||||
[Comment("""
|
||||
Every message sent has a certain % chance to generate the currency
|
||||
specify the percentage here (1 being 100%, 0 being 0% - for example
|
||||
default is 0.02, which is 2%
|
||||
""")]
|
||||
public decimal Chance { get; set; } = 0.02M;
|
||||
|
||||
[Comment("""How many seconds have to pass for the next message to have a chance to spawn currency""")]
|
||||
public int GenCooldown { get; set; } = 10;
|
||||
|
||||
[Comment("""Minimum amount of currency that can spawn""")]
|
||||
public int MinAmount { get; set; } = 1;
|
||||
|
||||
[Comment("""
|
||||
Maximum amount of currency that can spawn.
|
||||
Set to the same value as MinAmount to always spawn the same amount
|
||||
""")]
|
||||
public int MaxAmount { get; set; } = 1;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class DecayConfig
|
||||
{
|
||||
[Comment("""
|
||||
Percentage of user's current currency which will be deducted every 24h.
|
||||
0 - 1 (1 is 100%, 0.5 50%, 0 disabled)
|
||||
""")]
|
||||
public decimal Percent { get; set; } = 0;
|
||||
|
||||
[Comment("""Maximum amount of user's currency that can decay at each interval. 0 for unlimited.""")]
|
||||
public int MaxDecay { get; set; } = 0;
|
||||
|
||||
[Comment("""Only users who have more than this amount will have their currency decay.""")]
|
||||
public int MinThreshold { get; set; } = 99;
|
||||
|
||||
[Comment("""How often, in hours, does the decay run. Default is 24 hours""")]
|
||||
public int HourInterval { get; set; } = 24;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public partial class LuckyLadderSettings
|
||||
{
|
||||
[Comment("""Self-Explanatory. Has to have 8 values, otherwise the command won't work.""")]
|
||||
public decimal[] Multipliers { get; set; }
|
||||
|
||||
public LuckyLadderSettings()
|
||||
=> Multipliers = new[] { 2.4M, 1.7M, 1.5M, 1.2M, 0.5M, 0.3M, 0.2M, 0.1M };
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class WaifuConfig
|
||||
{
|
||||
[Comment("""Minimum price a waifu can have""")]
|
||||
public long MinPrice { get; set; } = 50;
|
||||
|
||||
public MultipliersData Multipliers { get; set; } = new();
|
||||
|
||||
[Comment("""
|
||||
Settings for periodic waifu price decay.
|
||||
Waifu price decays only if the waifu has no claimer.
|
||||
""")]
|
||||
public WaifuDecayConfig Decay { get; set; } = new();
|
||||
|
||||
[Comment("""
|
||||
List of items available for gifting.
|
||||
If negative is true, gift will instead reduce waifu value.
|
||||
""")]
|
||||
public List<WaifuItemModel> Items { get; set; } = new();
|
||||
|
||||
public WaifuConfig()
|
||||
=> Items = new()
|
||||
{
|
||||
new("🥔", 5, "Potato"),
|
||||
new("🍪", 10, "Cookie"),
|
||||
new("🥖", 20, "Bread"),
|
||||
new("🍭", 30, "Lollipop"),
|
||||
new("🌹", 50, "Rose"),
|
||||
new("🍺", 70, "Beer"),
|
||||
new("🌮", 85, "Taco"),
|
||||
new("💌", 100, "LoveLetter"),
|
||||
new("🥛", 125, "Milk"),
|
||||
new("🍕", 150, "Pizza"),
|
||||
new("🍫", 200, "Chocolate"),
|
||||
new("🍦", 250, "Icecream"),
|
||||
new("🍣", 300, "Sushi"),
|
||||
new("🍚", 400, "Rice"),
|
||||
new("🍉", 500, "Watermelon"),
|
||||
new("🍱", 600, "Bento"),
|
||||
new("🎟", 800, "MovieTicket"),
|
||||
new("🍰", 1000, "Cake"),
|
||||
new("📔", 1500, "Book"),
|
||||
new("🐱", 2000, "Cat"),
|
||||
new("🐶", 2001, "Dog"),
|
||||
new("🐼", 2500, "Panda"),
|
||||
new("💄", 3000, "Lipstick"),
|
||||
new("👛", 3500, "Purse"),
|
||||
new("📱", 4000, "iPhone"),
|
||||
new("👗", 4500, "Dress"),
|
||||
new("💻", 5000, "Laptop"),
|
||||
new("🎻", 7500, "Violin"),
|
||||
new("🎹", 8000, "Piano"),
|
||||
new("🚗", 9000, "Car"),
|
||||
new("💍", 10000, "Ring"),
|
||||
new("🛳", 12000, "Ship"),
|
||||
new("🏠", 15000, "House"),
|
||||
new("🚁", 20000, "Helicopter"),
|
||||
new("🚀", 30000, "Spaceship"),
|
||||
new("🌕", 50000, "Moon")
|
||||
};
|
||||
|
||||
public class WaifuDecayConfig
|
||||
{
|
||||
[Comment("""
|
||||
Percentage (0 - 100) of the waifu value to reduce.
|
||||
Set 0 to disable
|
||||
For example if a waifu has a price of 500$, setting this value to 10 would reduce the waifu value by 10% (50$)
|
||||
""")]
|
||||
public int Percent { get; set; } = 0;
|
||||
|
||||
[Comment("""How often to decay waifu values, in hours""")]
|
||||
public int HourInterval { get; set; } = 24;
|
||||
|
||||
[Comment("""
|
||||
Minimum waifu price required for the decay to be applied.
|
||||
For example if this value is set to 300, any waifu with the price 300 or less will not experience decay.
|
||||
""")]
|
||||
public long MinPrice { get; set; } = 300;
|
||||
}
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class MultipliersData
|
||||
{
|
||||
[Comment("""
|
||||
Multiplier for waifureset. Default 150.
|
||||
Formula (at the time of writing this):
|
||||
price = (waifu_price * 1.25f) + ((number_of_divorces + changes_of_heart + 2) * WaifuReset) rounded up
|
||||
""")]
|
||||
public int WaifuReset { get; set; } = 150;
|
||||
|
||||
[Comment("""
|
||||
The minimum amount of currency that you have to pay
|
||||
in order to buy a waifu who doesn't have a crush on you.
|
||||
Default is 1.1
|
||||
Example: If a waifu is worth 100, you will have to pay at least 100 * NormalClaim currency to claim her.
|
||||
(100 * 1.1 = 110)
|
||||
""")]
|
||||
public decimal NormalClaim { get; set; } = 1.1m;
|
||||
|
||||
[Comment("""
|
||||
The minimum amount of currency that you have to pay
|
||||
in order to buy a waifu that has a crush on you.
|
||||
Default is 0.88
|
||||
Example: If a waifu is worth 100, you will have to pay at least 100 * CrushClaim currency to claim her.
|
||||
(100 * 0.88 = 88)
|
||||
""")]
|
||||
public decimal CrushClaim { get; set; } = 0.88M;
|
||||
|
||||
[Comment("""
|
||||
When divorcing a waifu, her new value will be her current value multiplied by this number.
|
||||
Default 0.75 (meaning will lose 25% of her value)
|
||||
""")]
|
||||
public decimal DivorceNewValue { get; set; } = 0.75M;
|
||||
|
||||
[Comment("""
|
||||
All gift prices will be multiplied by this number.
|
||||
Default 1 (meaning no effect)
|
||||
""")]
|
||||
public decimal AllGiftPrices { get; set; } = 1.0M;
|
||||
|
||||
[Comment("""
|
||||
What percentage of the value of the gift will a waifu gain when she's gifted.
|
||||
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;
|
||||
}
|
||||
|
||||
public sealed class SlotsConfig
|
||||
{
|
||||
[Comment("""Hex value of the color which the numbers on the slot image will have.""")]
|
||||
public Rgba32 CurrencyFontColor { get; set; } = Color.Red;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class WaifuItemModel
|
||||
{
|
||||
public string ItemEmoji { get; set; }
|
||||
public long Price { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
[YamlMember(DefaultValuesHandling = DefaultValuesHandling.OmitDefaults)]
|
||||
public bool Negative { get; set; }
|
||||
|
||||
public WaifuItemModel()
|
||||
{
|
||||
}
|
||||
|
||||
public WaifuItemModel(
|
||||
string itemEmoji,
|
||||
long price,
|
||||
string name,
|
||||
bool negative = false)
|
||||
{
|
||||
ItemEmoji = itemEmoji;
|
||||
Price = price;
|
||||
Name = name;
|
||||
Negative = negative;
|
||||
}
|
||||
|
||||
|
||||
public override string ToString()
|
||||
=> Name;
|
||||
}
|
||||
|
||||
[Cloneable]
|
||||
public sealed partial class BetRollPair
|
||||
{
|
||||
public int WhenAbove { get; set; }
|
||||
public float MultiplyBy { get; set; }
|
||||
}
|
||||
186
src/NadekoBot/Modules/Gambling/GamblingConfigService.cs
Normal file
186
src/NadekoBot/Modules/Gambling/GamblingConfigService.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.Configs;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public sealed class GamblingConfigService : ConfigServiceBase<GamblingConfig>
|
||||
{
|
||||
private const string FILE_PATH = "data/gambling.yml";
|
||||
private static readonly TypedKey<GamblingConfig> _changeKey = new("config.gambling.updated");
|
||||
|
||||
public override string Name
|
||||
=> "gambling";
|
||||
|
||||
private readonly IEnumerable<WaifuItemModel> _antiGiftSeed = new[]
|
||||
{
|
||||
new WaifuItemModel("🥀", 100, "WiltedRose", true), new WaifuItemModel("✂️", 1000, "Haircut", true),
|
||||
new WaifuItemModel("🧻", 10000, "ToiletPaper", true)
|
||||
};
|
||||
|
||||
public GamblingConfigService(IConfigSeria serializer, IPubSub pubSub)
|
||||
: base(FILE_PATH, serializer, pubSub, _changeKey)
|
||||
{
|
||||
AddParsedProp("currency.name",
|
||||
gs => gs.Currency.Name,
|
||||
ConfigParsers.String,
|
||||
ConfigPrinters.ToString);
|
||||
|
||||
AddParsedProp("currency.sign",
|
||||
gs => gs.Currency.Sign,
|
||||
ConfigParsers.String,
|
||||
ConfigPrinters.ToString);
|
||||
|
||||
AddParsedProp("minbet",
|
||||
gs => gs.MinBet,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 0);
|
||||
|
||||
AddParsedProp("maxbet",
|
||||
gs => gs.MaxBet,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 0);
|
||||
|
||||
AddParsedProp("gen.min",
|
||||
gs => gs.Generation.MinAmount,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 1);
|
||||
|
||||
AddParsedProp("gen.max",
|
||||
gs => gs.Generation.MaxAmount,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 1);
|
||||
|
||||
AddParsedProp("gen.cd",
|
||||
gs => gs.Generation.GenCooldown,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val > 0);
|
||||
|
||||
AddParsedProp("gen.chance",
|
||||
gs => gs.Generation.Chance,
|
||||
decimal.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val is >= 0 and <= 1);
|
||||
|
||||
AddParsedProp("gen.has_pw",
|
||||
gs => gs.Generation.HasPassword,
|
||||
bool.TryParse,
|
||||
ConfigPrinters.ToString);
|
||||
|
||||
AddParsedProp("bf.multi",
|
||||
gs => gs.BetFlip.Multiplier,
|
||||
decimal.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 1);
|
||||
|
||||
AddParsedProp("waifu.min_price",
|
||||
gs => gs.Waifu.MinPrice,
|
||||
long.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 0);
|
||||
|
||||
AddParsedProp("waifu.multi.reset",
|
||||
gs => gs.Waifu.Multipliers.WaifuReset,
|
||||
int.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 0);
|
||||
|
||||
AddParsedProp("waifu.multi.crush_claim",
|
||||
gs => gs.Waifu.Multipliers.CrushClaim,
|
||||
decimal.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val >= 0);
|
||||
|
||||
AddParsedProp("waifu.multi.normal_claim",
|
||||
gs => gs.Waifu.Multipliers.NormalClaim,
|
||||
decimal.TryParse,
|
||||
ConfigPrinters.ToString,
|
||||
val => val > 0);
|
||||
|
||||
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 is >= 0 and <= 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();
|
||||
}
|
||||
|
||||
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.Version = 3;
|
||||
c.VoteReward = 100;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 5)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
c.Version = 5;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.Version < 6)
|
||||
{
|
||||
ModifyConfig(c =>
|
||||
{
|
||||
c.Version = 6;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
221
src/NadekoBot/Modules/Gambling/GamblingService.cs
Normal file
221
src/NadekoBot/Modules/Gambling/GamblingService.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Connect4;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class GamblingService : INService, IReadyExecutor
|
||||
{
|
||||
public ConcurrentDictionary<(ulong, ulong), RollDuelGame> Duels { get; } = new();
|
||||
public ConcurrentDictionary<ulong, Connect4Game> Connect4Games { get; } = new();
|
||||
private readonly DbService _db;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly GamblingConfigService _gss;
|
||||
|
||||
private static readonly TypedKey<long> _curDecayKey = new("currency:last_decay");
|
||||
|
||||
public GamblingService(
|
||||
DbService db,
|
||||
DiscordSocketClient client,
|
||||
IBotCache cache,
|
||||
GamblingConfigService gss)
|
||||
{
|
||||
_db = db;
|
||||
_client = client;
|
||||
_cache = cache;
|
||||
_gss = gss;
|
||||
}
|
||||
|
||||
public Task OnReadyAsync()
|
||||
=> Task.WhenAll(CurrencyDecayLoopAsync(), TransactionClearLoopAsync());
|
||||
|
||||
private async Task TransactionClearLoopAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
var lifetime = _gss.Data.Currency.TransactionsLifetime;
|
||||
if (lifetime <= 0)
|
||||
continue;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var days = TimeSpan.FromDays(lifetime);
|
||||
await using var uow = _db.GetDbContext();
|
||||
await uow.Set<CurrencyTransaction>()
|
||||
.DeleteAsync(ct => ct.DateAdded == null || now - ct.DateAdded < days);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"An unexpected error occurred in transactions cleanup loop: {ErrorMessage}",
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CurrencyDecayLoopAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
|
||||
while (await timer.WaitForNextTickAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = _gss.Data;
|
||||
var maxDecay = config.Decay.MaxDecay;
|
||||
if (config.Decay.Percent is <= 0 or > 1 || maxDecay < 0)
|
||||
continue;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var result = await _cache.GetAsync(_curDecayKey);
|
||||
|
||||
if (result.TryPickT0(out var bin, out _)
|
||||
&& (now - DateTime.FromBinary(bin) < TimeSpan.FromHours(config.Decay.HourInterval)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.Information("""
|
||||
--- Decaying users' currency ---
|
||||
| decay: {ConfigDecayPercent}%
|
||||
| max: {MaxDecay}
|
||||
| threshold: {DecayMinTreshold}
|
||||
""",
|
||||
config.Decay.Percent * 100,
|
||||
maxDecay,
|
||||
config.Decay.MinThreshold);
|
||||
|
||||
if (maxDecay == 0)
|
||||
maxDecay = int.MaxValue;
|
||||
|
||||
var decay = (double)config.Decay.Percent;
|
||||
await uow.Set<DiscordUser>()
|
||||
.Where(x => x.CurrencyAmount > config.Decay.MinThreshold && x.UserId != _client.CurrentUser.Id)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
CurrencyAmount =
|
||||
maxDecay > Sql.Round((old.CurrencyAmount * decay) - 0.5)
|
||||
? (long)(old.CurrencyAmount - Sql.Round((old.CurrencyAmount * decay) - 0.5))
|
||||
: old.CurrencyAmount - maxDecay
|
||||
});
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
await _cache.AddAsync(_curDecayKey, now.ToBinary());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex,
|
||||
"An unexpected error occurred in currency decay loop: {ErrorMessage}",
|
||||
ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly TypedKey<EconomyResult> _ecoKey = new("nadeko:economy");
|
||||
|
||||
public async Task<EconomyResult> GetEconomyAsync()
|
||||
{
|
||||
var data = await _cache.GetOrAddAsync(_ecoKey,
|
||||
async () =>
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var cash = uow.Set<DiscordUser>().GetTotalCurrency();
|
||||
var onePercent = uow.Set<DiscordUser>().GetTopOnePercentCurrency(_client.CurrentUser.Id);
|
||||
decimal planted = uow.Set<PlantedCurrency>().AsQueryable().Sum(x => x.Amount);
|
||||
var waifus = uow.Set<WaifuInfo>().GetTotalValue();
|
||||
var bot = await uow.Set<DiscordUser>().GetUserCurrencyAsync(_client.CurrentUser.Id);
|
||||
decimal bank = await uow.GetTable<BankUser>()
|
||||
.SumAsyncLinqToDB(x => x.Balance);
|
||||
|
||||
var result = new EconomyResult
|
||||
{
|
||||
Cash = cash,
|
||||
Planted = planted,
|
||||
Bot = bot,
|
||||
Waifus = waifus,
|
||||
OnePercent = onePercent,
|
||||
Bank = bank
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
TimeSpan.FromMinutes(3));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
private static readonly SemaphoreSlim _timelyLock = new(1, 1);
|
||||
|
||||
private static TypedKey<Dictionary<ulong, long>> _timelyKey
|
||||
= new("timely:claims");
|
||||
|
||||
public async Task<TimeSpan?> ClaimTimelyAsync(ulong userId, int period)
|
||||
{
|
||||
if (period == 0)
|
||||
return null;
|
||||
|
||||
await _timelyLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
// get the dictionary from the cache or get a new one
|
||||
var dict = (await _cache.GetOrAddAsync(_timelyKey,
|
||||
() => Task.FromResult(new Dictionary<ulong, long>())))!;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nowB = now.ToBinary();
|
||||
|
||||
// try to get users last claim
|
||||
if (!dict.TryGetValue(userId, out var lastB))
|
||||
lastB = dict[userId] = now.ToBinary();
|
||||
|
||||
var diff = now - DateTime.FromBinary(lastB);
|
||||
|
||||
// if its now, or too long ago => success
|
||||
if (lastB == nowB || diff > period.Hours())
|
||||
{
|
||||
// update the cache
|
||||
dict[userId] = nowB;
|
||||
await _cache.AddAsync(_timelyKey, dict);
|
||||
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// otherwise return the remaining time
|
||||
return period.Hours() - diff;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_timelyLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public bool UserHasTimelyReminder(ulong userId)
|
||||
{
|
||||
var db = _db.GetDbContext();
|
||||
return db.GetTable<Reminder>().Any(x => x.UserId == userId
|
||||
&& x.Type == ReminderType.Timely);
|
||||
}
|
||||
|
||||
public async Task RemoveAllTimelyClaimsAsync()
|
||||
=> await _cache.RemoveAsync(_timelyKey);
|
||||
}
|
||||
68
src/NadekoBot/Modules/Gambling/GamblingTopLevelModule.cs
Normal file
68
src/NadekoBot/Modules/Gambling/GamblingTopLevelModule.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using System.Numerics;
|
||||
using NadekoBot.Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
public abstract class GamblingModule<TService> : NadekoModule<TService>
|
||||
{
|
||||
protected GamblingConfig Config
|
||||
=> _lazyConfig.Value;
|
||||
|
||||
protected string CurrencySign
|
||||
=> Config.Currency.Sign;
|
||||
|
||||
protected string CurrencyName
|
||||
=> Config.Currency.Name;
|
||||
|
||||
private readonly Lazy<GamblingConfig> _lazyConfig;
|
||||
|
||||
protected GamblingModule(GamblingConfigService gambService)
|
||||
=> _lazyConfig = new(() => gambService.Data);
|
||||
|
||||
private async Task<bool> InternalCheckBet(long amount)
|
||||
{
|
||||
if (amount < 1)
|
||||
return false;
|
||||
if (amount < Config.MinBet)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.min_bet_limit(Format.Bold(Config.MinBet.ToString()) + CurrencySign));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Config.MaxBet > 0 && amount > Config.MaxBet)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.max_bet_limit(Format.Bold(Config.MaxBet.ToString()) + CurrencySign));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected string N<T>(T cur)
|
||||
where T : INumber<T>
|
||||
=> CurrencyHelper.N(cur, Culture, CurrencySign);
|
||||
|
||||
protected Task<bool> CheckBetMandatory(long amount)
|
||||
{
|
||||
if (amount < 1)
|
||||
return Task.FromResult(false);
|
||||
return InternalCheckBet(amount);
|
||||
}
|
||||
|
||||
protected Task<bool> CheckBetOptional(long amount)
|
||||
{
|
||||
if (amount == 0)
|
||||
return Task.FromResult(true);
|
||||
return InternalCheckBet(amount);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class GamblingSubmodule<TService> : GamblingModule<TService>
|
||||
{
|
||||
protected GamblingSubmodule(GamblingConfigService gamblingConfService)
|
||||
: base(gamblingConfService)
|
||||
{
|
||||
}
|
||||
}
|
||||
3
src/NadekoBot/Modules/Gambling/InputRpsPick.cs
Normal file
3
src/NadekoBot/Modules/Gambling/InputRpsPick.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
112
src/NadekoBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
Normal file
112
src/NadekoBot/Modules/Gambling/PlantPick/PlantAndPickCommands.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class PlantPickCommands : GamblingSubmodule<PlantPickService>
|
||||
{
|
||||
private readonly ILogCommandService _logService;
|
||||
|
||||
public PlantPickCommands(ILogCommandService logService, GamblingConfigService gss)
|
||||
: base(gss)
|
||||
=> _logService = logService;
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Pick(string pass = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||
return;
|
||||
|
||||
var picked = await _service.PickAsync(ctx.Guild.Id, (ITextChannel)ctx.Channel, ctx.User.Id, pass);
|
||||
|
||||
if (picked > 0)
|
||||
{
|
||||
var msg = await ReplyConfirmLocalizedAsync(strs.picked(N(picked)));
|
||||
msg.DeleteAfter(10);
|
||||
}
|
||||
|
||||
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logService.AddDeleteIgnore(ctx.Message.Id);
|
||||
await ctx.Message.DeleteAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Plant([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, string pass = null)
|
||||
{
|
||||
if (amount < 1)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||
return;
|
||||
|
||||
if (((SocketGuild)ctx.Guild).CurrentUser.GuildPermissions.ManageMessages)
|
||||
{
|
||||
_logService.AddDeleteIgnore(ctx.Message.Id);
|
||||
await ctx.Message.DeleteAsync();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
#if GLOBAL_NADEKO
|
||||
[OwnerOnly]
|
||||
#endif
|
||||
public async Task GenCurrency()
|
||||
{
|
||||
var enabled = _service.ToggleCurrencyGeneration(ctx.Guild.Id, ctx.Channel.Id);
|
||||
if (enabled)
|
||||
await ReplyConfirmLocalizedAsync(strs.curgen_enabled);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.curgen_disabled);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.ManageMessages)]
|
||||
[OwnerOnly]
|
||||
public Task GenCurList(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return Task.CompletedTask;
|
||||
var enabledIn = _service.GetAllGeneratingChannels();
|
||||
|
||||
return ctx.SendPaginatedConfirmAsync(page,
|
||||
_ =>
|
||||
{
|
||||
var items = enabledIn.Skip(page * 9).Take(9).ToList();
|
||||
|
||||
if (!items.Any())
|
||||
return _eb.Create().WithErrorColor().WithDescription("-");
|
||||
|
||||
return items.Aggregate(_eb.Create().WithOkColor(),
|
||||
(eb, i) => eb.AddField(i.GuildId.ToString(), i.ChannelId));
|
||||
},
|
||||
enabledIn.Count(),
|
||||
9);
|
||||
}
|
||||
}
|
||||
}
|
||||
385
src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs
Normal file
385
src/NadekoBot/Modules/Gambling/PlantPick/PlantPickService.cs
Normal file
@@ -0,0 +1,385 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class PlantPickService : INService, IExecNoCommand
|
||||
{
|
||||
//channelId/last generation
|
||||
public ConcurrentDictionary<ulong, long> LastGenerations { get; } = new();
|
||||
private readonly DbService _db;
|
||||
private readonly IBotStrings _strings;
|
||||
private readonly IImageCache _images;
|
||||
private readonly FontProvider _fonts;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly CommandHandler _cmdHandler;
|
||||
private readonly NadekoRandom _rng;
|
||||
private readonly DiscordSocketClient _client;
|
||||
private readonly GamblingConfigService _gss;
|
||||
|
||||
private readonly ConcurrentHashSet<ulong> _generationChannels;
|
||||
private readonly SemaphoreSlim _pickLock = new(1, 1);
|
||||
|
||||
public PlantPickService(
|
||||
DbService db,
|
||||
CommandHandler cmd,
|
||||
IBotStrings strings,
|
||||
IImageCache images,
|
||||
FontProvider fonts,
|
||||
ICurrencyService cs,
|
||||
CommandHandler cmdHandler,
|
||||
DiscordSocketClient client,
|
||||
GamblingConfigService gss)
|
||||
{
|
||||
_db = db;
|
||||
_strings = strings;
|
||||
_images = images;
|
||||
_fonts = fonts;
|
||||
_cs = cs;
|
||||
_cmdHandler = cmdHandler;
|
||||
_rng = new();
|
||||
_client = client;
|
||||
_gss = gss;
|
||||
|
||||
using var uow = db.GetDbContext();
|
||||
var guildIds = client.Guilds.Select(x => x.Id).ToList();
|
||||
var configs = uow.Set<GuildConfig>()
|
||||
.AsQueryable()
|
||||
.Include(x => x.GenerateCurrencyChannelIds)
|
||||
.Where(x => guildIds.Contains(x.GuildId))
|
||||
.ToList();
|
||||
|
||||
_generationChannels = new(configs.SelectMany(c => c.GenerateCurrencyChannelIds.Select(obj => obj.ChannelId)));
|
||||
}
|
||||
|
||||
public Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
|
||||
=> PotentialFlowerGeneration(msg);
|
||||
|
||||
private string GetText(ulong gid, LocStr str)
|
||||
=> _strings.GetText(str, gid);
|
||||
|
||||
public bool ToggleCurrencyGeneration(ulong gid, ulong cid)
|
||||
{
|
||||
bool enabled;
|
||||
using var uow = _db.GetDbContext();
|
||||
var guildConfig = uow.GuildConfigsForId(gid, set => set.Include(gc => gc.GenerateCurrencyChannelIds));
|
||||
|
||||
var toAdd = new GCChannelId
|
||||
{
|
||||
ChannelId = cid
|
||||
};
|
||||
if (!guildConfig.GenerateCurrencyChannelIds.Contains(toAdd))
|
||||
{
|
||||
guildConfig.GenerateCurrencyChannelIds.Add(toAdd);
|
||||
_generationChannels.Add(cid);
|
||||
enabled = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var toDelete = guildConfig.GenerateCurrencyChannelIds.FirstOrDefault(x => x.Equals(toAdd));
|
||||
if (toDelete is not null)
|
||||
uow.Remove(toDelete);
|
||||
_generationChannels.TryRemove(cid);
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
uow.SaveChanges();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public IEnumerable<GuildConfigExtensions.GeneratingChannel> GetAllGeneratingChannels()
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
var chs = uow.Set<GuildConfig>().GetGeneratingChannels();
|
||||
return chs;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a random currency image stream, with an optional password sticked onto it.
|
||||
/// </summary>
|
||||
/// <param name="pass">Optional password to add to top left corner.</param>
|
||||
/// <param name="extension">Extension of the file, defaults to png</param>
|
||||
/// <returns>Stream of the currency image</returns>
|
||||
public async Task<(Stream, string)> GetRandomCurrencyImageAsync(string pass)
|
||||
{
|
||||
var curImg = await _images.GetCurrencyImageAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pass))
|
||||
{
|
||||
// determine the extension
|
||||
using var load = _ = Image.Load(curImg, out var format);
|
||||
|
||||
// return the image
|
||||
return (curImg.ToStream(), format.FileExtensions.FirstOrDefault() ?? "png");
|
||||
}
|
||||
|
||||
// get the image stream and extension
|
||||
return AddPassword(curImg, pass);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a password to the image.
|
||||
/// </summary>
|
||||
/// <param name="curImg">Image to add password to.</param>
|
||||
/// <param name="pass">Password to add to top left corner.</param>
|
||||
/// <returns>Image with the password in the top left corner.</returns>
|
||||
private (Stream, string) AddPassword(byte[] curImg, string pass)
|
||||
{
|
||||
// draw lower, it looks better
|
||||
pass = pass.TrimTo(10, true).ToLowerInvariant();
|
||||
using var img = Image.Load<Rgba32>(curImg, out var format);
|
||||
// choose font size based on the image height, so that it's visible
|
||||
var font = _fonts.NotoSans.CreateFont(img.Height / 12.0f, FontStyle.Bold);
|
||||
img.Mutate(x =>
|
||||
{
|
||||
// measure the size of the text to be drawing
|
||||
var size = TextMeasurer.Measure(pass, new TextOptions(font)
|
||||
{
|
||||
Origin = new PointF(0, 0)
|
||||
});
|
||||
|
||||
// fill the background with black, add 5 pixels on each side to make it look better
|
||||
x.FillPolygon(Color.ParseHex("00000080"),
|
||||
new PointF(0, 0),
|
||||
new PointF(size.Width + 5, 0),
|
||||
new PointF(size.Width + 5, size.Height + 10),
|
||||
new PointF(0, size.Height + 10));
|
||||
|
||||
// draw the password over the background
|
||||
x.DrawText(pass, font, Color.White, new(0, 0));
|
||||
});
|
||||
// return image as a stream for easy sending
|
||||
return (img.ToStream(format), format.FileExtensions.FirstOrDefault() ?? "png");
|
||||
}
|
||||
|
||||
private Task PotentialFlowerGeneration(IUserMessage imsg)
|
||||
{
|
||||
if (imsg is not SocketUserMessage msg || msg.Author.IsBot)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (imsg.Channel is not ITextChannel channel)
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (!_generationChannels.Contains(channel.Id))
|
||||
return Task.CompletedTask;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = _gss.Data;
|
||||
var lastGeneration = LastGenerations.GetOrAdd(channel.Id, DateTime.MinValue.ToBinary());
|
||||
var rng = new NadekoRandom();
|
||||
|
||||
if (DateTime.UtcNow - TimeSpan.FromSeconds(config.Generation.GenCooldown)
|
||||
< DateTime.FromBinary(lastGeneration)) //recently generated in this channel, don't generate again
|
||||
return;
|
||||
|
||||
var num = rng.Next(1, 101) + (config.Generation.Chance * 100);
|
||||
if (num > 100 && LastGenerations.TryUpdate(channel.Id, DateTime.UtcNow.ToBinary(), lastGeneration))
|
||||
{
|
||||
var dropAmount = config.Generation.MinAmount;
|
||||
var dropAmountMax = config.Generation.MaxAmount;
|
||||
|
||||
if (dropAmountMax > dropAmount)
|
||||
dropAmount = new NadekoRandom().Next(dropAmount, dropAmountMax + 1);
|
||||
|
||||
if (dropAmount > 0)
|
||||
{
|
||||
var prefix = _cmdHandler.GetPrefix(channel.Guild.Id);
|
||||
var toSend = dropAmount == 1
|
||||
? GetText(channel.GuildId, strs.curgen_sn(config.Currency.Sign))
|
||||
+ " "
|
||||
+ GetText(channel.GuildId, strs.pick_sn(prefix))
|
||||
: GetText(channel.GuildId, strs.curgen_pl(dropAmount, config.Currency.Sign))
|
||||
+ " "
|
||||
+ GetText(channel.GuildId, strs.pick_pl(prefix));
|
||||
|
||||
var pw = config.Generation.HasPassword ? GenerateCurrencyPassword().ToUpperInvariant() : null;
|
||||
|
||||
IUserMessage sent;
|
||||
var (stream, ext) = await GetRandomCurrencyImageAsync(pw);
|
||||
|
||||
await using (stream)
|
||||
sent = await channel.SendFileAsync(stream, $"currency_image.{ext}", toSend);
|
||||
|
||||
await AddPlantToDatabase(channel.GuildId,
|
||||
channel.Id,
|
||||
_client.CurrentUser.Id,
|
||||
sent.Id,
|
||||
dropAmount,
|
||||
pw);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a hexadecimal string from 1000 to ffff.
|
||||
/// </summary>
|
||||
/// <returns>A hexadecimal string from 1000 to ffff</returns>
|
||||
private string GenerateCurrencyPassword()
|
||||
{
|
||||
// generate a number from 1000 to ffff
|
||||
var num = _rng.Next(4096, 65536);
|
||||
// convert it to hexadecimal
|
||||
return num.ToString("x4");
|
||||
}
|
||||
|
||||
public async Task<long> PickAsync(
|
||||
ulong gid,
|
||||
ITextChannel ch,
|
||||
ulong uid,
|
||||
string pass)
|
||||
{
|
||||
await _pickLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
long amount;
|
||||
ulong[] ids;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
// this method will sum all plants with that password,
|
||||
// remove them, and get messageids of the removed plants
|
||||
|
||||
pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
|
||||
// gets all plants in this channel with the same password
|
||||
var entries = uow.Set<PlantedCurrency>().AsQueryable()
|
||||
.Where(x => x.ChannelId == ch.Id && pass == x.Password)
|
||||
.ToList();
|
||||
// sum how much currency that is, and get all of the message ids (so that i can delete them)
|
||||
amount = entries.Sum(x => x.Amount);
|
||||
ids = entries.Select(x => x.MessageId).ToArray();
|
||||
// remove them from the database
|
||||
uow.RemoveRange(entries);
|
||||
|
||||
|
||||
if (amount > 0)
|
||||
// give the picked currency to the user
|
||||
await _cs.AddAsync(uid, amount, new("currency", "collect"));
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// delete all of the plant messages which have just been picked
|
||||
_ = ch.DeleteMessagesAsync(ids);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// return the amount of currency the user picked
|
||||
return amount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pickLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ulong?> SendPlantMessageAsync(
|
||||
ulong gid,
|
||||
IMessageChannel ch,
|
||||
string user,
|
||||
long amount,
|
||||
string pass)
|
||||
{
|
||||
try
|
||||
{
|
||||
// get the text
|
||||
var prefix = _cmdHandler.GetPrefix(gid);
|
||||
var msgToSend = GetText(gid, strs.planted(Format.Bold(user), amount + _gss.Data.Currency.Sign));
|
||||
|
||||
if (amount > 1)
|
||||
msgToSend += " " + GetText(gid, strs.pick_pl(prefix));
|
||||
else
|
||||
msgToSend += " " + GetText(gid, strs.pick_sn(prefix));
|
||||
|
||||
//get the image
|
||||
var (stream, ext) = await GetRandomCurrencyImageAsync(pass);
|
||||
// send it
|
||||
await using (stream)
|
||||
{
|
||||
var msg = await ch.SendFileAsync(stream, $"img.{ext}", msgToSend);
|
||||
// return sent message's id (in order to be able to delete it when it's picked)
|
||||
return msg.Id;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// if sending fails, return null as message id
|
||||
Log.Warning(ex, "Sending plant message failed: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> PlantAsync(
|
||||
ulong gid,
|
||||
IMessageChannel ch,
|
||||
ulong uid,
|
||||
string user,
|
||||
long amount,
|
||||
string pass)
|
||||
{
|
||||
// normalize it - no more than 10 chars, uppercase
|
||||
pass = pass?.Trim().TrimTo(10, true).ToUpperInvariant();
|
||||
// has to be either null or alphanumeric
|
||||
if (!string.IsNullOrWhiteSpace(pass) && !pass.IsAlphaNumeric())
|
||||
return false;
|
||||
|
||||
// remove currency from the user who's planting
|
||||
if (await _cs.RemoveAsync(uid, amount, new("put/collect", "put")))
|
||||
{
|
||||
// try to send the message with the currency image
|
||||
var msgId = await SendPlantMessageAsync(gid, ch, user, amount, pass);
|
||||
if (msgId is null)
|
||||
{
|
||||
// if it fails it will return null, if it returns null, refund
|
||||
await _cs.AddAsync(uid, amount, new("put/collect", "refund"));
|
||||
return false;
|
||||
}
|
||||
|
||||
// if it doesn't fail, put the plant in the database for other people to pick
|
||||
await AddPlantToDatabase(gid, ch.Id, uid, msgId.Value, amount, pass);
|
||||
return true;
|
||||
}
|
||||
|
||||
// if user doesn't have enough currency, fail
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task AddPlantToDatabase(
|
||||
ulong gid,
|
||||
ulong cid,
|
||||
ulong uid,
|
||||
ulong mid,
|
||||
long amount,
|
||||
string pass)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
uow.Set<PlantedCurrency>().Add(new()
|
||||
{
|
||||
Amount = amount,
|
||||
GuildId = gid,
|
||||
ChannelId = cid,
|
||||
Password = pass,
|
||||
UserId = uid,
|
||||
MessageId = mid
|
||||
});
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
public partial class CurrencyRaffleCommands : GamblingSubmodule<CurrencyRaffleService>
|
||||
{
|
||||
public enum Mixed { Mixed }
|
||||
|
||||
public CurrencyRaffleCommands(GamblingConfigService gamblingConfService)
|
||||
: base(gamblingConfService)
|
||||
{
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public Task RaffleCur(Mixed _, [OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||
=> RaffleCur(amount, true);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task RaffleCur([OverrideTypeReader(typeof(BalanceTypeReader))] long amount, bool mixed = false)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount))
|
||||
return;
|
||||
|
||||
async Task OnEnded(IUser arg, long won)
|
||||
{
|
||||
await SendConfirmAsync(GetText(strs.rafflecur_ended(CurrencyName,
|
||||
Format.Bold(arg.ToString()),
|
||||
won + CurrencySign)));
|
||||
}
|
||||
|
||||
var res = await _service.JoinOrCreateGame(ctx.Channel.Id, ctx.User, amount, mixed, OnEnded);
|
||||
|
||||
if (res.Item1 is not null)
|
||||
{
|
||||
await SendConfirmAsync(GetText(strs.rafflecur(res.Item1.GameType.ToString())),
|
||||
string.Join("\n", res.Item1.Users.Select(x => $"{x.DiscordUser} ({N(x.Amount)})")),
|
||||
footer: GetText(strs.rafflecur_joined(ctx.User.ToString())));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (res.Item2 == CurrencyRaffleService.JoinErrorType.AlreadyJoinedOrInvalidAmount)
|
||||
await ReplyErrorLocalizedAsync(strs.rafflecur_already_joined);
|
||||
else if (res.Item2 == CurrencyRaffleService.JoinErrorType.NotEnoughCurrency)
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/NadekoBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs
Normal file
69
src/NadekoBot/Modules/Gambling/Raffle/CurrencyRaffleGame.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
public class CurrencyRaffleGame
|
||||
{
|
||||
public enum Type
|
||||
{
|
||||
Mixed,
|
||||
Normal
|
||||
}
|
||||
|
||||
public IEnumerable<User> Users
|
||||
=> _users;
|
||||
|
||||
public Type GameType { get; }
|
||||
|
||||
private readonly HashSet<User> _users = new();
|
||||
|
||||
public CurrencyRaffleGame(Type type)
|
||||
=> GameType = type;
|
||||
|
||||
public bool AddUser(IUser usr, long amount)
|
||||
{
|
||||
// if game type is normal, and someone already joined the game
|
||||
// (that's the user who created it)
|
||||
if (GameType == Type.Normal && _users.Count > 0 && _users.First().Amount != amount)
|
||||
return false;
|
||||
|
||||
if (!_users.Add(new()
|
||||
{
|
||||
DiscordUser = usr,
|
||||
Amount = amount
|
||||
}))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public User GetWinner()
|
||||
{
|
||||
var rng = new NadekoRandom();
|
||||
if (GameType == Type.Mixed)
|
||||
{
|
||||
var num = rng.NextLong(0L, Users.Sum(x => x.Amount));
|
||||
var sum = 0L;
|
||||
foreach (var u in Users)
|
||||
{
|
||||
sum += u.Amount;
|
||||
if (sum > num)
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
var usrs = _users.ToArray();
|
||||
return usrs[rng.Next(0, usrs.Length)];
|
||||
}
|
||||
|
||||
public class User
|
||||
{
|
||||
public IUser DiscordUser { get; set; }
|
||||
public long Amount { get; set; }
|
||||
|
||||
public override int GetHashCode()
|
||||
=> DiscordUser.GetHashCode();
|
||||
|
||||
public override bool Equals(object obj)
|
||||
=> obj is User u ? u.DiscordUser == DiscordUser : false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class CurrencyRaffleService : INService
|
||||
{
|
||||
public enum JoinErrorType
|
||||
{
|
||||
NotEnoughCurrency,
|
||||
AlreadyJoinedOrInvalidAmount
|
||||
}
|
||||
|
||||
public Dictionary<ulong, CurrencyRaffleGame> Games { get; } = new();
|
||||
private readonly SemaphoreSlim _locker = new(1, 1);
|
||||
private readonly ICurrencyService _cs;
|
||||
|
||||
public CurrencyRaffleService(ICurrencyService cs)
|
||||
=> _cs = cs;
|
||||
|
||||
public async Task<(CurrencyRaffleGame, JoinErrorType?)> JoinOrCreateGame(
|
||||
ulong channelId,
|
||||
IUser user,
|
||||
long amount,
|
||||
bool mixed,
|
||||
Func<IUser, long, Task> onEnded)
|
||||
{
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
var newGame = false;
|
||||
if (!Games.TryGetValue(channelId, out var crg))
|
||||
{
|
||||
newGame = true;
|
||||
crg = new(mixed ? CurrencyRaffleGame.Type.Mixed : CurrencyRaffleGame.Type.Normal);
|
||||
Games.Add(channelId, crg);
|
||||
}
|
||||
|
||||
//remove money, and stop the game if this
|
||||
// user created it and doesn't have the money
|
||||
if (!await _cs.RemoveAsync(user.Id, amount, new("raffle", "join")))
|
||||
{
|
||||
if (newGame)
|
||||
Games.Remove(channelId);
|
||||
return (null, JoinErrorType.NotEnoughCurrency);
|
||||
}
|
||||
|
||||
if (!crg.AddUser(user, amount))
|
||||
{
|
||||
await _cs.AddAsync(user.Id, amount, new("raffle", "refund"));
|
||||
return (null, JoinErrorType.AlreadyJoinedOrInvalidAmount);
|
||||
}
|
||||
|
||||
if (newGame)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(60000);
|
||||
await _locker.WaitAsync();
|
||||
try
|
||||
{
|
||||
var winner = crg.GetWinner();
|
||||
var won = crg.Users.Sum(x => x.Amount);
|
||||
|
||||
await _cs.AddAsync(winner.DiscordUser.Id, won, new("raffle", "win"));
|
||||
Games.Remove(channelId, out _);
|
||||
_ = onEnded(winner.DiscordUser, won);
|
||||
}
|
||||
catch { }
|
||||
finally { _locker.Release(); }
|
||||
});
|
||||
}
|
||||
|
||||
return (crg, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_locker.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/NadekoBot/Modules/Gambling/Shop/IShopService.cs
Normal file
46
src/NadekoBot/Modules/Gambling/Shop/IShopService.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
#nullable disable
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public interface IShopService
|
||||
{
|
||||
/// <summary>
|
||||
/// Changes the price of a shop item
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||
/// <param name="index">Index of the item</param>
|
||||
/// <param name="newPrice">New item price</param>
|
||||
/// <returns>Success status</returns>
|
||||
Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice);
|
||||
|
||||
/// <summary>
|
||||
/// Changes the name of a shop item
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||
/// <param name="index">Index of the item</param>
|
||||
/// <param name="newName">New item name</param>
|
||||
/// <returns>Success status</returns>
|
||||
Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName);
|
||||
|
||||
/// <summary>
|
||||
/// Swaps indexes of 2 items in the shop
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||
/// <param name="index1">First entry's index</param>
|
||||
/// <param name="index2">Second entry's index</param>
|
||||
/// <returns>Whether swap was successful</returns>
|
||||
Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2);
|
||||
|
||||
/// <summary>
|
||||
/// Swaps indexes of 2 items in the shop
|
||||
/// </summary>
|
||||
/// <param name="guildId">Id of the guild in which the shop is</param>
|
||||
/// <param name="fromIndex">Current index of the entry to move</param>
|
||||
/// <param name="toIndex">Destination index of the entry</param>
|
||||
/// <returns>Whether swap was successful</returns>
|
||||
Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex);
|
||||
|
||||
Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId);
|
||||
Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command);
|
||||
}
|
||||
582
src/NadekoBot/Modules/Gambling/Shop/ShopCommands.cs
Normal file
582
src/NadekoBot/Modules/Gambling/Shop/ShopCommands.cs
Normal file
@@ -0,0 +1,582 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
using NadekoBot.Modules.Administration;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class ShopCommands : GamblingSubmodule<IShopService>
|
||||
{
|
||||
public enum List
|
||||
{
|
||||
List
|
||||
}
|
||||
|
||||
public enum Role
|
||||
{
|
||||
Role
|
||||
}
|
||||
|
||||
public enum Command
|
||||
{
|
||||
Command,
|
||||
Cmd
|
||||
}
|
||||
|
||||
private readonly DbService _db;
|
||||
private readonly ICurrencyService _cs;
|
||||
|
||||
public ShopCommands(DbService db, ICurrencyService cs, GamblingConfigService gamblingConf)
|
||||
: base(gamblingConf)
|
||||
{
|
||||
_db = db;
|
||||
_cs = cs;
|
||||
}
|
||||
|
||||
private Task ShopInternalAsync(int page = 0)
|
||||
{
|
||||
if (page < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(page));
|
||||
|
||||
using var uow = _db.GetDbContext();
|
||||
var entries = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items))
|
||||
.ShopEntries.ToIndexed();
|
||||
return ctx.SendPaginatedConfirmAsync(page,
|
||||
curPage =>
|
||||
{
|
||||
var theseEntries = entries.Skip(curPage * 9).Take(9).ToArray();
|
||||
|
||||
if (!theseEntries.Any())
|
||||
return _eb.Create().WithErrorColor().WithDescription(GetText(strs.shop_none));
|
||||
var embed = _eb.Create().WithOkColor().WithTitle(GetText(strs.shop));
|
||||
|
||||
for (var i = 0; i < theseEntries.Length; i++)
|
||||
{
|
||||
var entry = theseEntries[i];
|
||||
embed.AddField($"#{(curPage * 9) + i + 1} - {N(entry.Price)}",
|
||||
EntryToString(entry),
|
||||
true);
|
||||
}
|
||||
|
||||
return embed;
|
||||
},
|
||||
entries.Count,
|
||||
9);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public Task Shop(int page = 1)
|
||||
{
|
||||
if (--page < 0)
|
||||
return Task.CompletedTask;
|
||||
|
||||
return ShopInternalAsync(page);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Buy(int index)
|
||||
{
|
||||
index -= 1;
|
||||
if (index < 0)
|
||||
return;
|
||||
ShopEntry entry;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
|
||||
var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
|
||||
entry = entries.ElementAtOrDefault(index);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.RoleRequirement is ulong reqRoleId)
|
||||
{
|
||||
var role = ctx.Guild.GetRole(reqRoleId);
|
||||
if (role is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_req_role_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
if (!guser.RoleIds.Contains(reqRoleId))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_req_role_unfulfilled(Format.Bold(role.ToString())));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.Type == ShopEntryType.Role)
|
||||
{
|
||||
var guser = (IGuildUser)ctx.User;
|
||||
var role = ctx.Guild.GetRole(entry.RoleId);
|
||||
|
||||
if (role is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_role_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (guser.RoleIds.Any(id => id == role.Id))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_role_already_bought);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||
{
|
||||
try
|
||||
{
|
||||
await guser.AddRoleAsync(role);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error adding shop role");
|
||||
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund"));
|
||||
await ReplyErrorLocalizedAsync(strs.shop_role_purchase_error);
|
||||
return;
|
||||
}
|
||||
|
||||
var profit = GetProfitAmount(entry.Price);
|
||||
await _cs.AddAsync(entry.AuthorId, profit, new("shop", "sell", $"Shop sell item - {entry.Type}"));
|
||||
await _cs.AddAsync(ctx.Client.CurrentUser.Id, entry.Price - profit, new("shop", "cut"));
|
||||
await ReplyConfirmLocalizedAsync(strs.shop_role_purchase(Format.Bold(role.Name)));
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
else if (entry.Type == ShopEntryType.List)
|
||||
{
|
||||
if (entry.Items.Count == 0)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.out_of_stock);
|
||||
return;
|
||||
}
|
||||
|
||||
var item = entry.Items.ToArray()[new NadekoRandom().Next(0, entry.Items.Count)];
|
||||
|
||||
if (await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||
{
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
uow.Set<ShopEntryItem>().Remove(item);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ctx.User.EmbedAsync(_eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.shop_purchase(ctx.Guild.Name)))
|
||||
.AddField(GetText(strs.item), item.Text)
|
||||
.AddField(GetText(strs.price), entry.Price.ToString(), true)
|
||||
.AddField(GetText(strs.name), entry.Name, true));
|
||||
|
||||
await _cs.AddAsync(entry.AuthorId,
|
||||
GetProfitAmount(entry.Price),
|
||||
new("shop", "sell", entry.Name));
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _cs.AddAsync(ctx.User.Id, entry.Price, new("shop", "error-refund", entry.Name));
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries)
|
||||
.ThenInclude(x => x.Items))
|
||||
.ShopEntries);
|
||||
entry = entries.ElementAtOrDefault(index);
|
||||
if (entry is not null)
|
||||
{
|
||||
if (entry.Items.Add(item))
|
||||
uow.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.shop_buy_error);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.shop_item_purchase);
|
||||
}
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
}
|
||||
else if (entry.Type == ShopEntryType.Command)
|
||||
{
|
||||
var guild = ctx.Guild as SocketGuild;
|
||||
var channel = ctx.Channel as ISocketMessageChannel;
|
||||
var msg = ctx.Message as SocketUserMessage;
|
||||
var user = await ctx.Guild.GetUserAsync(entry.AuthorId);
|
||||
|
||||
if (guild is null || channel is null || msg is null || user is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_command_invalid_context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await _cs.RemoveAsync(ctx.User.Id, entry.Price, new("shop", "buy", entry.Type.ToString())))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cmd = entry.Command.Replace("%you%", ctx.User.Id.ToString());
|
||||
var eb = _eb.Create()
|
||||
.WithPendingColor()
|
||||
.WithTitle("Executing shop command")
|
||||
.WithDescription(cmd);
|
||||
|
||||
var msgTask = ctx.Channel.EmbedAsync(eb);
|
||||
|
||||
await _cs.AddAsync(entry.AuthorId,
|
||||
GetProfitAmount(entry.Price),
|
||||
new("shop", "sell", entry.Name));
|
||||
|
||||
await _cmdHandler.TryRunCommand(guild,
|
||||
channel,
|
||||
new DoAsUserMessage(
|
||||
msg,
|
||||
user,
|
||||
cmd
|
||||
));
|
||||
|
||||
try
|
||||
{
|
||||
var pendingMsg = await msgTask;
|
||||
await pendingMsg.EditAsync(SmartEmbedText.FromEmbed(eb
|
||||
.WithOkColor()
|
||||
.WithTitle("Shop command executed")
|
||||
.Build()));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetProfitAmount(int price)
|
||||
=> (int)Math.Ceiling(0.90 * price);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ShopAdd(Command _, int price, [Leftover] string command)
|
||||
{
|
||||
if (price < 1)
|
||||
return;
|
||||
|
||||
|
||||
var entry = await _service.AddShopCommandAsync(ctx.Guild.Id, ctx.User.Id, price, command);
|
||||
|
||||
await ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
[BotPerm(GuildPerm.ManageRoles)]
|
||||
public async Task ShopAdd(Role _, int price, [Leftover] IRole role)
|
||||
{
|
||||
if (price < 1)
|
||||
return;
|
||||
|
||||
var entry = new ShopEntry
|
||||
{
|
||||
Name = "-",
|
||||
Price = price,
|
||||
Type = ShopEntryType.Role,
|
||||
AuthorId = ctx.User.Id,
|
||||
RoleId = role.Id,
|
||||
RoleName = role.Name
|
||||
};
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries)
|
||||
.ThenInclude(x => x.Items))
|
||||
.ShopEntries)
|
||||
{
|
||||
entry
|
||||
};
|
||||
uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopAdd(List _, int price, [Leftover] string name)
|
||||
{
|
||||
if (price < 1)
|
||||
return;
|
||||
|
||||
var entry = new ShopEntry
|
||||
{
|
||||
Name = name.TrimTo(100),
|
||||
Price = price,
|
||||
Type = ShopEntryType.List,
|
||||
AuthorId = ctx.User.Id,
|
||||
Items = new()
|
||||
};
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries)
|
||||
.ThenInclude(x => x.Items))
|
||||
.ShopEntries)
|
||||
{
|
||||
entry
|
||||
};
|
||||
uow.GuildConfigsForId(ctx.Guild.Id, set => set).ShopEntries = entries;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(EntryToEmbed(entry).WithTitle(GetText(strs.shop_item_add)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopListAdd(int index, [Leftover] string itemText)
|
||||
{
|
||||
index -= 1;
|
||||
if (index < 0)
|
||||
return;
|
||||
var item = new ShopEntryItem
|
||||
{
|
||||
Text = itemText
|
||||
};
|
||||
ShopEntry entry;
|
||||
var rightType = false;
|
||||
var added = false;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var entries = new IndexedCollection<ShopEntry>(uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries)
|
||||
.ThenInclude(x => x.Items))
|
||||
.ShopEntries);
|
||||
entry = entries.ElementAtOrDefault(index);
|
||||
if (entry is not null && (rightType = entry.Type == ShopEntryType.List))
|
||||
{
|
||||
if (entry.Items.Add(item))
|
||||
{
|
||||
added = true;
|
||||
uow.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
|
||||
else if (!rightType)
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_wrong_type);
|
||||
else if (added == false)
|
||||
await ReplyErrorLocalizedAsync(strs.shop_list_item_not_unique);
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.shop_list_item_added);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopRemove(int index)
|
||||
{
|
||||
index -= 1;
|
||||
if (index < 0)
|
||||
return;
|
||||
ShopEntry removed;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var config = uow.GuildConfigsForId(ctx.Guild.Id,
|
||||
set => set.Include(x => x.ShopEntries).ThenInclude(x => x.Items));
|
||||
|
||||
var entries = new IndexedCollection<ShopEntry>(config.ShopEntries);
|
||||
removed = entries.ElementAtOrDefault(index);
|
||||
if (removed is not null)
|
||||
{
|
||||
uow.RemoveRange(removed.Items);
|
||||
uow.Remove(removed);
|
||||
uow.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
if (removed is null)
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
|
||||
else
|
||||
await ctx.Channel.EmbedAsync(EntryToEmbed(removed).WithTitle(GetText(strs.shop_item_rm)));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopChangePrice(int index, int price)
|
||||
{
|
||||
if (--index < 0 || price <= 0)
|
||||
return;
|
||||
|
||||
var succ = await _service.ChangeEntryPriceAsync(ctx.Guild.Id, index, price);
|
||||
if (succ)
|
||||
{
|
||||
await ShopInternalAsync(index / 9);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
else
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopChangeName(int index, [Leftover] string newName)
|
||||
{
|
||||
if (--index < 0 || string.IsNullOrWhiteSpace(newName))
|
||||
return;
|
||||
|
||||
var succ = await _service.ChangeEntryNameAsync(ctx.Guild.Id, index, newName);
|
||||
if (succ)
|
||||
{
|
||||
await ShopInternalAsync(index / 9);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
else
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopSwap(int index1, int index2)
|
||||
{
|
||||
if (--index1 < 0 || --index2 < 0 || index1 == index2)
|
||||
return;
|
||||
|
||||
var succ = await _service.SwapEntriesAsync(ctx.Guild.Id, index1, index2);
|
||||
if (succ)
|
||||
{
|
||||
await ShopInternalAsync(index1 / 9);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
else
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopMove(int fromIndex, int toIndex)
|
||||
{
|
||||
if (--fromIndex < 0 || --toIndex < 0 || fromIndex == toIndex)
|
||||
return;
|
||||
|
||||
var succ = await _service.MoveEntryAsync(ctx.Guild.Id, fromIndex, toIndex);
|
||||
if (succ)
|
||||
{
|
||||
await ShopInternalAsync(toIndex / 9);
|
||||
await ctx.OkAsync();
|
||||
}
|
||||
else
|
||||
await ctx.ErrorAsync();
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[UserPerm(GuildPerm.Administrator)]
|
||||
public async Task ShopReq(int itemIndex, [Leftover] IRole role = null)
|
||||
{
|
||||
if (--itemIndex < 0)
|
||||
return;
|
||||
|
||||
var succ = await _service.SetItemRoleRequirementAsync(ctx.Guild.Id, itemIndex, role?.Id);
|
||||
if (!succ)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.shop_item_not_found);
|
||||
return;
|
||||
}
|
||||
|
||||
if (role is null)
|
||||
await ReplyConfirmLocalizedAsync(strs.shop_item_role_no_req(itemIndex));
|
||||
else
|
||||
await ReplyConfirmLocalizedAsync(strs.shop_item_role_req(itemIndex + 1, role));
|
||||
}
|
||||
|
||||
public IEmbedBuilder EntryToEmbed(ShopEntry entry)
|
||||
{
|
||||
var embed = _eb.Create().WithOkColor();
|
||||
|
||||
if (entry.Type == ShopEntryType.Role)
|
||||
{
|
||||
return embed
|
||||
.AddField(GetText(strs.name),
|
||||
GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name
|
||||
?? "MISSING_ROLE"))),
|
||||
true)
|
||||
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||
.AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||
}
|
||||
|
||||
if (entry.Type == ShopEntryType.List)
|
||||
{
|
||||
return embed.AddField(GetText(strs.name), entry.Name, true)
|
||||
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||
.AddField(GetText(strs.type), GetText(strs.random_unique_item), true);
|
||||
}
|
||||
|
||||
else if (entry.Type == ShopEntryType.Command)
|
||||
{
|
||||
return embed
|
||||
.AddField(GetText(strs.name), Format.Code(entry.Command), true)
|
||||
.AddField(GetText(strs.price), N(entry.Price), true)
|
||||
.AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||
}
|
||||
|
||||
//else if (entry.Type == ShopEntryType.Infinite_List)
|
||||
// return embed.AddField(GetText(strs.name), GetText(strs.shop_role(Format.Bold(entry.RoleName)), true))
|
||||
// .AddField(GetText(strs.price), entry.Price.ToString(), true)
|
||||
// .AddField(GetText(strs.type), entry.Type.ToString(), true);
|
||||
return null;
|
||||
}
|
||||
|
||||
public string EntryToString(ShopEntry entry)
|
||||
{
|
||||
var prepend = string.Empty;
|
||||
if (entry.RoleRequirement is not null)
|
||||
prepend = Format.Italics(GetText(strs.shop_item_requires_role($"<@&{entry.RoleRequirement}>")))
|
||||
+ Environment.NewLine;
|
||||
|
||||
if (entry.Type == ShopEntryType.Role)
|
||||
return prepend
|
||||
+ GetText(strs.shop_role(Format.Bold(ctx.Guild.GetRole(entry.RoleId)?.Name ?? "MISSING_ROLE")));
|
||||
if (entry.Type == ShopEntryType.List)
|
||||
return prepend + GetText(strs.unique_items_left(entry.Items.Count)) + "\n" + entry.Name;
|
||||
|
||||
if (entry.Type == ShopEntryType.Command)
|
||||
return prepend + Format.Code(entry.Command);
|
||||
return prepend;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/NadekoBot/Modules/Gambling/Shop/ShopService.cs
Normal file
133
src/NadekoBot/Modules/Gambling/Shop/ShopService.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
#nullable disable
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Db;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class ShopService : IShopService, INService
|
||||
{
|
||||
private readonly DbService _db;
|
||||
|
||||
public ShopService(DbService db)
|
||||
=> _db = db;
|
||||
|
||||
private IndexedCollection<ShopEntry> GetEntriesInternal(DbContext uow, ulong guildId)
|
||||
=> uow.GuildConfigsForId(guildId,
|
||||
set => set.Include(x => x.ShopEntries)
|
||||
.ThenInclude(x => x.Items))
|
||||
.ShopEntries.ToIndexed();
|
||||
|
||||
public async Task<bool> ChangeEntryPriceAsync(ulong guildId, int index, int newPrice)
|
||||
{
|
||||
if (index < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
if (newPrice <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(newPrice));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
|
||||
if (index >= entries.Count)
|
||||
return false;
|
||||
|
||||
entries[index].Price = newPrice;
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeEntryNameAsync(ulong guildId, int index, string newName)
|
||||
{
|
||||
if (index < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index));
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
throw new ArgumentNullException(nameof(newName));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
|
||||
if (index >= entries.Count)
|
||||
return false;
|
||||
|
||||
entries[index].Name = newName.TrimTo(100);
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SwapEntriesAsync(ulong guildId, int index1, int index2)
|
||||
{
|
||||
if (index1 < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index1));
|
||||
if (index2 < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(index2));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
|
||||
if (index1 >= entries.Count || index2 >= entries.Count || index1 == index2)
|
||||
return false;
|
||||
|
||||
entries[index1].Index = index2;
|
||||
entries[index2].Index = index1;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> MoveEntryAsync(ulong guildId, int fromIndex, int toIndex)
|
||||
{
|
||||
if (fromIndex < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(fromIndex));
|
||||
if (toIndex < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(toIndex));
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
|
||||
if (fromIndex >= entries.Count || toIndex >= entries.Count || fromIndex == toIndex)
|
||||
return false;
|
||||
|
||||
var entry = entries[fromIndex];
|
||||
entries.RemoveAt(fromIndex);
|
||||
entries.Insert(toIndex, entry);
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> SetItemRoleRequirementAsync(ulong guildId, int index, ulong? roleId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
|
||||
if (index >= entries.Count)
|
||||
return false;
|
||||
|
||||
var entry = entries[index];
|
||||
|
||||
entry.RoleRequirement = roleId;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<ShopEntry> AddShopCommandAsync(ulong guildId, ulong userId, int price, string command)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
var entries = GetEntriesInternal(uow, guildId);
|
||||
var entry = new ShopEntry()
|
||||
{
|
||||
AuthorId = userId,
|
||||
Command = command,
|
||||
Type = ShopEntryType.Command,
|
||||
Price = price,
|
||||
};
|
||||
entries.Add(entry);
|
||||
uow.GuildConfigsForId(guildId, set => set).ShopEntries = entries;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
227
src/NadekoBot/Modules/Gambling/Slot/SlotCommands.cs
Normal file
227
src/NadekoBot/Modules/Gambling/Slot/SlotCommands.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
#nullable disable warnings
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using SixLabors.Fonts;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Drawing.Processing;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using Nadeko.Econ.Gambling;
|
||||
using NadekoBot.Common.TypeReaders;
|
||||
using Color = SixLabors.ImageSharp.Color;
|
||||
using Image = SixLabors.ImageSharp.Image;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public enum GamblingError
|
||||
{
|
||||
InsufficientFunds,
|
||||
}
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class SlotCommands : GamblingSubmodule<IGamblingService>
|
||||
{
|
||||
private static decimal totalBet;
|
||||
private static decimal totalPaidOut;
|
||||
|
||||
private readonly IImageCache _images;
|
||||
private readonly FontProvider _fonts;
|
||||
private readonly DbService _db;
|
||||
private object _slotStatsLock = new();
|
||||
|
||||
public SlotCommands(
|
||||
IImageCache images,
|
||||
FontProvider fonts,
|
||||
DbService db,
|
||||
GamblingConfigService gamb)
|
||||
: base(gamb)
|
||||
{
|
||||
_images = images;
|
||||
_fonts = fonts;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public Task Test()
|
||||
=> Task.CompletedTask;
|
||||
|
||||
[Cmd]
|
||||
public async Task Slot([OverrideTypeReader(typeof(BalanceTypeReader))] long amount)
|
||||
{
|
||||
if (!await CheckBetMandatory(amount))
|
||||
return;
|
||||
|
||||
// var slotInteraction = CreateSlotInteractionIntenal(amount);
|
||||
|
||||
await ctx.Channel.TriggerTypingAsync();
|
||||
|
||||
if (await InternalSlotAsync(amount) is not SlotResult result)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
var text = GetSlotMessageTextInternal(result);
|
||||
|
||||
using var image = await GenerateSlotImageAsync(amount, result);
|
||||
await using var imgStream = await image.ToStreamAsync();
|
||||
|
||||
|
||||
var eb = _eb.Create(ctx)
|
||||
.WithAuthor(ctx.User)
|
||||
.WithDescription(Format.Bold(text))
|
||||
.WithImageUrl($"attachment://result.png")
|
||||
.WithOkColor();
|
||||
|
||||
var bb = new ButtonBuilder(emote: Emoji.Parse("🔁"), customId: "slot:again", label: "Pull Again");
|
||||
var si = new SimpleInteraction<long>(bb, (_, amount) => Slot(amount), amount);
|
||||
|
||||
var inter = _inter.Create(ctx.User.Id, si);
|
||||
var msg = await ctx.Channel.SendFileAsync(imgStream,
|
||||
"result.png",
|
||||
embed: eb.Build(),
|
||||
components: inter.CreateComponent()
|
||||
);
|
||||
await inter.RunAsync(msg);
|
||||
}
|
||||
|
||||
// private SlotInteraction CreateSlotInteractionIntenal(long amount)
|
||||
// {
|
||||
// return new SlotInteraction((DiscordSocketClient)ctx.Client,
|
||||
// ctx.User.Id,
|
||||
// async (smc) =>
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// if (await InternalSlotAsync(amount) is not SlotResult result)
|
||||
// {
|
||||
// await smc.RespondErrorAsync(_eb, GetText(strs.not_enough(CurrencySign)), true);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// var msg = GetSlotMessageInternal(result);
|
||||
//
|
||||
// using var image = await GenerateSlotImageAsync(amount, result);
|
||||
// await using var imgStream = await image.ToStreamAsync();
|
||||
//
|
||||
// var guid = Guid.NewGuid();
|
||||
// var imgName = $"result_{guid}.png";
|
||||
//
|
||||
// var slotInteraction = CreateSlotInteractionIntenal(amount).GetInteraction();
|
||||
//
|
||||
// await smc.Message.ModifyAsync(m =>
|
||||
// {
|
||||
// m.Content = msg;
|
||||
// m.Attachments = new[]
|
||||
// {
|
||||
// new FileAttachment(imgStream, imgName)
|
||||
// };
|
||||
// m.Components = slotInteraction.CreateComponent();
|
||||
// });
|
||||
//
|
||||
// _ = slotInteraction.RunAsync(smc.Message);
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// Log.Error(ex, "Error pulling slot again");
|
||||
// }
|
||||
// // finally
|
||||
// // {
|
||||
// // await Task.Delay(1000);
|
||||
// // _runningUsers.TryRemove(ctx.User.Id);
|
||||
// // }
|
||||
// });
|
||||
// }
|
||||
|
||||
private string GetSlotMessageTextInternal(SlotResult result)
|
||||
{
|
||||
var multi = result.Multiplier.ToString("0.##");
|
||||
var msg = result.WinType switch
|
||||
{
|
||||
SlotWinType.SingleJoker => GetText(strs.slot_single(CurrencySign, multi)),
|
||||
SlotWinType.DoubleJoker => GetText(strs.slot_two(CurrencySign, multi)),
|
||||
SlotWinType.TrippleNormal => GetText(strs.slot_three(multi)),
|
||||
SlotWinType.TrippleJoker => GetText(strs.slot_jackpot(multi)),
|
||||
_ => GetText(strs.better_luck),
|
||||
};
|
||||
return msg;
|
||||
}
|
||||
|
||||
private async Task<SlotResult?> InternalSlotAsync(long amount)
|
||||
{
|
||||
var maybeResult = await _service.SlotAsync(ctx.User.Id, amount);
|
||||
|
||||
if (!maybeResult.TryPickT0(out var result, out var error))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_slotStatsLock)
|
||||
{
|
||||
totalBet += amount;
|
||||
totalPaidOut += result.Won;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Image<Rgba32>> GenerateSlotImageAsync(long amount, SlotResult result)
|
||||
{
|
||||
long ownedAmount;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
ownedAmount = uow.Set<DiscordUser>().FirstOrDefault(x => x.UserId == ctx.User.Id)?.CurrencyAmount
|
||||
?? 0;
|
||||
}
|
||||
|
||||
var slotBg = await _images.GetSlotBgAsync();
|
||||
var bgImage = Image.Load<Rgba32>(slotBg, out _);
|
||||
var numbers = new int[3];
|
||||
result.Rolls.CopyTo(numbers, 0);
|
||||
|
||||
Color fontColor = Config.Slots.CurrencyFontColor;
|
||||
|
||||
bgImage.Mutate(x => x.DrawText(new TextOptions(_fonts.DottyFont.CreateFont(65))
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
WrappingLength = 140,
|
||||
Origin = new(298, 100)
|
||||
},
|
||||
((long)result.Won).ToString(),
|
||||
fontColor));
|
||||
|
||||
var bottomFont = _fonts.DottyFont.CreateFont(50);
|
||||
|
||||
bgImage.Mutate(x => x.DrawText(new TextOptions(bottomFont)
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
WrappingLength = 135,
|
||||
Origin = new(196, 480)
|
||||
},
|
||||
amount.ToString(),
|
||||
fontColor));
|
||||
|
||||
bgImage.Mutate(x => x.DrawText(new(bottomFont)
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Origin = new(393, 480)
|
||||
},
|
||||
ownedAmount.ToString(),
|
||||
fontColor));
|
||||
//sw.PrintLap("drew red text");
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
using var img = Image.Load(await _images.GetSlotEmojiAsync(numbers[i]));
|
||||
bgImage.Mutate(x => x.DrawImage(img, new Point(148 + (105 * i), 217), 1f));
|
||||
}
|
||||
|
||||
return bgImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/NadekoBot/Modules/Gambling/VoteRewardService.cs
Normal file
106
src/NadekoBot/Modules/Gambling/VoteRewardService.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
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 ICurrencyService _currencyService;
|
||||
private readonly GamblingConfigService _gamb;
|
||||
|
||||
public VoteRewardService(
|
||||
DiscordSocketClient client,
|
||||
IBotCredentials creds,
|
||||
ICurrencyService currencyService,
|
||||
GamblingConfigService gamb)
|
||||
{
|
||||
_client = client;
|
||||
_creds = creds;
|
||||
_currencyService = currencyService;
|
||||
_gamb = gamb;
|
||||
}
|
||||
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
using var 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,
|
||||
_gamb.Data.VoteReward,
|
||||
new("vote", "top.gg", "top.gg vote reward"));
|
||||
|
||||
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,
|
||||
_gamb.Data.VoteReward,
|
||||
new("vote", "discords", "discords.com vote reward"));
|
||||
|
||||
Log.Information("Rewarding {Count} discords.com voters", ids.Count());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Critical error loading discords.com vote rewards");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
375
src/NadekoBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
Normal file
375
src/NadekoBot/Modules/Gambling/Waifus/WaifuClaimCommands.cs
Normal file
@@ -0,0 +1,375 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
using NadekoBot.Modules.Gambling.Services;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling;
|
||||
|
||||
public partial class Gambling
|
||||
{
|
||||
[Group]
|
||||
public partial class WaifuClaimCommands : GamblingSubmodule<WaifuService>
|
||||
{
|
||||
public WaifuClaimCommands(GamblingConfigService gamblingConfService)
|
||||
: base(gamblingConfService)
|
||||
{
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
public async Task WaifuReset()
|
||||
{
|
||||
var price = _service.GetResetPrice(ctx.User);
|
||||
var embed = _eb.Create()
|
||||
.WithTitle(GetText(strs.waifu_reset_confirm))
|
||||
.WithDescription(GetText(strs.waifu_reset_price(Format.Bold(N(price)))));
|
||||
|
||||
if (!await PromptUserConfirmAsync(embed))
|
||||
return;
|
||||
|
||||
if (await _service.TryReset(ctx.User))
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_reset);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_reset_fail);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task WaifuClaim(long amount, [Leftover] IUser target)
|
||||
{
|
||||
if (amount < Config.Waifu.MinPrice)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_isnt_cheap(Config.Waifu.MinPrice + CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.Id == ctx.User.Id)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_not_yourself);
|
||||
return;
|
||||
}
|
||||
|
||||
var (w, isAffinity, result) = await _service.ClaimWaifuAsync(ctx.User, target, amount);
|
||||
|
||||
if (result == WaifuClaimResult.InsufficientAmount)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(
|
||||
strs.waifu_not_enough(N((long)Math.Ceiling(w.Price * (isAffinity ? 0.88f : 1.1f)))));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result == WaifuClaimResult.NotEnoughFunds)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = GetText(strs.waifu_claimed(Format.Bold(target.ToString()), N(amount)));
|
||||
if (w.Affinity?.UserId == ctx.User.Id)
|
||||
msg += "\n" + GetText(strs.waifu_fulfilled(target, N(w.Price)));
|
||||
else
|
||||
msg = " " + msg;
|
||||
await SendConfirmAsync(ctx.User.Mention + msg);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public async Task WaifuTransfer(ulong waifuId, IUser newOwner)
|
||||
{
|
||||
if (!await _service.WaifuTransfer(ctx.User, waifuId, newOwner))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifuId.ToString()),
|
||||
Format.Bold(ctx.User.ToString()),
|
||||
Format.Bold(newOwner.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task WaifuTransfer(IUser waifu, IUser newOwner)
|
||||
{
|
||||
if (!await _service.WaifuTransfer(ctx.User, waifu.Id, newOwner))
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_transfer_fail);
|
||||
return;
|
||||
}
|
||||
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_transfer_success(Format.Bold(waifu.ToString()),
|
||||
Format.Bold(ctx.User.ToString()),
|
||||
Format.Bold(newOwner.ToString())));
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(-1)]
|
||||
public Task Divorce([Leftover] string target)
|
||||
{
|
||||
var waifuUserId = _service.GetWaifuUserId(ctx.User.Id, target);
|
||||
if (waifuUserId == default)
|
||||
return ReplyErrorLocalizedAsync(strs.waifu_not_yours);
|
||||
|
||||
return Divorce(waifuUserId);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public Task Divorce([Leftover] IGuildUser target)
|
||||
=> Divorce(target.Id);
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task Divorce([Leftover] ulong targetId)
|
||||
{
|
||||
if (targetId == ctx.User.Id)
|
||||
return;
|
||||
|
||||
var (w, result, amount, remaining) = await _service.DivorceWaifuAsync(ctx.User, targetId);
|
||||
|
||||
if (result == DivorceResult.SucessWithPenalty)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_divorced_like(Format.Bold(w.Waifu.ToString()),
|
||||
N(amount)));
|
||||
}
|
||||
else if (result == DivorceResult.Success)
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_divorced_notlike(N(amount)));
|
||||
else if (result == DivorceResult.NotYourWife)
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_not_yours);
|
||||
else
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_recent_divorce(
|
||||
Format.Bold(((int)remaining?.TotalHours).ToString()),
|
||||
Format.Bold(remaining?.Minutes.ToString())));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task Affinity([Leftover] IGuildUser user = null)
|
||||
{
|
||||
if (user?.Id == ctx.User.Id)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_egomaniac);
|
||||
return;
|
||||
}
|
||||
|
||||
var (oldAff, sucess, remaining) = await _service.ChangeAffinityAsync(ctx.User, user);
|
||||
if (!sucess)
|
||||
{
|
||||
if (remaining is not null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_affinity_cooldown(
|
||||
Format.Bold(((int)remaining?.TotalHours).ToString()),
|
||||
Format.Bold(remaining?.Minutes.ToString())));
|
||||
}
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_affinity_already);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (user is null)
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_reset);
|
||||
else if (oldAff is null)
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_set(Format.Bold(user.ToString())));
|
||||
else
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_affinity_changed(Format.Bold(oldAff.ToString()),
|
||||
Format.Bold(user.ToString())));
|
||||
}
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
public async Task WaifuLb(int page = 1)
|
||||
{
|
||||
page--;
|
||||
|
||||
if (page < 0)
|
||||
return;
|
||||
|
||||
if (page > 100)
|
||||
page = 100;
|
||||
|
||||
var waifus = _service.GetTopWaifusAtPage(page).ToList();
|
||||
|
||||
if (waifus.Count == 0)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.waifus_none);
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = _eb.Create().WithTitle(GetText(strs.waifus_top_waifus)).WithOkColor();
|
||||
|
||||
var i = 0;
|
||||
foreach (var w in waifus)
|
||||
{
|
||||
var j = i++;
|
||||
embed.AddField("#" + ((page * 9) + j + 1) + " - " + N(w.Price), GetLbString(w));
|
||||
}
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
private string GetLbString(WaifuLbResult w)
|
||||
{
|
||||
var claimer = "no one";
|
||||
var status = string.Empty;
|
||||
|
||||
var waifuUsername = w.Username.TrimTo(20);
|
||||
var claimerUsername = w.Claimer?.TrimTo(20);
|
||||
|
||||
if (w.Claimer is not null)
|
||||
claimer = $"{claimerUsername}#{w.ClaimerDiscrim}";
|
||||
if (w.Affinity is null)
|
||||
status = $"... but {waifuUsername}'s heart is empty";
|
||||
else if (w.Affinity + w.AffinityDiscrim == w.Claimer + w.ClaimerDiscrim)
|
||||
status = $"... and {waifuUsername} likes {claimerUsername} too <3";
|
||||
else
|
||||
status = $"... but {waifuUsername}'s heart belongs to {w.Affinity.TrimTo(20)}#{w.AffinityDiscrim}";
|
||||
return $"**{waifuUsername}#{w.Discrim}** - claimed by **{claimer}**\n\t{status}";
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public Task WaifuInfo([Leftover] IUser target = null)
|
||||
{
|
||||
if (target is null)
|
||||
target = ctx.User;
|
||||
|
||||
return InternalWaifuInfo(target.Id, target.ToString());
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public Task WaifuInfo(ulong targetId)
|
||||
=> InternalWaifuInfo(targetId);
|
||||
|
||||
private async Task InternalWaifuInfo(ulong targetId, string name = null)
|
||||
{
|
||||
var wi = await _service.GetFullWaifuInfoAsync(targetId);
|
||||
var affInfo = _service.GetAffinityTitle(wi.AffinityCount);
|
||||
|
||||
var waifuItems = _service.GetWaifuItems().ToDictionary(x => x.ItemEmoji, x => x);
|
||||
|
||||
var nobody = GetText(strs.nobody);
|
||||
var itemList = await _service.GetItems(wi.WaifuId);
|
||||
var itemsStr = !itemList.Any()
|
||||
? "-"
|
||||
: string.Join("\n",
|
||||
itemList.Where(x => waifuItems.TryGetValue(x.ItemEmoji, out _))
|
||||
.OrderByDescending(x => waifuItems[x.ItemEmoji].Price)
|
||||
.GroupBy(x => x.ItemEmoji)
|
||||
.Take(60)
|
||||
.Select(x => $"{x.Key} x{x.Count(),-3}")
|
||||
.Chunk(2)
|
||||
.Select(x => string.Join(" ", x)));
|
||||
|
||||
var claimsNames = (await _service.GetClaimNames(wi.WaifuId));
|
||||
var claimsStr = claimsNames
|
||||
.Shuffle()
|
||||
.Take(30)
|
||||
.Join('\n');
|
||||
|
||||
var fansList = await _service.GetFansNames(wi.WaifuId);
|
||||
var fansStr = fansList
|
||||
.Shuffle()
|
||||
.Take(30)
|
||||
.Select((x) => claimsNames.Contains(x) ? $"{x} 💞" : x)
|
||||
.Join('\n');
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fansStr))
|
||||
fansStr = "-";
|
||||
|
||||
var embed = _eb.Create()
|
||||
.WithOkColor()
|
||||
.WithTitle(GetText(strs.waifu)
|
||||
+ " "
|
||||
+ (wi.FullName ?? name ?? targetId.ToString())
|
||||
+ " - \"the "
|
||||
+ _service.GetClaimTitle(wi.ClaimCount)
|
||||
+ "\"")
|
||||
.AddField(GetText(strs.price), N(wi.Price), true)
|
||||
.AddField(GetText(strs.claimed_by), wi.ClaimerName ?? nobody, true)
|
||||
.AddField(GetText(strs.likes), wi.AffinityName ?? nobody, true)
|
||||
.AddField(GetText(strs.changes_of_heart), $"{wi.AffinityCount} - \"the {affInfo}\"", true)
|
||||
.AddField(GetText(strs.divorces), wi.DivorceCount.ToString(), true)
|
||||
.AddField("\u200B", "\u200B", true)
|
||||
.AddField(GetText(strs.fans(fansList.Count)), fansStr, true)
|
||||
.AddField($"Waifus ({wi.ClaimCount})",
|
||||
wi.ClaimCount == 0 ? nobody : claimsStr,
|
||||
true)
|
||||
.AddField(GetText(strs.gifts), itemsStr, true);
|
||||
|
||||
await ctx.Channel.EmbedAsync(embed);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(1)]
|
||||
public async Task WaifuGift(int page = 1)
|
||||
{
|
||||
if (--page < 0 || page > (Config.Waifu.Items.Count - 1) / 9)
|
||||
return;
|
||||
|
||||
var waifuItems = _service.GetWaifuItems();
|
||||
await ctx.SendPaginatedConfirmAsync(page,
|
||||
cur =>
|
||||
{
|
||||
var embed = _eb.Create().WithTitle(GetText(strs.waifu_gift_shop)).WithOkColor();
|
||||
|
||||
waifuItems.OrderBy(x => x.Negative)
|
||||
.ThenBy(x => x.Price)
|
||||
.Skip(9 * cur)
|
||||
.Take(9)
|
||||
.ToList()
|
||||
.ForEach(x => embed.AddField(
|
||||
$"{(!x.Negative ? string.Empty : "\\💔")} {x.ItemEmoji} {x.Name}",
|
||||
Format.Bold(N(x.Price)),
|
||||
true));
|
||||
|
||||
return embed;
|
||||
},
|
||||
waifuItems.Count,
|
||||
9);
|
||||
}
|
||||
|
||||
[Cmd]
|
||||
[RequireContext(ContextType.Guild)]
|
||||
[Priority(0)]
|
||||
public async Task WaifuGift(string itemName, [Leftover] IUser waifu)
|
||||
{
|
||||
if (waifu.Id == ctx.User.Id)
|
||||
return;
|
||||
|
||||
var allItems = _service.GetWaifuItems();
|
||||
var item = allItems.FirstOrDefault(x => x.Name.ToLowerInvariant() == itemName.ToLowerInvariant());
|
||||
if (item is null)
|
||||
{
|
||||
await ReplyErrorLocalizedAsync(strs.waifu_gift_not_exist);
|
||||
return;
|
||||
}
|
||||
|
||||
var sucess = await _service.GiftWaifuAsync(ctx.User, waifu, item);
|
||||
|
||||
if (sucess)
|
||||
{
|
||||
await ReplyConfirmLocalizedAsync(strs.waifu_gift(Format.Bold(item + " " + item.ItemEmoji),
|
||||
Format.Bold(waifu.ToString())));
|
||||
}
|
||||
else
|
||||
await ReplyErrorLocalizedAsync(strs.not_enough(CurrencySign));
|
||||
}
|
||||
}
|
||||
}
|
||||
583
src/NadekoBot/Modules/Gambling/Waifus/WaifuService.cs
Normal file
583
src/NadekoBot/Modules/Gambling/Waifus/WaifuService.cs
Normal file
@@ -0,0 +1,583 @@
|
||||
#nullable disable
|
||||
using LinqToDB;
|
||||
using LinqToDB.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
using NadekoBot.Db;
|
||||
using NadekoBot.Db.Models;
|
||||
using NadekoBot.Modules.Gambling.Common;
|
||||
using NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
using Nadeko.Bot.Db.Models;
|
||||
|
||||
namespace NadekoBot.Modules.Gambling.Services;
|
||||
|
||||
public class WaifuService : INService, IReadyExecutor
|
||||
{
|
||||
private readonly DbService _db;
|
||||
private readonly ICurrencyService _cs;
|
||||
private readonly IBotCache _cache;
|
||||
private readonly GamblingConfigService _gss;
|
||||
private readonly IBotCredentials _creds;
|
||||
private readonly DiscordSocketClient _client;
|
||||
|
||||
public WaifuService(
|
||||
DbService db,
|
||||
ICurrencyService cs,
|
||||
IBotCache cache,
|
||||
GamblingConfigService gss,
|
||||
IBotCredentials creds,
|
||||
DiscordSocketClient client)
|
||||
{
|
||||
_db = db;
|
||||
_cs = cs;
|
||||
_cache = cache;
|
||||
_gss = gss;
|
||||
_creds = creds;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task<bool> WaifuTransfer(IUser owner, ulong waifuId, IUser newOwner)
|
||||
{
|
||||
if (owner.Id == newOwner.Id || waifuId == newOwner.Id)
|
||||
return false;
|
||||
|
||||
var settings = _gss.Data;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(waifuId);
|
||||
var ownerUser = uow.GetOrCreateUser(owner);
|
||||
|
||||
// owner has to be the owner of the waifu
|
||||
if (waifu is null || waifu.ClaimerId != ownerUser.Id)
|
||||
return false;
|
||||
|
||||
// if waifu likes the person, gotta pay the penalty
|
||||
if (waifu.AffinityId == ownerUser.Id)
|
||||
{
|
||||
if (!await _cs.RemoveAsync(owner.Id, (long)(waifu.Price * 0.6), new("waifu", "affinity-penalty")))
|
||||
// unable to pay 60% penalty
|
||||
return false;
|
||||
|
||||
waifu.Price = (long)(waifu.Price * 0.7); // half of 60% = 30% price reduction
|
||||
if (waifu.Price < settings.Waifu.MinPrice)
|
||||
waifu.Price = settings.Waifu.MinPrice;
|
||||
}
|
||||
else // if not, pay 10% fee
|
||||
{
|
||||
if (!await _cs.RemoveAsync(owner.Id, waifu.Price / 10, new("waifu", "transfer")))
|
||||
return false;
|
||||
|
||||
waifu.Price = (long)(waifu.Price * 0.95); // half of 10% = 5% price reduction
|
||||
if (waifu.Price < settings.Waifu.MinPrice)
|
||||
waifu.Price = settings.Waifu.MinPrice;
|
||||
}
|
||||
|
||||
//new claimerId is the id of the new owner
|
||||
var newOwnerUser = uow.GetOrCreateUser(newOwner);
|
||||
waifu.ClaimerId = newOwnerUser.Id;
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public long GetResetPrice(IUser user)
|
||||
{
|
||||
var settings = _gss.Data;
|
||||
using var uow = _db.GetDbContext();
|
||||
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||
|
||||
if (waifu is null)
|
||||
return settings.Waifu.MinPrice;
|
||||
|
||||
var divorces = uow.Set<WaifuUpdate>().Count(x
|
||||
=> x.Old != null && x.Old.UserId == user.Id && x.UpdateType == WaifuUpdateType.Claimed && x.New == null);
|
||||
var affs = uow.Set<WaifuUpdate>().AsQueryable()
|
||||
.Where(w => w.User.UserId == user.Id
|
||||
&& w.UpdateType == WaifuUpdateType.AffinityChanged
|
||||
&& w.New != null)
|
||||
.ToList()
|
||||
.GroupBy(x => x.New)
|
||||
.Count();
|
||||
|
||||
return (long)Math.Ceiling(waifu.Price * 1.25f)
|
||||
+ ((divorces + affs + 2) * settings.Waifu.Multipliers.WaifuReset);
|
||||
}
|
||||
|
||||
public async Task<bool> TryReset(IUser user)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var price = GetResetPrice(user);
|
||||
if (!await _cs.RemoveAsync(user.Id, price, new("waifu", "reset")))
|
||||
return false;
|
||||
|
||||
var affs = uow.Set<WaifuUpdate>().AsQueryable()
|
||||
.Where(w => w.User.UserId == user.Id
|
||||
&& w.UpdateType == WaifuUpdateType.AffinityChanged
|
||||
&& w.New != null);
|
||||
|
||||
var divorces = uow.Set<WaifuUpdate>().AsQueryable()
|
||||
.Where(x => x.Old != null
|
||||
&& x.Old.UserId == user.Id
|
||||
&& x.UpdateType == WaifuUpdateType.Claimed
|
||||
&& x.New == null);
|
||||
|
||||
//reset changes of heart to 0
|
||||
uow.Set<WaifuUpdate>().RemoveRange(affs);
|
||||
//reset divorces to 0
|
||||
uow.Set<WaifuUpdate>().RemoveRange(divorces);
|
||||
var waifu = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||
//reset price, remove items
|
||||
//remove owner, remove affinity
|
||||
waifu.Price = 50;
|
||||
waifu.Items.Clear();
|
||||
waifu.ClaimerId = null;
|
||||
waifu.AffinityId = null;
|
||||
|
||||
//wives stay though
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<(WaifuInfo, bool, WaifuClaimResult)> ClaimWaifuAsync(IUser user, IUser target, long amount)
|
||||
{
|
||||
var settings = _gss.Data;
|
||||
WaifuClaimResult result;
|
||||
WaifuInfo w;
|
||||
bool isAffinity;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
w = uow.Set<WaifuInfo>().ByWaifuUserId(target.Id);
|
||||
isAffinity = w?.Affinity?.UserId == user.Id;
|
||||
if (w is null)
|
||||
{
|
||||
var claimer = uow.GetOrCreateUser(user);
|
||||
var waifu = uow.GetOrCreateUser(target);
|
||||
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||
result = WaifuClaimResult.NotEnoughFunds;
|
||||
else
|
||||
{
|
||||
uow.Set<WaifuInfo>().Add(w = new()
|
||||
{
|
||||
Waifu = waifu,
|
||||
Claimer = claimer,
|
||||
Affinity = null,
|
||||
Price = amount
|
||||
});
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = waifu,
|
||||
Old = null,
|
||||
New = claimer,
|
||||
UpdateType = WaifuUpdateType.Claimed
|
||||
});
|
||||
result = WaifuClaimResult.Success;
|
||||
}
|
||||
}
|
||||
else if (isAffinity && amount > w.Price * settings.Waifu.Multipliers.CrushClaim)
|
||||
{
|
||||
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||
result = WaifuClaimResult.NotEnoughFunds;
|
||||
else
|
||||
{
|
||||
var oldClaimer = w.Claimer;
|
||||
w.Claimer = uow.GetOrCreateUser(user);
|
||||
w.Price = amount + (amount / 4);
|
||||
result = WaifuClaimResult.Success;
|
||||
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = w.Waifu,
|
||||
Old = oldClaimer,
|
||||
New = w.Claimer,
|
||||
UpdateType = WaifuUpdateType.Claimed
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (amount >= w.Price * settings.Waifu.Multipliers.NormalClaim) // if no affinity
|
||||
{
|
||||
if (!await _cs.RemoveAsync(user.Id, amount, new("waifu", "claim")))
|
||||
result = WaifuClaimResult.NotEnoughFunds;
|
||||
else
|
||||
{
|
||||
var oldClaimer = w.Claimer;
|
||||
w.Claimer = uow.GetOrCreateUser(user);
|
||||
w.Price = amount;
|
||||
result = WaifuClaimResult.Success;
|
||||
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = w.Waifu,
|
||||
Old = oldClaimer,
|
||||
New = w.Claimer,
|
||||
UpdateType = WaifuUpdateType.Claimed
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
result = WaifuClaimResult.InsufficientAmount;
|
||||
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return (w, isAffinity, result);
|
||||
}
|
||||
|
||||
public async Task<(DiscordUser, bool, TimeSpan?)> ChangeAffinityAsync(IUser user, IGuildUser target)
|
||||
{
|
||||
DiscordUser oldAff = null;
|
||||
var success = false;
|
||||
TimeSpan? remaining = null;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
var w = uow.Set<WaifuInfo>().ByWaifuUserId(user.Id);
|
||||
var newAff = target is null ? null : uow.GetOrCreateUser(target);
|
||||
if (w?.Affinity?.UserId == target?.Id)
|
||||
{
|
||||
return (null, false, null);
|
||||
}
|
||||
|
||||
remaining = await _cache.GetRatelimitAsync(GetAffinityKey(user.Id),
|
||||
30.Minutes());
|
||||
|
||||
if (remaining is not null)
|
||||
{
|
||||
}
|
||||
else if (w is null)
|
||||
{
|
||||
var thisUser = uow.GetOrCreateUser(user);
|
||||
uow.Set<WaifuInfo>().Add(new()
|
||||
{
|
||||
Affinity = newAff,
|
||||
Waifu = thisUser,
|
||||
Price = 1,
|
||||
Claimer = null
|
||||
});
|
||||
success = true;
|
||||
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = thisUser,
|
||||
Old = null,
|
||||
New = newAff,
|
||||
UpdateType = WaifuUpdateType.AffinityChanged
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (w.Affinity is not null)
|
||||
oldAff = w.Affinity;
|
||||
w.Affinity = newAff;
|
||||
success = true;
|
||||
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = w.Waifu,
|
||||
Old = oldAff,
|
||||
New = newAff,
|
||||
UpdateType = WaifuUpdateType.AffinityChanged
|
||||
});
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return (oldAff, success, remaining);
|
||||
}
|
||||
|
||||
public IEnumerable<WaifuLbResult> GetTopWaifusAtPage(int page, int perPage = 9)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<WaifuInfo>().GetTop(perPage, page * perPage);
|
||||
}
|
||||
|
||||
public ulong GetWaifuUserId(ulong ownerId, string name)
|
||||
{
|
||||
using var uow = _db.GetDbContext();
|
||||
return uow.Set<WaifuInfo>().GetWaifuUserId(ownerId, name);
|
||||
}
|
||||
|
||||
private static TypedKey<long> GetDivorceKey(ulong userId)
|
||||
=> new($"waifu:divorce_cd:{userId}");
|
||||
|
||||
private static TypedKey<long> GetAffinityKey(ulong userId)
|
||||
=> new($"waifu:affinity:{userId}");
|
||||
|
||||
public async Task<(WaifuInfo, DivorceResult, long, TimeSpan?)> DivorceWaifuAsync(IUser user, ulong targetId)
|
||||
{
|
||||
DivorceResult result;
|
||||
TimeSpan? remaining = null;
|
||||
long amount = 0;
|
||||
WaifuInfo w;
|
||||
await using (var uow = _db.GetDbContext())
|
||||
{
|
||||
w = uow.Set<WaifuInfo>().ByWaifuUserId(targetId);
|
||||
if (w?.Claimer is null || w.Claimer.UserId != user.Id)
|
||||
result = DivorceResult.NotYourWife;
|
||||
else
|
||||
{
|
||||
remaining = await _cache.GetRatelimitAsync(GetDivorceKey(user.Id), 6.Hours());
|
||||
if (remaining is TimeSpan rem)
|
||||
{
|
||||
result = DivorceResult.Cooldown;
|
||||
return (w, result, amount, rem);
|
||||
}
|
||||
|
||||
amount = w.Price / 2;
|
||||
|
||||
if (w.Affinity?.UserId == user.Id)
|
||||
{
|
||||
await _cs.AddAsync(w.Waifu.UserId, amount, new("waifu", "compensation"));
|
||||
w.Price = (long)Math.Floor(w.Price * _gss.Data.Waifu.Multipliers.DivorceNewValue);
|
||||
result = DivorceResult.SucessWithPenalty;
|
||||
}
|
||||
else
|
||||
{
|
||||
await _cs.AddAsync(user.Id, amount, new("waifu", "refund"));
|
||||
|
||||
result = DivorceResult.Success;
|
||||
}
|
||||
|
||||
var oldClaimer = w.Claimer;
|
||||
w.Claimer = null;
|
||||
|
||||
uow.Set<WaifuUpdate>().Add(new()
|
||||
{
|
||||
User = w.Waifu,
|
||||
Old = oldClaimer,
|
||||
New = null,
|
||||
UpdateType = WaifuUpdateType.Claimed
|
||||
});
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return (w, result, amount, remaining);
|
||||
}
|
||||
|
||||
public async Task<bool> GiftWaifuAsync(IUser from, IUser giftedWaifu, WaifuItemModel itemObj)
|
||||
{
|
||||
if (!await _cs.RemoveAsync(from, itemObj.Price, new("waifu", "item")))
|
||||
return false;
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
var w = uow.Set<WaifuInfo>().ByWaifuUserId(giftedWaifu.Id, set => set.Include(x => x.Items).Include(x => x.Claimer));
|
||||
if (w is null)
|
||||
{
|
||||
uow.Set<WaifuInfo>().Add(w = new()
|
||||
{
|
||||
Affinity = null,
|
||||
Claimer = null,
|
||||
Price = 1,
|
||||
Waifu = uow.GetOrCreateUser(giftedWaifu)
|
||||
});
|
||||
}
|
||||
|
||||
if (!itemObj.Negative)
|
||||
{
|
||||
w.Items.Add(new()
|
||||
{
|
||||
Name = itemObj.Name.ToLowerInvariant(),
|
||||
ItemEmoji = itemObj.ItemEmoji
|
||||
});
|
||||
|
||||
if (w.Claimer?.UserId == from.Id)
|
||||
w.Price += (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.GiftEffect);
|
||||
else
|
||||
w.Price += itemObj.Price / 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
w.Price -= (long)(itemObj.Price * _gss.Data.Waifu.Multipliers.NegativeGiftEffect);
|
||||
if (w.Price < 1)
|
||||
w.Price = 1;
|
||||
}
|
||||
|
||||
await uow.SaveChangesAsync();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<WaifuInfoStats> GetFullWaifuInfoAsync(ulong targetId)
|
||||
{
|
||||
await using var uow = _db.GetDbContext();
|
||||
var wi = await uow.GetWaifuInfoAsync(targetId);
|
||||
if (wi is null)
|
||||
{
|
||||
wi = new()
|
||||
{
|
||||
AffinityCount = 0,
|
||||
AffinityName = null,
|
||||
ClaimCount = 0,
|
||||
ClaimerName = null,
|
||||
DivorceCount = 0,
|
||||
FullName = null,
|
||||
Price = 1
|
||||
};
|
||||
}
|
||||
|
||||
return wi;
|
||||
}
|
||||
|
||||
public string GetClaimTitle(int count)
|
||||
{
|
||||
ClaimTitle title;
|
||||
if (count == 0)
|
||||
title = ClaimTitle.Lonely;
|
||||
else if (count == 1)
|
||||
title = ClaimTitle.Devoted;
|
||||
else if (count < 3)
|
||||
title = ClaimTitle.Rookie;
|
||||
else if (count < 6)
|
||||
title = ClaimTitle.Schemer;
|
||||
else if (count < 10)
|
||||
title = ClaimTitle.Dilettante;
|
||||
else if (count < 17)
|
||||
title = ClaimTitle.Intermediate;
|
||||
else if (count < 25)
|
||||
title = ClaimTitle.Seducer;
|
||||
else if (count < 35)
|
||||
title = ClaimTitle.Expert;
|
||||
else if (count < 50)
|
||||
title = ClaimTitle.Veteran;
|
||||
else if (count < 75)
|
||||
title = ClaimTitle.Incubis;
|
||||
else if (count < 100)
|
||||
title = ClaimTitle.Harem_King;
|
||||
else
|
||||
title = ClaimTitle.Harem_God;
|
||||
|
||||
return title.ToString().Replace('_', ' ');
|
||||
}
|
||||
|
||||
public string GetAffinityTitle(int count)
|
||||
{
|
||||
AffinityTitle title;
|
||||
if (count < 1)
|
||||
title = AffinityTitle.Pure;
|
||||
else if (count < 2)
|
||||
title = AffinityTitle.Faithful;
|
||||
else if (count < 4)
|
||||
title = AffinityTitle.Playful;
|
||||
else if (count < 8)
|
||||
title = AffinityTitle.Cheater;
|
||||
else if (count < 11)
|
||||
title = AffinityTitle.Tainted;
|
||||
else if (count < 15)
|
||||
title = AffinityTitle.Corrupted;
|
||||
else if (count < 20)
|
||||
title = AffinityTitle.Lewd;
|
||||
else if (count < 25)
|
||||
title = AffinityTitle.Sloot;
|
||||
else if (count < 35)
|
||||
title = AffinityTitle.Depraved;
|
||||
else
|
||||
title = AffinityTitle.Harlot;
|
||||
|
||||
return title.ToString().Replace('_', ' ');
|
||||
}
|
||||
|
||||
public IReadOnlyList<WaifuItemModel> GetWaifuItems()
|
||||
{
|
||||
var conf = _gss.Data;
|
||||
return conf.Waifu.Items.Select(x
|
||||
=> new WaifuItemModel(x.ItemEmoji,
|
||||
(long)(x.Price * conf.Waifu.Multipliers.AllGiftPrices),
|
||||
x.Name,
|
||||
x.Negative))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static readonly TypedKey<long> _waifuDecayKey = $"waifu:last_decay";
|
||||
public async Task OnReadyAsync()
|
||||
{
|
||||
// only decay waifu values from shard 0
|
||||
if (_client.ShardId != 0)
|
||||
return;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var multi = _gss.Data.Waifu.Decay.Percent / 100f;
|
||||
var minPrice = _gss.Data.Waifu.Decay.MinPrice;
|
||||
var decayInterval = _gss.Data.Waifu.Decay.HourInterval;
|
||||
|
||||
if (multi is < 0f or > 1f || decayInterval < 0)
|
||||
continue;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nowB = now.ToBinary();
|
||||
|
||||
var result = await _cache.GetAsync(_waifuDecayKey);
|
||||
|
||||
if (result.TryGetValue(out var val))
|
||||
{
|
||||
var lastDecay = DateTime.FromBinary(val);
|
||||
var toWait = decayInterval.Hours() - (DateTime.UtcNow - lastDecay);
|
||||
|
||||
if (toWait > 0.Hours())
|
||||
continue;
|
||||
}
|
||||
|
||||
await _cache.AddAsync(_waifuDecayKey, nowB);
|
||||
|
||||
await using var uow = _db.GetDbContext();
|
||||
|
||||
await uow.GetTable<WaifuInfo>()
|
||||
.Where(x => x.Price > minPrice && x.ClaimerId == null)
|
||||
.UpdateAsync(old => new()
|
||||
{
|
||||
Price = (long)(old.Price * multi)
|
||||
});
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unexpected error occured in waifu decay loop: {ErrorMessage}", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await Task.Delay(1.Hours());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<string>> GetClaimNames(int waifuId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<DiscordUser>()
|
||||
.Where(x => ctx.GetTable<WaifuInfo>()
|
||||
.Where(wi => wi.ClaimerId == waifuId)
|
||||
.Select(wi => wi.WaifuId)
|
||||
.Contains(x.Id))
|
||||
.Select(x => $"{x.Username}#{x.Discriminator}")
|
||||
.ToListAsyncEF();
|
||||
}
|
||||
public async Task<IReadOnlyCollection<string>> GetFansNames(int waifuId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<DiscordUser>()
|
||||
.Where(x => ctx.GetTable<WaifuInfo>()
|
||||
.Where(wi => wi.AffinityId == waifuId)
|
||||
.Select(wi => wi.WaifuId)
|
||||
.Contains(x.Id))
|
||||
.Select(x => $"{x.Username}#{x.Discriminator}")
|
||||
.ToListAsyncEF();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyCollection<WaifuItem>> GetItems(int waifuId)
|
||||
{
|
||||
await using var ctx = _db.GetDbContext();
|
||||
return await ctx.GetTable<WaifuItem>()
|
||||
.Where(x => x.WaifuInfoId == ctx.GetTable<WaifuInfo>()
|
||||
.Where(x => x.WaifuId == waifuId)
|
||||
.Select(x => x.Id)
|
||||
.FirstOrDefault())
|
||||
.ToListAsyncEF();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
|
||||
public enum AffinityTitle
|
||||
{
|
||||
Pure,
|
||||
Faithful,
|
||||
Playful,
|
||||
Cheater,
|
||||
Tainted,
|
||||
Corrupted,
|
||||
Lewd,
|
||||
Sloot,
|
||||
Depraved,
|
||||
Harlot
|
||||
}
|
||||
18
src/NadekoBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
Normal file
18
src/NadekoBot/Modules/Gambling/Waifus/_common/ClaimTitle.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
|
||||
public enum ClaimTitle
|
||||
{
|
||||
Lonely,
|
||||
Devoted,
|
||||
Rookie,
|
||||
Schemer,
|
||||
Dilettante,
|
||||
Intermediate,
|
||||
Seducer,
|
||||
Expert,
|
||||
Veteran,
|
||||
Incubis,
|
||||
Harem_King,
|
||||
Harem_God
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
|
||||
public enum DivorceResult
|
||||
{
|
||||
Success,
|
||||
SucessWithPenalty,
|
||||
NotYourWife,
|
||||
Cooldown
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
|
||||
public class Extensions
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Modules.Gambling.Common.Waifu;
|
||||
|
||||
public enum WaifuClaimResult
|
||||
{
|
||||
Success,
|
||||
NotEnoughFunds,
|
||||
InsufficientAmount
|
||||
}
|
||||
19
src/NadekoBot/Modules/Gambling/Waifus/db/Waifu.cs
Normal file
19
src/NadekoBot/Modules/Gambling/Waifus/db/Waifu.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
#nullable disable
|
||||
using NadekoBot.Db.Models;
|
||||
|
||||
namespace Nadeko.Bot.Db.Models;
|
||||
|
||||
public class WaifuInfo : DbEntity
|
||||
{
|
||||
public int WaifuId { get; set; }
|
||||
public DiscordUser Waifu { get; set; }
|
||||
|
||||
public int? ClaimerId { get; set; }
|
||||
public DiscordUser Claimer { get; set; }
|
||||
|
||||
public int? AffinityId { get; set; }
|
||||
public DiscordUser Affinity { get; set; }
|
||||
|
||||
public long Price { get; set; }
|
||||
public List<WaifuItem> Items { get; set; } = new();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user