Compare commits

..

23 Commits

Author SHA1 Message Date
Kwoth
e49e3eec69 Version upped to 4.3.10, CHANGELOG.md updated 2022-11-10 22:42:34 +01:00
Kwoth
3992ae392b Fixed nullref in xploop 2022-11-10 22:32:27 +01:00
Kwoth
8f0c5fab47 Fixed nullref in cmdcd 2022-11-08 07:51:29 +01:00
Kwoth
780a260b88 If cleverbot is disabled through permissions, let other things execute 2022-11-08 07:51:20 +01:00
Kwoth
25692b9585 Fixed .feedadd for real 2022-11-04 17:21:05 +01:00
Kwoth
ed3ce52865 Fixed .feedadd, closes #388 2022-11-04 05:08:22 +01:00
Kwoth
f5f0f1e250 Fixed .prune @Target not working bug 2022-10-25 02:07:05 +02:00
Kwoth
9d9e61fdfb Fixed command cooldown calculation. Closes #387 2022-10-25 01:57:58 +02:00
Kwoth
e68e948a80 Aliases now support %target% placeholder. For example 'alias .bft .bf %target% t' 2022-10-22 23:33:42 +02:00
Kwoth
cb98f4aa15 Added .exprtoggleglobal / .extg which can be used to toggle usage of global expressions on the server 2022-10-21 21:31:59 +02:00
Kwoth
bfec0cbcbf OwnerId will be autofilled in creds.yml if it's missing at startup 2022-10-19 23:43:44 +02:00
Kwoth
3e1268f3bb You can now specify time+date (time is optional) in remind instead of relative time, in the format HH:mm dd.MM.YYYY 2022-10-18 23:31:34 +02:00
Kwoth
c28f458972 Updated help text for .antispam and .antiraid 2022-10-18 01:45:00 +02:00
Kwoth
27ac948463 Added .forwardtochannel which will forward messages to the current channel. It has lower priority than fwtoall 2022-10-17 22:20:48 +02:00
Kwoth
3f9a3c4c18 Merge branch 'hokutochen-v4-patch-12507' into 'v4'
upped medusa version and updated brew command

See merge request Kwoth/nadekobot!271
2022-10-17 11:11:37 +00:00
Hokuto Chen
9a5545a951 docs: upped medusa version and updated brew command 2022-10-17 11:11:37 +00:00
Kwoth
584193db18 Added .filterlist command 2022-10-16 22:57:36 +02:00
Kwoth
1a132fd234 Added a missing return 2022-10-15 13:17:30 +02:00
Kwoth
fd6a51ac82 Added appropriate error messages for every club related command. Removed obsolete error messages. 2022-10-15 13:13:31 +02:00
Kwoth
eb1fabb2b7 Prepared several more enums to clarify club related action results 2022-10-14 23:02:43 +02:00
Kwoth
d079e684bd Clubleave errors clarified. Moved enum results to their own files 2022-10-13 22:27:12 +02:00
Kwoth
bf817a1436 Medusa modules (sneks) should now inherit medusa description when listed in .mdls command 2022-10-12 23:29:48 +02:00
Kwoth
78f1624aaf .meload and .meunload are now case sensitive. Previously loaded medusae may need to be reloaded or data/medusae/medusa.yml may need to be edited manually 2022-10-12 20:59:04 +02:00
57 changed files with 11012 additions and 272 deletions

View File

@@ -2,6 +2,33 @@
Experimental changelog. Mostly based on [keepachangelog](https://keepachangelog.com/en/1.0.0/) except date format. a-c-f-r-o
## [4.3.10] - 10.11.2022
### Added
- `.filterlist` / `.fl` command which lists link and invite filtering channels and status
- Added support for `%target%` placeholder in `.alias` command
- Added .forwardtochannel which will forward messages to the current channel. It has lower priority than fwtoall
- Added .exprtoggleglobal / .extg which can be used to toggle usage of global expressions on the server
### Changed
- .meload and .meunload are now case sensitive. Previously loaded medusae may need to be reloaded or data/medusae/medusa.yml may need to be edited manually
- Several club related command have their error messages improved
- Updated help text for .antispam and .antiraid
- You can now specify time and date (time is optional) in `.remind` command instead of relative time, in the format `HH:mm dd.MM.YYYY`
- OwnerId will be automatically added to `creds.yml` at bot startup if it's missing
### Fixed
- Fixed `.cmdcd` console error
- Fixed an error when currency is add per xp
- Fixed an issue preventing execution of expressions starting with @Bot when cleverbot is enabled on the server
- Fixed `.feedadd`
- Fixed `.prune @target` not working
- Medusa modules (sneks) should now inherit medusa description when listed in .mdls command
- Fixed command cooldown calculation
## [4.3.9] - 12.10.2022
### Added

View File

@@ -7,7 +7,7 @@ Open Terminal (if you don't know how to, click on the magnifying glass on the to
###### Homebrew/wget
*Skip this step if you already have homebrew installed*
- Copy and paste this command, then press Enter:
- `/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"`
- `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`
- Install wget
- `brew install wget`

View File

@@ -155,7 +155,7 @@ This section will guide you through how to create a simple custom medusa. You ca
<ItemGroup>
<!-- Base medusa package. You MUST reference this in order to have a working medusa -->
<!-- Also, this package comes from MyGet, which requires you to have a NuGet.Config file next to your .csproj -->
<PackageReference Include="Nadeko.Medusa" Version="1.0.1">
<PackageReference Include="Nadeko.Medusa" Version="4.3.9">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -248,4 +248,4 @@ hello:
- Unload it
- `.meunload example_medusa`
- Congrats! You've just made your first medusa!
- Congrats! You've just made your first medusa!

View File

@@ -313,10 +313,29 @@ public sealed class Bot
await _commandService.AddModulesAsync(typeof(Bot).Assembly, Services);
// await _interactionService.AddModulesAsync(typeof(Bot).Assembly, Services);
IsReady = true;
await EnsureBotOwnershipAsync();
_ = Task.Run(ExecuteReadySubscriptions);
Log.Information("Shard {ShardId} ready", Client.ShardId);
}
private async ValueTask EnsureBotOwnershipAsync()
{
try
{
if (_creds.OwnerIds.Count != 0)
return;
Log.Information("Initializing Owner Id...");
var info = await Client.GetApplicationInfoAsync();
_credsProvider.ModifyCredsFile(x => x.OwnerIds = new[] { info.Owner.Id });
}
catch (Exception ex)
{
Log.Warning("Getting application info failed: {ErrorMessage}", ex.Message);
}
}
private Task ExecuteReadySubscriptions()
{
var readyExecutors = Services.GetServices<IReadyExecutor>();

View File

@@ -12,7 +12,7 @@ namespace NadekoBot.Common.Configs;
public sealed partial class BotConfig : ICloneable<BotConfig>
{
[Comment(@"DO NOT CHANGE")]
public int Version { get; set; } = 4;
public int Version { get; set; } = 5;
[Comment(@"Most commands, when executed, have a small colored line
next to the response. The color depends whether the command
@@ -39,6 +39,10 @@ Allowed values: Simple, Normal, None")]
@"Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml),
or all owners? (this might cause the bot to lag if there's a lot of owners specified)")]
public bool ForwardToAllOwners { get; set; }
[Comment(@"Any messages sent by users in Bot's DM to be forwarded to the specified channel.
This option will only work when ForwardToAllOwners is set to false")]
public ulong? ForwardToChannel { get; set; }
[Comment(@"When a user DMs the bot with a message which is not a command
they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot.

View File

@@ -186,7 +186,6 @@ public sealed class MedusaLoaderService : IMedusaLoaderService, IReadyExecutor,
return MedusaLoadResult.AlreadyLoaded;
var safeName = Uri.EscapeDataString(name);
name = name.ToLowerInvariant();
await _lock.WaitAsync();
try
@@ -525,7 +524,6 @@ public sealed class MedusaLoaderService : IMedusaLoaderService, IReadyExecutor,
[MethodImpl(MethodImplOptions.NoInlining)]
private async Task<MedusaUnloadResult> InternalUnloadAsync(string name)
{
name = name.ToLowerInvariant();
if (!_resolved.Remove(name, out var lsi))
return MedusaUnloadResult.NotLoaded;

View File

@@ -1,15 +1,14 @@
#nullable disable
using CommandLine;
using CommandLine;
namespace NadekoBot.Common;
public static class OptionsParser
{
public static T ParseFrom<T>(string[] args)
public static T ParseFrom<T>(string[]? args)
where T : INadekoCommandOptions, new()
=> ParseFrom(new T(), args).Item1;
public static (T, bool) ParseFrom<T>(T options, string[] args)
public static (T, bool) ParseFrom<T>(T options, string[]? args)
where T : INadekoCommandOptions
{
using var p = new Parser(x =>

View File

@@ -95,6 +95,8 @@ public class GuildConfig : DbEntity
public int WarnExpireHours { get; set; }
public WarnExpireAction WarnExpireAction { get; set; } = WarnExpireAction.Clear;
public bool DisableGlobalExpressions { get; set; } = false;
#region Boost Message
public bool SendBoostMessage { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations.Mysql
{
public partial class toggleglobalexpressions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "disableglobalexpressions",
table: "guildconfigs",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "disableglobalexpressions",
table: "guildconfigs");
}
}
}

View File

@@ -1258,6 +1258,10 @@ namespace NadekoBot.Migrations.Mysql
.HasColumnType("tinyint(1)")
.HasColumnName("deletestreamonlinemessage");
b.Property<bool>("DisableGlobalExpressions")
.HasColumnType("tinyint(1)")
.HasColumnName("disableglobalexpressions");
b.Property<string>("DmGreetMessageText")
.HasColumnType("longtext")
.HasColumnName("dmgreetmessagetext");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations.PostgreSql
{
public partial class toggleglobalexpressions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "disableglobalexpressions",
table: "guildconfigs",
type: "boolean",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "disableglobalexpressions",
table: "guildconfigs");
}
}
}

View File

@@ -1322,6 +1322,10 @@ namespace NadekoBot.Migrations.PostgreSql
.HasColumnType("boolean")
.HasColumnName("deletestreamonlinemessage");
b.Property<bool>("DisableGlobalExpressions")
.HasColumnType("boolean")
.HasColumnName("disableglobalexpressions");
b.Property<string>("DmGreetMessageText")
.HasColumnType("text")
.HasColumnName("dmgreetmessagetext");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NadekoBot.Migrations
{
public partial class toggleglobalexpressions : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DisableGlobalExpressions",
table: "GuildConfigs",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DisableGlobalExpressions",
table: "GuildConfigs");
}
}
}

View File

@@ -986,6 +986,9 @@ namespace NadekoBot.Migrations
b.Property<bool>("DeleteStreamOnlineMessage")
.HasColumnType("INTEGER");
b.Property<bool>("DisableGlobalExpressions")
.HasColumnType("INTEGER");
b.Property<string>("DmGreetMessageText")
.HasColumnType("TEXT");

View File

@@ -72,7 +72,7 @@ public partial class Administration
[BotPerm(ChannelPerm.ManageMessages)]
[NadekoOptions(typeof(PruneOptions))]
[Priority(0)]
public Task Prune(IGuildUser user, int count = 100, string args = null)
public Task Prune(IGuildUser user, int count = 100, params string[] args)
=> Prune(user.Id, count, args);
//prune userid [x]

View File

@@ -230,6 +230,19 @@ public partial class Administration
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)
{

View File

@@ -85,12 +85,12 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
await using var uow = _db.GetDbContext();
autoCommands = uow.AutoCommands.AsNoTracking()
.Where(x => x.Interval >= 5)
.AsEnumerable()
.GroupBy(x => x.GuildId)
.ToDictionary(x => x.Key,
y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent())
.ToConcurrent();
.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.AutoCommands.AsNoTracking().Where(x => x.Interval == 0);
foreach (var cmd in startupCommands)
@@ -169,18 +169,18 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
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);
{
var user = _client.GetUser(id);
if (user is null)
return Task.FromResult<IDMChannel>(null);
return user.CreateDMChannelAsync();
})
.WhenAll();
return user.CreateDMChannelAsync();
})
.WhenAll();
ownerChannels = channels.Where(x => x is not null)
.ToDictionary(x => x.Recipient.Id, x => x)
.ToImmutableDictionary();
.ToDictionary(x => x.Recipient.Id, x => x)
.ToImmutableDictionary();
if (!ownerChannels.Any())
{
@@ -202,7 +202,7 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
var bs = _bss.Data;
if (msg.Channel is IDMChannel && bs.ForwardMessages && ownerChannels.Any())
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})";
@@ -232,6 +232,18 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
}
}
}
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();
@@ -333,6 +345,20 @@ public sealed class SelfService : IExecNoCommand, IReadyExecutor, INService
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 =>

View File

@@ -41,6 +41,17 @@ public partial class NadekoExpressions : NadekoModule<NadekoExpressionsService>
message.Length > 1024 ? GetText(strs.redacted_too_long) : message));
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
public async Task ExprToggleGlobal()
{
var result = await _service.ToggleGlobalExpressionsAsync(ctx.Guild.Id);
if (result)
await ReplyConfirmLocalizedAsync(strs.expr_global_disabled);
else
await ReplyConfirmLocalizedAsync(strs.expr_global_enabled);
}
[Cmd]
[UserPerm(GuildPerm.Administrator)]
public async Task ExprAddServer(string key, [Leftover] string message)

View File

@@ -7,6 +7,7 @@ using NadekoBot.Modules.Permissions.Common;
using NadekoBot.Modules.Permissions.Services;
using NadekoBot.Services.Database.Models;
using System.Runtime.CompilerServices;
using LinqToDB.EntityFrameworkCore;
using Nadeko.Common;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
@@ -56,8 +57,8 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
// 1. expressions are almost never added (compared to how many times they are being looped through)
// 2. only need write locks for this as we'll rebuild+replace the array on every edit
// 3. there's never many of them (at most a thousand, usually < 100)
private NadekoExpression[] globalReactions;
private ConcurrentDictionary<ulong, NadekoExpression[]> newGuildReactions;
private NadekoExpression[] globalExpressions;
private ConcurrentDictionary<ulong, NadekoExpression[]> newguildExpressions;
private readonly DbService _db;
private readonly DiscordSocketClient _client;
@@ -72,6 +73,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
private readonly Random _rng;
private bool ready;
private ConcurrentHashSet<ulong> _disabledGlobalExpressionGuilds;
public NadekoExpressionsService(
PermissionService perms,
@@ -113,7 +115,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
.Where(x => allGuildIds.Contains(x.GuildId.Value))
.ToListAsync();
newGuildReactions = guildItems.GroupBy(k => k.GuildId!.Value)
newguildExpressions = guildItems.GroupBy(k => k.GuildId!.Value)
.ToDictionary(g => g.Key,
g => g.Select(x =>
{
@@ -123,6 +125,11 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
.ToArray())
.ToConcurrent();
_disabledGlobalExpressionGuilds = new (await uow.GuildConfigs
.Where(x => x.DisableGlobalExpressions)
.Select(x => x.GuildId)
.ToListAsyncLinqToDB());
lock (_gexprWriteLock)
{
var globalItems = uow.Expressions.AsNoTracking()
@@ -135,7 +142,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
})
.ToArray();
globalReactions = globalItems;
globalExpressions = globalItems;
}
ready = true;
@@ -151,14 +158,17 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
var content = umsg.Content.Trim().ToLowerInvariant();
if (newGuildReactions.TryGetValue(channel.Guild.Id, out var reactions) && reactions.Length > 0)
if (newguildExpressions.TryGetValue(channel.Guild.Id, out var expressions) && expressions.Length > 0)
{
var expr = MatchExpressions(content, reactions);
var expr = MatchExpressions(content, expressions);
if (expr is not null)
return expr;
}
var localGrs = globalReactions;
if (_disabledGlobalExpressionGuilds.Contains(channel.Guild.Id))
return null;
var localGrs = globalExpressions;
return MatchExpressions(content, localGrs);
}
@@ -345,7 +355,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
if (maybeGuildId is { } guildId)
{
newGuildReactions.AddOrUpdate(guildId,
newguildExpressions.AddOrUpdate(guildId,
new[] { expr },
(_, old) =>
{
@@ -363,7 +373,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
lock (_gexprWriteLock)
{
var exprs = globalReactions;
var exprs = globalExpressions;
for (var i = 0; i < exprs.Length; i++)
{
if (exprs[i].Id == expr.Id)
@@ -379,7 +389,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
expr.Trigger = expr.Trigger.Replace(MENTION_PH, _client.CurrentUser.Mention);
if (maybeGuildId is { } guildId)
newGuildReactions.AddOrUpdate(guildId, new[] { expr }, (_, old) => old.With(expr));
newguildExpressions.AddOrUpdate(guildId, new[] { expr }, (_, old) => old.With(expr));
else
return _pubSub.Pub(_gexprAddedKey, expr);
@@ -390,7 +400,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
if (maybeGuildId is { } guildId)
{
newGuildReactions.AddOrUpdate(guildId,
newguildExpressions.AddOrUpdate(guildId,
Array.Empty<NadekoExpression>(),
(key, old) => DeleteInternal(old, id, out _));
@@ -399,7 +409,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
lock (_gexprWriteLock)
{
var expr = Array.Find(globalReactions, item => item.Id == id);
var expr = Array.Find(globalExpressions, item => item.Id == id);
if (expr is not null)
return _pubSub.Pub(_gexprDeletedkey, expr.Id);
}
@@ -492,7 +502,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
var count = uow.Expressions.ClearFromGuild(guildId);
uow.SaveChanges();
newGuildReactions.TryRemove(guildId, out _);
newguildExpressions.TryRemove(guildId, out _);
return count;
}
@@ -562,10 +572,10 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
lock (_gexprWriteLock)
{
var newGlobalReactions = new NadekoExpression[globalReactions.Length + 1];
Array.Copy(globalReactions, newGlobalReactions, globalReactions.Length);
newGlobalReactions[globalReactions.Length] = c;
globalReactions = newGlobalReactions;
var newGlobalReactions = new NadekoExpression[globalExpressions.Length + 1];
Array.Copy(globalExpressions, newGlobalReactions, globalExpressions.Length);
newGlobalReactions[globalExpressions.Length] = c;
globalExpressions = newGlobalReactions;
}
return default;
@@ -575,11 +585,11 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
lock (_gexprWriteLock)
{
for (var i = 0; i < globalReactions.Length; i++)
for (var i = 0; i < globalExpressions.Length; i++)
{
if (globalReactions[i].Id == c.Id)
if (globalExpressions[i].Id == c.Id)
{
globalReactions[i] = c;
globalExpressions[i] = c;
return default;
}
}
@@ -596,8 +606,8 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
{
lock (_gexprWriteLock)
{
var newGlobalReactions = DeleteInternal(globalReactions, id, out _);
globalReactions = newGlobalReactions;
var newGlobalReactions = DeleteInternal(globalExpressions, id, out _);
globalExpressions = newGlobalReactions;
}
return default;
@@ -612,7 +622,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
private Task OnLeftGuild(SocketGuild arg)
{
newGuildReactions.TryRemove(arg.Id, out _);
newguildExpressions.TryRemove(arg.Id, out _);
return Task.CompletedTask;
}
@@ -622,7 +632,7 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
await using var uow = _db.GetDbContext();
var exprs = await uow.Expressions.AsNoTracking().Where(x => x.GuildId == gc.GuildId).ToArrayAsync();
newGuildReactions[gc.GuildId] = exprs;
newguildExpressions[gc.GuildId] = exprs;
}
#endregion
@@ -702,10 +712,25 @@ public sealed class NadekoExpressionsService : IExecOnMessage, IReadyExecutor
public NadekoExpression[] GetExpressionsFor(ulong? maybeGuildId)
{
if (maybeGuildId is { } guildId)
return newGuildReactions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty<NadekoExpression>();
return newguildExpressions.TryGetValue(guildId, out var exprs) ? exprs : Array.Empty<NadekoExpression>();
return globalReactions;
return globalExpressions;
}
#endregion
public async Task<bool> ToggleGlobalExpressionsAsync(ulong guildId)
{
await using var ctx = _db.GetDbContext();
var gc = ctx.GuildConfigsForId(guildId, set => set);
var toReturn = gc.DisableGlobalExpressions = !gc.DisableGlobalExpressions;
await ctx.SaveChangesAsync();
if (toReturn)
_disabledGlobalExpressionGuilds.Add(guildId);
else
_disabledGlobalExpressionGuilds.TryRemove(guildId);
return toReturn;
}
}

View File

@@ -126,7 +126,7 @@ public class ChatterBotService : IExecOnMessage
Log.Information("{PermissionMessage}", returnMsg);
}
return true;
return false;
}
if (await _ccs.TryBlock(sg, usrMsg.Author, CleverBotResponseStr.CLEVERBOT_RESPONSE))

View File

@@ -88,11 +88,11 @@ public partial class Help : NadekoModule<HelpService>
embed = embed.WithOkColor().WithDescription(GetText(strs.module_page_empty));
return embed;
}
localModules.OrderBy(module => module.Name)
.ToList()
.ForEach(module => embed.AddField($"{GetModuleEmoji(module.Name)} {module.Name}",
GetText(GetModuleLocStr(module.Name))
GetModuleDescription(module.Name)
+ "\n"
+ Format.Code(GetText(strs.module_footer(prefix, module.Name.ToLowerInvariant()))),
true));
@@ -104,6 +104,25 @@ public partial class Help : NadekoModule<HelpService>
false);
}
private string GetModuleDescription(string moduleName)
{
var key = GetModuleLocStr(moduleName);
if (key.Key == strs.module_description_missing.Key)
{
var desc = _medusae
.GetLoadedMedusae(Culture)
.FirstOrDefault(m => m.Sneks
.Any(x => x.Name.Equals(moduleName, StringComparison.InvariantCultureIgnoreCase)))
?.Description;
if (desc is not null)
return desc;
}
return GetText(key);
}
private LocStr GetModuleLocStr(string moduleName)
{
switch (moduleName.ToLowerInvariant())

View File

@@ -1,5 +1,4 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
@@ -30,8 +29,11 @@ public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, INService
public Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
=> TryBlock(context.Guild, context.User, command.Name.ToLowerInvariant());
public Task<bool> TryBlock(IGuild guild, IUser user, string commandName)
public Task<bool> TryBlock(IGuild? guild, IUser user, string commandName)
{
if (guild is null)
return Task.FromResult(false);
if (!_settings.TryGetValue(guild.Id, out var cooldownSettings))
return Task.FromResult(false);
@@ -53,7 +55,7 @@ public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, INService
if (cooldowns.TryGetValue(user.Id, out var oldValue))
{
var diff = DateTime.UtcNow - oldValue;
if (diff.Seconds > cdSeconds)
if (diff.TotalSeconds > cdSeconds)
{
if (cooldowns.TryUpdate(user.Id, DateTime.UtcNow, oldValue))
return Task.FromResult(false);
@@ -69,7 +71,6 @@ public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, INService
while (await timer.WaitForNextTickAsync())
{
var now = DateTime.UtcNow;
// once per hour delete expired entries
foreach (var ((guildId, commandName), dict) in _activeCooldowns)
{
@@ -90,7 +91,7 @@ public sealed class CmdCdService : IExecPreCommand, IReadyExecutor, INService
private void Cleanup(ConcurrentDictionary<ulong, DateTime> dict, int cdSeconds)
{
var now = DateTime.UtcNow;
foreach (var (key, _) in dict.Where(x => (now - x.Value).Seconds > cdSeconds).ToArray())
foreach (var (key, _) in dict.Where(x => (now - x.Value).TotalSeconds > cdSeconds).ToArray())
{
dict.TryRemove(key, out _);
}

View File

@@ -25,6 +25,47 @@ public partial class Permissions
await ReplyConfirmLocalizedAsync(strs.fw_cleared);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task FilterList()
{
var embed = _eb.Create(ctx)
.WithOkColor()
.WithTitle("Server filter settings");
var config = await _service.GetFilterSettings(ctx.Guild.Id);
string GetEnabledEmoji(bool value)
=> value ? "\\🟢" : "\\🔴";
async Task<string> GetChannelListAsync(IReadOnlyCollection<ulong> channels)
{
var toReturn = (await channels
.Select(async cid =>
{
var ch = await ctx.Guild.GetChannelAsync(cid);
return ch is null
? $"{cid} *missing*"
: $"<#{cid}>";
})
.WhenAll())
.Join('\n');
if (string.IsNullOrWhiteSpace(toReturn))
return GetText(strs.no_channel_found);
return toReturn;
}
embed.AddField($"{GetEnabledEmoji(config.FilterLinksEnabled)} Filter Links",
await GetChannelListAsync(config.FilterLinksChannels));
embed.AddField($"{GetEnabledEmoji(config.FilterInvitesEnabled)} Filter Invites",
await GetChannelListAsync(config.FilterInvitesChannels));
await ctx.Channel.EmbedAsync(embed);
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task SrvrFilterInv()

View File

@@ -1,4 +1,5 @@
#nullable disable
using AngleSharp.Dom;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
@@ -222,4 +223,20 @@ public sealed class FilterService : IExecOnMessage
return false;
}
public async Task<ServerFilterSettings> GetFilterSettings(ulong guildId)
{
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set
.Include(x => x.FilterInvitesChannelIds)
.Include(x => x.FilterLinksChannelIds));
return new()
{
FilterInvitesChannels = gc.FilterInvitesChannelIds.Map(x => x.ChannelId),
FilterLinksChannels = gc.FilterLinksChannelIds.Map(x => x.ChannelId),
FilterInvitesEnabled = gc.FilterInvites,
FilterLinksEnabled = gc.FilterLinks,
};
}
}

View File

@@ -0,0 +1,10 @@
#nullable disable
namespace NadekoBot.Modules.Permissions.Services;
public readonly struct ServerFilterSettings
{
public bool FilterInvitesEnabled { get; init; }
public bool FilterLinksEnabled { get; init; }
public IReadOnlyCollection<ulong> FilterInvitesChannels { get; init; }
public IReadOnlyCollection<ulong> FilterLinksChannels { get; init; }
}

View File

@@ -32,8 +32,8 @@ public partial class Searches
[UserPerm(GuildPerm.ManageMessages)]
public async Task Feed(string url, [Leftover] ITextChannel channel = null)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var uri)
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
await ReplyErrorLocalizedAsync(strs.feed_invalid_url);
return;

View File

@@ -9,7 +9,7 @@ namespace NadekoBot.Modules.Utility;
public partial class Utility
{
[Group]
public partial class CommandMapCommands : NadekoModule<CommandMapService>
public partial class CommandMapCommands : NadekoModule<AliasService>
{
private readonly DbService _db;
private readonly DiscordSocketClient _client;

View File

@@ -6,15 +6,14 @@ using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Utility.Services;
public class CommandMapService : IInputTransformer, INService
public class AliasService : IInputTransformer, INService
{
public ConcurrentDictionary<ulong, ConcurrentDictionary<string, string>> AliasMaps { get; } = new();
private readonly IEmbedBuilderService _eb;
private readonly DbService _db;
//commandmap
public CommandMapService(DiscordSocketClient client, DbService db, IEmbedBuilderService eb)
public AliasService(DiscordSocketClient client, DbService db, IEmbedBuilderService eb)
{
_eb = eb;
@@ -66,7 +65,10 @@ public class CommandMapService : IInputTransformer, INService
}
else if (input.StartsWith(k + ' ', StringComparison.OrdinalIgnoreCase))
{
newInput = v + ' ' + input[k.Length..];
if (v.Contains("%target%"))
newInput = v.Replace("%target%", input[k.Length..]);
else
newInput = v + ' ' + input[k.Length..];
}
if (newInput is not null)
@@ -74,17 +76,11 @@ public class CommandMapService : IInputTransformer, INService
try
{
var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
_ = Task.Run(async () =>
{
await Task.Delay(1500);
await toDelete.DeleteAsync(new()
{
RetryMode = RetryMode.AlwaysRetry
});
});
toDelete.DeleteAfter(1.5f);
}
catch
{
// ignored
}
return newInput;
@@ -92,34 +88,6 @@ public class CommandMapService : IInputTransformer, INService
}
return null;
// var keys = maps.Keys.OrderByDescending(x => x.Length);
// foreach (var k in keys)
// {
// string newInput;
// if (input.StartsWith(k + " ", StringComparison.InvariantCultureIgnoreCase))
// newInput = maps[k] + input.Substring(k.Length, input.Length - k.Length);
// else if (input.Equals(k, StringComparison.InvariantCultureIgnoreCase))
// newInput = maps[k];
// else
// continue;
//
// try
// {
// var toDelete = await channel.SendConfirmAsync(_eb, $"{input} => {newInput}");
// _ = Task.Run(async () =>
// {
// await Task.Delay(1500);
// await toDelete.DeleteAsync(new()
// {
// RetryMode = RetryMode.AlwaysRetry
// });
// });
// }
// catch { }
//
// return newInput;
// }
}
return null;

View File

@@ -1,24 +1,24 @@
#nullable disable
using System.Globalization;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Services.Database.Models;
using System.Text.RegularExpressions;
using Nadeko.Common;
namespace NadekoBot.Modules.Utility.Services;
public class RemindService : INService, IReadyExecutor
{
private readonly Regex _regex =
new(
@"^(?:in\s?)?\s*(?:(?<mo>\d+)(?:\s?(?:months?|mos?),?))?(?:(?:\sand\s|\s*)?(?<w>\d+)(?:\s?(?:weeks?|w),?))?(?:(?:\sand\s|\s*)?(?<d>\d+)(?:\s?(?:days?|d),?))?(?:(?:\sand\s|\s*)?(?<h>\d+)(?:\s?(?:hours?|h),?))?(?:(?:\sand\s|\s*)?(?<m>\d+)(?:\s?(?:minutes?|mins?|m),?))?\s+(?:to:?\s+)?(?<what>(?:\r\n|[\r\n]|.)+)",
new(@"^(?:(?:at|on(?:\sthe)?)?\s*(?<date>(?:\d{2}:\d{2}\s)?\d{1,2}\.\d{1,2}(?:\.\d{2,4})?)|(?:in\s?)?\s*(?:(?<mo>\d+)(?:\s?(?:months?|mos?),?))?(?:(?:\sand\s|\s*)?(?<w>\d+)(?:\s?(?:weeks?|w),?))?(?:(?:\sand\s|\s*)?(?<d>\d+)(?:\s?(?:days?|d),?))?(?:(?:\sand\s|\s*)?(?<h>\d+)(?:\s?(?:hours?|h),?))?(?:(?:\sand\s|\s*)?(?<m>\d+)(?:\s?(?:minutes?|mins?|m),?))?)\s+(?:to:?\s+)?(?<what>(?:\r\n|[\r\n]|.)+)",
RegexOptions.Compiled | RegexOptions.Multiline);
private readonly DiscordSocketClient _client;
private readonly DbService _db;
private readonly IBotCredentials _creds;
private readonly IEmbedBuilderService _eb;
private readonly CultureInfo _culture;
public RemindService(
DiscordSocketClient client,
@@ -30,6 +30,15 @@ public class RemindService : INService, IReadyExecutor
_db = db;
_creds = creds;
_eb = eb;
try
{
_culture = new CultureInfo("en-GB");
}
catch
{
_culture = CultureInfo.InvariantCulture;
}
}
public async Task OnReadyAsync()
@@ -105,32 +114,57 @@ public class RemindService : INService, IReadyExecutor
return false;
}
foreach (var groupName in _regex.GetGroupNames())
TimeSpan ts;
var dateString = m.Groups["date"].Value;
if (!string.IsNullOrWhiteSpace(dateString))
{
if (groupName is "0" or "what")
continue;
if (string.IsNullOrWhiteSpace(m.Groups[groupName].Value))
var now = DateTime.UtcNow;
if (!DateTime.TryParse(dateString, _culture, DateTimeStyles.None, out var dt))
{
values[groupName] = 0;
continue;
}
if (!int.TryParse(m.Groups[groupName].Value, out var value))
{
Log.Warning("Reminder regex group {GroupName} has invalid value", groupName);
Log.Warning("Invalid remind datetime format");
return false;
}
if (value < 1)
if (now >= dt)
{
Log.Warning("Reminder time value has to be an integer greater than 0");
Log.Warning("That remind time has already passed");
return false;
}
values[groupName] = value;
ts = dt - now;
}
else
{
foreach (var groupName in _regex.GetGroupNames())
{
if (groupName is "0" or "what")
continue;
if (string.IsNullOrWhiteSpace(m.Groups[groupName].Value))
{
values[groupName] = 0;
continue;
}
if (!int.TryParse(m.Groups[groupName].Value, out var value))
{
Log.Warning("Reminder regex group {GroupName} has invalid value", groupName);
return false;
}
if (value < 1)
{
Log.Warning("Reminder time value has to be an integer greater than 0");
return false;
}
values[groupName] = value;
}
ts = new TimeSpan((30 * values["mo"]) + (7 * values["w"]) + values["d"], values["h"], values["m"], 0);
}
var ts = new TimeSpan((30 * values["mo"]) + (7 * values["w"]) + values["d"], values["h"], values["m"], 0);
obj = new()
{

View File

@@ -17,11 +17,14 @@ public partial class Xp
[Cmd]
public async Task ClubTransfer([Leftover] IUser newOwner)
{
var club = _service.TransferClub(ctx.User, newOwner);
var result = _service.TransferClub(ctx.User, newOwner);
if (club is null)
if (!result.TryPickT0(out var club, out var error))
{
await ReplyErrorLocalizedAsync(strs.club_transfer_failed);
if(error == ClubTransferError.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else
await ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
else
{
@@ -37,15 +40,20 @@ public partial class Xp
[Cmd]
public async Task ClubAdmin([Leftover] IUser toAdmin)
{
var admin = await _service.ToggleAdminAsync(ctx.User, toAdmin);
if (admin is null)
await ReplyErrorLocalizedAsync(strs.club_admin_error);
else if (admin is true)
var result = await _service.ToggleAdminAsync(ctx.User, toAdmin);
if (result == ToggleAdminResult.AddedAdmin)
await ReplyConfirmLocalizedAsync(strs.club_admin_add(Format.Bold(toAdmin.ToString())));
else
else if (result == ToggleAdminResult.RemovedAdmin)
await ReplyConfirmLocalizedAsync(strs.club_admin_remove(Format.Bold(toAdmin.ToString())));
else if (result == ToggleAdminResult.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else if (result == ToggleAdminResult.CantTargetThyself)
await ReplyErrorLocalizedAsync(strs.club_admin_invalid_target);
else if (result == ToggleAdminResult.TargetNotMember)
await ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
[Cmd]
public async Task ClubCreate([Leftover] string clubName)
{
@@ -55,17 +63,23 @@ public partial class Xp
return;
}
var succ = await _service.CreateClubAsync(ctx.User, clubName);
var result = await _service.CreateClubAsync(ctx.User, clubName);
if (succ is null)
if (result == ClubCreateResult.NameTaken)
{
await ReplyErrorLocalizedAsync(strs.club_create_error_name);
return;
}
if (succ is false)
if (result == ClubCreateResult.InsufficientLevel)
{
await ReplyErrorLocalizedAsync(strs.club_create_error);
await ReplyErrorLocalizedAsync(strs.club_create_insuff_lvl);
return;
}
if (result == ClubCreateResult.AlreadyInAClub)
{
await ReplyErrorLocalizedAsync(strs.club_already_in);
return;
}
@@ -75,14 +89,21 @@ public partial class Xp
[Cmd]
public async Task ClubIcon([Leftover] string url = null)
{
if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null)
|| !await _service.SetClubIconAsync(ctx.User.Id, url))
if ((!Uri.IsWellFormedUriString(url, UriKind.Absolute) && url is not null))
{
await ReplyErrorLocalizedAsync(strs.club_icon_error);
await ReplyErrorLocalizedAsync(strs.club_icon_url_format);
return;
}
await ReplyConfirmLocalizedAsync(strs.club_icon_set);
var result = await _service.SetClubIconAsync(ctx.User.Id, url);
if(result == SetClubIconResult.Success)
await ReplyConfirmLocalizedAsync(strs.club_icon_set);
else if (result == SetClubIconResult.NotOwner)
await ReplyErrorLocalizedAsync(strs.club_owner_only);
else if (result == SetClubIconResult.TooLarge)
await ReplyErrorLocalizedAsync(strs.club_icon_too_large);
else if (result == SetClubIconResult.InvalidFileType)
await ReplyErrorLocalizedAsync(strs.club_icon_invalid_filetype);
}
private async Task InternalClubInfoAsync(ClubInfo club)
@@ -102,27 +123,27 @@ public partial class Xp
page =>
{
var embed = _eb.Create()
.WithOkColor()
.WithTitle($"{club}")
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),
string.Join("\n",
users.Skip(page * 10)
.Take(10)
.Select(x =>
{
var l = new LevelStats(x.TotalXp);
var lvlStr = Format.Bold($" ⟪{l.Level}⟫");
if (club.OwnerId == x.Id)
return x + "🌟" + lvlStr;
if (x.IsClubAdmin)
return x + "⭐" + lvlStr;
return x + lvlStr;
})));
.WithOkColor()
.WithTitle($"{club}")
.WithDescription(GetText(strs.level_x(lvl.Level + $" ({club.Xp} xp)")))
.AddField(GetText(strs.desc),
string.IsNullOrWhiteSpace(club.Description) ? "-" : club.Description)
.AddField(GetText(strs.owner), club.Owner.ToString(), true)
// .AddField(GetText(strs.level_req), club.MinimumLevelReq.ToString(), true)
.AddField(GetText(strs.members),
string.Join("\n",
users.Skip(page * 10)
.Take(10)
.Select(x =>
{
var l = new LevelStats(x.TotalXp);
var lvlStr = Format.Bold($" ⟪{l.Level}⟫");
if (club.OwnerId == x.Id)
return x + "🌟" + lvlStr;
if (x.IsClubAdmin)
return x + "⭐" + lvlStr;
return x + lvlStr;
})));
if (Uri.IsWellFormedUriString(club.ImageUrl, UriKind.Absolute))
return embed.WithThumbnailUrl(club.ImageUrl);
@@ -187,9 +208,9 @@ public partial class Xp
.Select(x => x.ToString()));
return _eb.Create()
.WithTitle(GetText(strs.club_bans_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
.WithTitle(GetText(strs.club_bans_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
},
bans.Length,
10);
@@ -213,9 +234,9 @@ public partial class Xp
var toShow = string.Join("\n", apps.Skip(page * 10).Take(10).Select(x => x.ToString()));
return _eb.Create()
.WithTitle(GetText(strs.club_apps_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
.WithTitle(GetText(strs.club_apps_for(club.ToString())))
.WithDescription(toShow)
.WithOkColor();
},
apps.Length,
10);
@@ -242,7 +263,6 @@ public partial class Xp
await ReplyErrorLocalizedAsync(strs.club_insuff_lvl);
else if (result == ClubApplyResult.AlreadyInAClub)
await ReplyErrorLocalizedAsync(strs.club_already_in);
}
[Cmd]
@@ -254,19 +274,26 @@ public partial class Xp
[Priority(0)]
public async Task ClubAccept([Leftover] string userName)
{
if (_service.AcceptApplication(ctx.User.Id, userName, out var discordUser))
var result = _service.AcceptApplication(ctx.User.Id, userName, out var discordUser);
if (result == ClubAcceptResult.Accepted)
await ReplyConfirmLocalizedAsync(strs.club_accepted(Format.Bold(discordUser.ToString())));
else
await ReplyErrorLocalizedAsync(strs.club_accept_error);
else if(result == ClubAcceptResult.NoSuchApplicant)
await ReplyErrorLocalizedAsync(strs.club_accept_invalid_applicant);
else if(result == ClubAcceptResult.NotOwnerOrAdmin)
await ReplyErrorLocalizedAsync(strs.club_admin_perms);
}
[Cmd]
public async Task Clubleave()
public async Task ClubLeave()
{
if (_service.LeaveClub(ctx.User))
var res = _service.LeaveClub(ctx.User);
if (res == ClubLeaveResult.Success)
await ReplyConfirmLocalizedAsync(strs.club_left);
else if (res == ClubLeaveResult.NotInAClub)
await ReplyErrorLocalizedAsync(strs.club_not_in_a_club);
else
await ReplyErrorLocalizedAsync(strs.club_not_in_club);
await ReplyErrorLocalizedAsync(strs.club_owner_cant_leave);
}
[Cmd]
@@ -278,13 +305,20 @@ public partial class Xp
[Priority(0)]
public Task ClubKick([Leftover] string userName)
{
if (_service.Kick(ctx.User.Id, userName, out var club))
var result = _service.Kick(ctx.User.Id, userName, out var club);
if(result == ClubKickResult.Success)
{
return ReplyConfirmLocalizedAsync(strs.club_user_kick(Format.Bold(userName),
Format.Bold(club.ToString())));
}
return ReplyErrorLocalizedAsync(strs.club_user_kick_fail);
if (result == ClubKickResult.Hierarchy)
return ReplyErrorLocalizedAsync(strs.club_kick_hierarchy);
if (result == ClubKickResult.NotOwnerOrAdmin)
return ReplyErrorLocalizedAsync(strs.club_admin_perms);
return ReplyErrorLocalizedAsync(strs.club_target_not_member);
}
[Cmd]
@@ -322,7 +356,7 @@ public partial class Xp
public Task ClubUnBan([Leftover] string userName)
{
var result = _service.UnBan(ctx.User.Id, userName, out var club);
if (result == ClubUnbanResult.Success)
{
return ReplyConfirmLocalizedAsync(strs.club_user_unbanned(Format.Bold(userName),
@@ -345,12 +379,12 @@ public partial class Xp
desc = string.IsNullOrWhiteSpace(desc)
? "-"
: desc;
var eb = _eb.Create(ctx)
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()
.WithDescription(desc);
.WithAuthor(ctx.User)
.WithTitle(GetText(strs.club_desc_update))
.WithOkColor()
.WithDescription(desc);
await ctx.Channel.EmbedAsync(eb);
}

View File

@@ -2,9 +2,9 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Nadeko.Common;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using OneOf;
namespace NadekoBot.Modules.Xp.Services;
@@ -19,7 +19,8 @@ public class ClubService : INService, IClubService
_httpFactory = httpFactory;
}
public async Task<bool?> CreateClubAsync(IUser user, string clubName)
public async Task<ClubCreateResult> CreateClubAsync(IUser user, string clubName)
{
//must be lvl 5 and must not be in a club already
@@ -27,11 +28,14 @@ public class ClubService : INService, IClubService
var du = uow.GetOrCreateUser(user);
var xp = new LevelStats(du.TotalXp);
if (xp.Level < 5 || du.ClubId is not null)
return false;
if (xp.Level < 5)
return ClubCreateResult.InsufficientLevel;
if (du.ClubId is not null)
return ClubCreateResult.AlreadyInAClub;
if (await uow.Clubs.AnyAsyncEF(x => x.Name == clubName))
return null;
return ClubCreateResult.NameTaken;
du.IsClubAdmin = true;
du.Club = new()
@@ -45,17 +49,20 @@ public class ClubService : INService, IClubService
await uow.GetTable<ClubApplicants>()
.DeleteAsync(x => x.UserId == du.Id);
return true;
return ClubCreateResult.Success;
}
public ClubInfo TransferClub(IUser from, IUser newOwner)
public OneOf<ClubInfo, ClubTransferError> TransferClub(IUser from, IUser newOwner)
{
using var uow = _db.GetDbContext();
var club = uow.Clubs.GetByOwner(@from.Id);
var newOwnerUser = uow.GetOrCreateUser(newOwner);
if (club is null || club.Owner.UserId != from.Id || !club.Members.Contains(newOwnerUser))
return null;
if (club is null || club.Owner.UserId != from.Id)
return ClubTransferError.NotOwner;
if (!club.Members.Contains(newOwnerUser))
return ClubTransferError.TargetNotMember;
club.Owner.IsClubAdmin = true; // old owner will stay as admin
newOwnerUser.IsClubAdmin = true;
@@ -63,22 +70,25 @@ public class ClubService : INService, IClubService
uow.SaveChanges();
return club;
}
public async Task<bool?> ToggleAdminAsync(IUser owner, IUser toAdmin)
public async Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin)
{
if (owner.Id == toAdmin.Id)
return ToggleAdminResult.CantTargetThyself;
await using var uow = _db.GetDbContext();
var club = uow.Clubs.GetByOwner(owner.Id);
var adminUser = uow.GetOrCreateUser(toAdmin);
if (club is null || club.Owner.UserId != owner.Id || !club.Members.Contains(adminUser))
return null;
if (club.OwnerId == adminUser.Id)
return true;
if (club is null)
return ToggleAdminResult.NotOwner;
if(!club.Members.Contains(adminUser))
return ToggleAdminResult.TargetNotMember;
var newState = adminUser.IsClubAdmin = !adminUser.IsClubAdmin;
await uow.SaveChangesAsync();
return newState;
return newState ? ToggleAdminResult.AddedAdmin : ToggleAdminResult.RemovedAdmin;
}
public ClubInfo GetClubByMember(IUser user)
@@ -87,27 +97,31 @@ public class ClubService : INService, IClubService
var member = uow.Clubs.GetByMember(user.Id);
return member;
}
public async Task<bool> SetClubIconAsync(ulong ownerUserId, string url)
public async Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string url)
{
if (url is not null)
{
using var http = _httpFactory.CreateClient();
using var temp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
if (!temp.IsImage() || temp.GetContentLength() > 5.Megabytes().Bytes)
return false;
if (!temp.IsImage())
return SetClubIconResult.InvalidFileType;
if (temp.GetContentLength() > 5.Megabytes().Bytes)
return SetClubIconResult.TooLarge;
}
await using var uow = _db.GetDbContext();
var club = uow.Clubs.GetByOwner(ownerUserId);
if (club is null)
return false;
return SetClubIconResult.NotOwner;
club.ImageUrl = url;
await uow.SaveChangesAsync();
return true;
return SetClubIconResult.Success;
}
public bool GetClubByName(string clubName, out ClubInfo club)
@@ -146,18 +160,19 @@ public class ClubService : INService, IClubService
return ClubApplyResult.Success;
}
public bool AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser)
public ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser)
{
discordUser = null;
using var uow = _db.GetDbContext();
var club = uow.Clubs.GetByOwnerOrAdmin(clubOwnerUserId);
if (club is null)
return false;
return ClubAcceptResult.NotOwnerOrAdmin;
var applicant =
club.Applicants.FirstOrDefault(x => x.User.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (applicant is null)
return false;
return ClubAcceptResult.NoSuchApplicant;
applicant.User.Club = club;
applicant.User.IsClubAdmin = false;
@@ -169,7 +184,7 @@ public class ClubService : INService, IClubService
discordUser = applicant.User;
uow.SaveChanges();
return true;
return ClubAcceptResult.Accepted;
}
public ClubInfo GetClubWithBansAndApplications(ulong ownerUserId)
@@ -178,17 +193,19 @@ public class ClubService : INService, IClubService
return uow.Clubs.GetByOwnerOrAdmin(ownerUserId);
}
public bool LeaveClub(IUser user)
public ClubLeaveResult LeaveClub(IUser user)
{
using var uow = _db.GetDbContext();
var du = uow.GetOrCreateUser(user, x => x.Include(u => u.Club));
if (du.Club is null || du.Club.OwnerId == du.Id)
return false;
if (du.Club is null)
return ClubLeaveResult.NotInAClub;
if (du.Club.OwnerId == du.Id)
return ClubLeaveResult.OwnerCantLeave;
du.Club = null;
du.IsClubAdmin = false;
uow.SaveChanges();
return true;
return ClubLeaveResult.Success;
}
public bool SetDescription(ulong userId, string desc)
@@ -267,19 +284,20 @@ public class ClubService : INService, IClubService
return ClubUnbanResult.Success;
}
public bool Kick(ulong kickerId, string userName, out ClubInfo club)
public ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club)
{
using var uow = _db.GetDbContext();
club = uow.Clubs.GetByOwnerOrAdmin(kickerId);
if (club is null)
return false;
return ClubKickResult.NotOwnerOrAdmin;
var usr = club.Members.FirstOrDefault(x => x.ToString().ToUpperInvariant() == userName.ToUpperInvariant());
if (usr is null)
return false;
return ClubKickResult.TargetNotAMember;
if (club.OwnerId == usr.Id || (usr.IsClubAdmin && club.Owner.UserId != kickerId))
return false;
return ClubKickResult.Hierarchy;
club.Members.Remove(usr);
var app = club.Applicants.FirstOrDefault(x => x.UserId == usr.Id);
@@ -287,7 +305,7 @@ public class ClubService : INService, IClubService
club.Applicants.Remove(app);
uow.SaveChanges();
return true;
return ClubKickResult.Success;
}
public List<ClubInfo> GetClubLeaderboardPage(int page)
@@ -298,20 +316,4 @@ public class ClubService : INService, IClubService
using var uow = _db.GetDbContext();
return uow.Clubs.GetClubLeaderboardPage(page);
}
}
public enum ClubUnbanResult
{
Success,
NotOwnerOrAdmin,
WrongUser
}
public enum ClubBanResult
{
Success,
NotOwnerOrAdmin,
WrongUser,
Unbannable,
}

View File

@@ -1,24 +1,25 @@
using NadekoBot.Db.Models;
using OneOf;
namespace NadekoBot.Modules.Xp.Services;
public interface IClubService
{
Task<bool?> CreateClubAsync(IUser user, string clubName);
ClubInfo? TransferClub(IUser from, IUser newOwner);
Task<bool?> ToggleAdminAsync(IUser owner, IUser toAdmin);
Task<ClubCreateResult> CreateClubAsync(IUser user, string clubName);
OneOf<ClubInfo,ClubTransferError> TransferClub(IUser from, IUser newOwner);
Task<ToggleAdminResult> ToggleAdminAsync(IUser owner, IUser toAdmin);
ClubInfo? GetClubByMember(IUser user);
Task<bool> SetClubIconAsync(ulong ownerUserId, string? url);
Task<SetClubIconResult> SetClubIconAsync(ulong ownerUserId, string? url);
bool GetClubByName(string clubName, out ClubInfo club);
ClubApplyResult ApplyToClub(IUser user, ClubInfo club);
bool AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser);
ClubAcceptResult AcceptApplication(ulong clubOwnerUserId, string userName, out DiscordUser discordUser);
ClubInfo? GetClubWithBansAndApplications(ulong ownerUserId);
bool LeaveClub(IUser user);
ClubLeaveResult LeaveClub(IUser user);
bool SetDescription(ulong userId, string? desc);
bool Disband(ulong userId, out ClubInfo club);
ClubBanResult Ban(ulong bannerId, string userName, out ClubInfo club);
ClubUnbanResult UnBan(ulong ownerUserId, string userName, out ClubInfo club);
bool Kick(ulong kickerId, string userName, out ClubInfo club);
ClubKickResult Kick(ulong kickerId, string userName, out ClubInfo club);
List<ClubInfo> GetClubLeaderboardPage(int page);
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubAcceptResult
{
Accepted,
NotOwnerOrAdmin,
NoSuchApplicant,
}

View File

@@ -0,0 +1,10 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubBanResult
{
Success,
NotOwnerOrAdmin,
WrongUser,
Unbannable,
}

View File

@@ -0,0 +1,9 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubCreateResult
{
Success,
AlreadyInAClub,
NameTaken,
InsufficientLevel,
}

View File

@@ -0,0 +1,9 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubKickResult
{
Success,
NotOwnerOrAdmin,
TargetNotAMember,
Hierarchy
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubLeaveResult
{
Success,
OwnerCantLeave,
NotInAClub
}

View File

@@ -0,0 +1,7 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubTransferError
{
NotOwner,
TargetNotMember
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ClubUnbanResult
{
Success,
NotOwnerOrAdmin,
WrongUser
}

View File

@@ -0,0 +1,9 @@
namespace NadekoBot.Modules.Xp.Services;
public enum SetClubIconResult
{
Success,
InvalidFileType,
TooLarge,
NotOwner,
}

View File

@@ -0,0 +1,10 @@
namespace NadekoBot.Modules.Xp.Services;
public enum ToggleAdminResult
{
AddedAdmin,
RemovedAdmin,
NotOwner,
TargetNotMember,
CantTargetThyself,
}

View File

@@ -71,8 +71,11 @@ public sealed class GamblingTxTracker : ITxTracker, INService, IReadyExecutor
}
}
public Task TrackAdd(long amount, TxData txData)
public Task TrackAdd(long amount, TxData? txData)
{
if (txData is null)
return Task.CompletedTask;
if (_gamblingTypes.Contains(txData.Type))
{
_stats.AddOrUpdate(txData.Type,
@@ -83,8 +86,11 @@ public sealed class GamblingTxTracker : ITxTracker, INService, IReadyExecutor
return Task.CompletedTask;
}
public Task TrackRemove(long amount, TxData txData)
public Task TrackRemove(long amount, TxData? txData)
{
if (txData is null)
return Task.CompletedTask;
if (_gamblingTypes.Contains(txData.Type))
{
_stats.AddOrUpdate(txData.Type,

View File

@@ -1,6 +1,5 @@
using NadekoBot.Services.Currency;
#nullable disable
namespace NadekoBot.Services;
public interface ICurrencyService
@@ -10,32 +9,32 @@ public interface ICurrencyService
Task AddBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData txData,
TxData? txData,
CurrencyType type = CurrencyType.Default);
Task RemoveBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData txData,
TxData? txData,
CurrencyType type = CurrencyType.Default);
Task AddAsync(
ulong userId,
long amount,
TxData txData);
TxData? txData);
Task AddAsync(
IUser user,
long amount,
TxData txData);
TxData? txData);
Task<bool> RemoveAsync(
ulong userId,
long amount,
TxData txData);
TxData? txData);
Task<bool> RemoveAsync(
IUser user,
long amount,
TxData txData);
TxData? txData);
}

View File

@@ -4,6 +4,6 @@ namespace NadekoBot.Services;
public interface ITxTracker
{
Task TrackAdd(long amount, TxData txData);
Task TrackRemove(long amount, TxData txData);
Task TrackAdd(long amount, TxData? txData);
Task TrackRemove(long amount, TxData? txData);
}

View File

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

View File

@@ -56,5 +56,11 @@ public sealed class BotConfigService : ConfigServiceBase<BotConfig>
c.Version = 4;
c.CheckForUpdates = true;
});
if(data.Version < 5)
ModifyConfig(c =>
{
c.Version = 5;
});
}
}

View File

@@ -144,11 +144,11 @@ public static class Extensions
public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Error);
public static IMessage DeleteAfter(this IUserMessage msg, int seconds, ILogCommandService? logService = null)
public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null)
{
Task.Run(async () =>
{
await Task.Delay(seconds * 1000);
await Task.Delay((int)(seconds * 1000));
if (logService is not null)
logService.AddDeleteIgnore(msg.Id);

View File

@@ -246,6 +246,9 @@ roles:
channeltopic:
- channeltopic
- ct
filterlist:
- filterlist
- fl
chnlfilterinv:
- chnlfilterinv
- cfi
@@ -741,6 +744,10 @@ forwardmessages:
forwardtoall:
- forwardtoall
- fwtoall
forwardtochannel:
- forwardtochannel
- fwtoch
- fwtochannel
resetperms:
- resetperms
antiraid:
@@ -1267,6 +1274,9 @@ quotesimport:
showembed:
- showembed
# NadekoExpressions
exprtoggleglobal:
- exprtoggleglobal
- extg
exprreact:
- exreact
- exr

View File

@@ -1,5 +1,5 @@
# DO NOT CHANGE
version: 4
version: 5
# Most commands, when executed, have a small colored line
# next to the response. The color depends whether the command
# is completed, errored or in progress (pending)
@@ -25,6 +25,9 @@ forwardMessages: false
# Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml),
# or all owners? (this might cause the bot to lag if there's a lot of owners specified)
forwardToAllOwners: false
# Any messages sent by users in Bot's DM to be forwarded to the specified channel.
# This option will only work when ForwardToAllOwners is set to false
forwardToChannel:
# When a user DMs the bot with a message which is not a command
# they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot.
# Supports embeds. How it looks: https://puu.sh/B0BLV.png

View File

@@ -236,6 +236,10 @@ fwclear:
desc: "Deletes all filtered words on this server."
args:
- ""
filterlist:
desc: "Lists invite and link filter channels and status."
args:
- ""
aliasesclear:
desc: "Deletes all aliases on this server."
args:
@@ -1253,19 +1257,23 @@ forwardtoall:
desc: "Toggles whether messages will be forwarded to all bot owners or only to the first one specified in the creds.yml file"
args:
- ""
forwardtochannel:
desc: "Toggles forwarding of non-command messages sent to bot's DM to the current channel"
args:
- ""
resetperms:
desc: "Resets the bot's permissions module on this server to the default value."
args:
- ""
antiraid:
desc: "Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles"
desc: "Sets an anti-raid protection on the server. Provide no parameters to disable. First parameter is number of people which will trigger the protection. Second parameter is a time interval in which that number of people needs to join in order to trigger the protection, and third parameter is punishment for those people. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, RemoveRoles, AddRole, Warn, TimeOut"
args:
- "5 20 Kick"
- "7 9 Ban"
- "10 10 Ban 6h30m"
- ""
antispam:
desc: "Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles"
desc: "Stops people from repeating same message X times in a row. Provide no parameters to disable. You can specify to either mute, kick or ban the offenders. You can specify an additional time argument to do a timed punishment for actions which support it (Ban, Mute, etc) up to 24h. Max message count is 10. Available punishments: Ban, Kick, Softban, Mute, VoiceMute, ChatMute, AddRole, RemoveRoles, Warn, TimeOut"
args:
- "3 Mute"
- "5 Ban"
@@ -1573,6 +1581,10 @@ rategirl:
desc: "Use the universal hot-crazy wife zone matrix to determine the girl's worth. It is everything young men need to know about women. At any moment in time, any woman you have previously located on this chart can vanish from that location and appear anywhere else on the chart."
args:
- "@SomeGurl"
exprtoggleglobal:
desc: "Toggles whether global expressions are usable on this server."
args:
- ""
exprreact:
desc: "Sets or resets reactions (up to 3) which will be added to the response message of the Expression with the specified ID. Provide no emojis to reset."
args:

View File

@@ -1,4 +1,6 @@
{
"expr_global_disabled": "Global expressions are now disabled on this server.",
"expr_global_enabled": "Global expressions are no longer disabled on this server.",
"expr_deleted": "Expression deleted",
"expr_insuff_perms": "Insufficient permissions. Requires Bot ownership for global expressions, and Administrator for server expressions.",
"expressions": "Expressions",
@@ -68,6 +70,8 @@
"fwall_stop": "I will forward DMs only to the first owner.",
"fwdm_start": "I will forward DMs from now on.",
"fwdm_stop": "I will stop forwarding DMs from now on.",
"fwch_start": "Any message sent to bot's DMs will be forwarded to this channel.",
"fwch_stop": "Messages sent to bot's DMs will no longer be forwarded to this channel.",
"greetdel_off": "Automatic deletion of greet messages has been disabled.",
"greetdel_on": "Greet messages will be deleted after {0} seconds.",
"greetdmmsg_cur": "Current DM greet message: {0}",
@@ -150,7 +154,6 @@
"rc": "Color of {0} role has been changed.",
"rc_perms": "Error occurred due to invalid color or insufficient permissions.",
"color": "Color",
"icon": "Icon",
"hoisted": "Hoisted",
"mentionable": "Mentionable",
"remrole": "Successfully removed role {0} from user {1}",
@@ -271,7 +274,6 @@
"usage": "Usage",
"options": "Options",
"requires": "Requires",
"tag": "Tag",
"animal_race": "Animal race",
"animal_race_failed": "Failed to start since there was not enough participants.",
"animal_race_full": "Race is full! Starting immediately.",
@@ -443,7 +445,6 @@
"removed": "removed permission #{0} - {1}",
"rx_disable": "Disabled usage of {0} {1} for {2} role.",
"rx_enable": "Enabled usage of {0} {1} for {2} role.",
"sec": "sec.",
"sx_disable": "Disabled usage of {0} {1} on this server.",
"sx_enable": "Enabled usage of {0} {1} on this server.",
"unblacklisted": "Unblacklisted {0} with ID {1}",
@@ -841,19 +842,22 @@
"club_join_banned": "You're banned from that club.",
"club_already_in": "You are already a member of a club.",
"club_create_error_name": "Failed creating the club. A club with that name already exists.",
"club_create_error": "Failed creating the club. Make sure you're above level 5 and not a member of a club already.",
"club_name_too_long": "Club name is too long.",
"club_created": "Club {0} successfully created!",
"club_create_insuff_lvl": "You don't meet the minimum level requirements in order to create a club.",
"club_not_exists": "That club doesn't exist.",
"club_applied": "You've applied for membership in {0} club.",
"club_accepted": "Accepted user {0} to the club.",
"club_accept_error": "User not found",
"club_accept_invalid_applicant": "That user has not applied to your club.",
"club_left": "You've left the club.",
"club_not_in_club": "You are not in a club, or you're trying to leave the club you're the owner of.",
"club_not_in_a_club": "You are not in a club.",
"club_owner_cant_leave": "Club owner can't leave the club - you must either transfer ownership or disband the club.",
"club_user_kick": "User {0} kicked from {1} club.",
"club_user_not_in_club": "{0} is not in a club.",
"club_user_kick_fail": "Error kicking. You're either not the club owner, or that user is not in your club.",
"club_user_banned": "Banned user {0} from {1} club.",
"club_owner_only": "This action can only be performed by the club owner.",
"club_admin_invalid_target": "You can't target yourself or the club owner.",
"club_target_not_member": "Specified user is not a member of your club.",
"club_admin_perms": "You must be a club admin or owner in order to perform this action.",
"club_ban_fail_user_not_found": "That user is not in your club or applied to it.",
"club_ban_fail_unbannable": "Only the club owner can ban club admins.",
@@ -863,11 +867,14 @@
"club_desc_update_failed": "Failed changing club description.",
"club_disbanded": "Club {0} has been disbanded",
"club_disband_error": "Error. You are either not in a club, or you are not the owner of your club.",
"club_icon_error": "Not a valid image url or you're not the club owner.",
"club_icon_too_large": "Image is too large.",
"club_icon_invalid_filetype": "Specified image has an invalid filetype. Make sure you're specifying a direct image url.",
"club_icon_url_format": "You must specify an absolute image url/.",
"club_icon_set": "New club icon set.",
"club_bans_for": "Bans for {0} club",
"club_apps_for": "Applicants for {0} club",
"club_leaderboard": "Club leaderboard - page {0}",
"club_kick_hierarchy": "Only club owner can kick club admins. Owner can't be kicked.",
"template_reloaded": "Xp template has been reloaded.",
"expr_edited": "Expression Edited",
"self_assign_are_exclusive": "You can only choose 1 role from each group.",
@@ -881,7 +888,6 @@
"poll_closed": "Poll Closed!",
"club_admin_add": "{0} is now a club admin.",
"club_admin_remove": "{0} is no longer club admin.",
"club_admin_error": "Error. You are either not the owner of the club, or that user is not in your club.",
"started": "Started. Reposting every {0}s.",
"stopped": "Stopped reposting.",
"feed_added": "Feed added.",
@@ -928,7 +934,6 @@
"mass_ban_completed": "Banned {0} users.",
"mass_kill_completed": "Mass Banning and Blacklisting of {0} users is complete.",
"club_transfered": "Ownership of the club {0} has been transferred to {1}",
"club_transfer_failed": "Transfer failed. You must be the club owner. Target must be a member of your club.",
"roll_duel_challenge": "challenged {1} for a roll duel for {2}",
"roll_duel": "Roll Duel",
"roll_duel_no_funds": "Either you or your opponent don't have enough funds.",