- Using PeriodicTimer in a few places instead of Timer

- Some small refactorings
- Fixed reference to non-existent command in bot.yml
This commit is contained in:
Kwoth
2022-01-05 08:00:25 +01:00
parent f13d7d2c80
commit 6c39044435
10 changed files with 114 additions and 111 deletions

View File

@@ -441,9 +441,7 @@ namespace NadekoBot.Coordinator
} }
public string GetConfigText() public string GetConfigText()
{ => File.ReadAllText(CONFIG_PATH);
return File.ReadAllText(CONFIG_PATH);
}
public void SetConfigText(string text) public void SetConfigText(string text)
{ {

View File

@@ -77,7 +77,7 @@ note: This setting is primarily used if you're afraid of raids, or you're runnin
public bool GroupGreets { get; set; } public bool GroupGreets { get; set; }
[Comment(@"Whether the bot will rotate through all specified statuses. [Comment(@"Whether the bot will rotate through all specified statuses.
This setting can be changed via .rots command. This setting can be changed via .ropl command.
See RotatingStatuses submodule in Administration.")] See RotatingStatuses submodule in Administration.")]
public bool RotateStatuses { get; set; } public bool RotateStatuses { get; set; }

View File

@@ -16,7 +16,7 @@ public class MuteService : INService
{ {
public enum TimerType { Mute, Ban, AddRole } public enum TimerType { Mute, Ban, AddRole }
private static readonly OverwritePermissions denyOverwrite = new(addReactions: PermValue.Deny, private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny,
sendMessages: PermValue.Deny, sendMessages: PermValue.Deny,
attachFiles: PermValue.Deny); attachFiles: PermValue.Deny);
@@ -26,7 +26,7 @@ public class MuteService : INService
public ConcurrentDictionary<ulong, string> GuildMuteRoles { get; } public ConcurrentDictionary<ulong, string> GuildMuteRoles { get; }
public ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> MutedUsers { get; } public ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> MutedUsers { get; }
public ConcurrentDictionary<ulong, ConcurrentDictionary<(ulong, TimerType), Timer>> Un_Timers { get; } = new(); public ConcurrentDictionary<ulong, ConcurrentDictionary<(ulong, TimerType), Timer>> UnTimers { get; } = new();
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly DbService _db; private readonly DbService _db;
@@ -312,7 +312,7 @@ public class MuteService : INService
if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id
&& x.TargetType == PermissionTarget.Role)) && x.TargetType == PermissionTarget.Role))
{ {
await toOverwrite.AddPermissionOverwriteAsync(muteRole, denyOverwrite); await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite);
await Task.Delay(200); await Task.Delay(200);
} }
@@ -394,12 +394,13 @@ public class MuteService : INService
ulong? roleId = null) ulong? roleId = null)
{ {
//load the unmute timers for this guild //load the unmute timers for this guild
var userUnTimers = Un_Timers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>()); var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>());
//unmute timer to be added //unmute timer to be added
var toAdd = new Timer(async _ => var toAdd = new Timer(async _ =>
{ {
if (type == TimerType.Ban) if (type == TimerType.Ban)
{
try try
{ {
RemoveTimerFromDb(guildId, userId, type); RemoveTimerFromDb(guildId, userId, type);
@@ -409,24 +410,28 @@ public class MuteService : INService
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning(ex, "Couldn't unban user {0} in guild {1}", userId, guildId); Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId);
} }
}
else if (type == TimerType.AddRole) else if (type == TimerType.AddRole)
{
try try
{ {
RemoveTimerFromDb(guildId, userId, type); RemoveTimerFromDb(guildId, userId, type);
StopTimer(guildId, userId, type); StopTimer(guildId, userId, type);
var guild = _client.GetGuild(guildId); var guild = _client.GetGuild(guildId);
var user = guild?.GetUser(userId); var user = guild?.GetUser(userId);
var role = guild.GetRole(roleId.Value); var role = guild?.GetRole(roleId.Value);
if (guild is not null && user is not null && user.Roles.Contains(role)) if (guild is not null && user is not null && user.Roles.Contains(role))
await user.RemoveRoleAsync(role); await user.RemoveRoleAsync(role);
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning(ex, "Couldn't remove role from user {0} in guild {1}", userId, guildId); Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId);
} }
}
else else
{
try try
{ {
// unmute the user, this will also remove the timer from the db // unmute the user, this will also remove the timer from the db
@@ -435,8 +440,9 @@ public class MuteService : INService
catch (Exception ex) catch (Exception ex)
{ {
RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db
Log.Warning(ex, "Couldn't unmute user {0} in guild {1}", userId, guildId); Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId);
} }
}
}, },
null, null,
after, after,
@@ -454,7 +460,7 @@ public class MuteService : INService
public void StopTimer(ulong guildId, ulong userId, TimerType type) public void StopTimer(ulong guildId, ulong userId, TimerType type)
{ {
if (!Un_Timers.TryGetValue(guildId, out var userTimer)) if (!UnTimers.TryGetValue(guildId, out var userTimer))
return; return;
if (userTimer.TryRemove((userId, type), out var removed)) removed.Change(Timeout.Infinite, Timeout.Infinite); if (userTimer.TryRemove((userId, type), out var removed)) removed.Change(Timeout.Infinite, Timeout.Infinite);

View File

@@ -37,7 +37,8 @@ public sealed class PlayingRotateService : INService, IReadyExecutor
{ {
try try
{ {
if (!_bss.Data.RotateStatuses) return; if (!_bss.Data.RotateStatuses)
continue;
IReadOnlyList<RotatingPlayingStatus> rotatingStatuses; IReadOnlyList<RotatingPlayingStatus> rotatingStatuses;
await using (var uow = _db.GetDbContext()) await using (var uow = _db.GetDbContext())
@@ -46,7 +47,7 @@ public sealed class PlayingRotateService : INService, IReadyExecutor
} }
if (rotatingStatuses.Count == 0) if (rotatingStatuses.Count == 0)
return; continue;
var playingStatus = index >= rotatingStatuses.Count var playingStatus = index >= rotatingStatuses.Count
? rotatingStatuses[index = 0] ? rotatingStatuses[index = 0]

View File

@@ -243,8 +243,8 @@ public partial class Administration
var allShardStrings = statuses.Select(st => var allShardStrings = statuses.Select(st =>
{ {
var stateStr = ConnectionStateToEmoji(st);
var timeDiff = DateTime.UtcNow - st.LastUpdate; var timeDiff = DateTime.UtcNow - st.LastUpdate;
var stateStr = ConnectionStateToEmoji(st);
var maxGuildCountLength = var maxGuildCountLength =
statuses.Max(x => x.GuildCount).ToString().Length; statuses.Max(x => x.GuildCount).ToString().Length;
return $"`{stateStr} " return $"`{stateStr} "
@@ -272,9 +272,9 @@ public partial class Administration
var timeDiff = DateTime.UtcNow - status.LastUpdate; var timeDiff = DateTime.UtcNow - status.LastUpdate;
return status.ConnectionState switch return status.ConnectionState switch
{ {
ConnectionState.Connected => "✅",
ConnectionState.Disconnected => "🔻", ConnectionState.Disconnected => "🔻",
_ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ", _ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ",
ConnectionState.Connected => "✅",
_ => " ⏳" _ => " ⏳"
}; };
} }

View File

@@ -1,19 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db; using NadekoBot.Db;
using NadekoBot.Modules.Administration.Services; using NadekoBot.Modules.Administration.Services;
using NadekoBot.Services.Database.Models; using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Administration; namespace NadekoBot.Modules.Administration;
public sealed class LogCommandService : ILogCommandService public sealed class LogCommandService : ILogCommandService, IReadyExecutor
{ {
public ConcurrentDictionary<ulong, LogSetting> GuildLogSettings { get; } public ConcurrentDictionary<ulong, LogSetting> GuildLogSettings { get; }
private ConcurrentDictionary<ITextChannel, List<string>> PresenceUpdates { get; } = new(); private ConcurrentDictionary<ITextChannel, List<string>> PresenceUpdates { get; } = new();
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly Timer _timerReference;
private readonly IBotStrings _strings; private readonly IBotStrings _strings;
private readonly DbService _db; private readonly DbService _db;
private readonly MuteService _mute; private readonly MuteService _mute;
@@ -58,11 +58,6 @@ public sealed class LogCommandService : ILogCommandService
GuildLogSettings = configs.ToDictionary(ls => ls.GuildId).ToConcurrent(); GuildLogSettings = configs.ToDictionary(ls => ls.GuildId).ToConcurrent();
} }
_timerReference = new(Callback,
null,
TimeSpan.FromSeconds(15),
TimeSpan.FromSeconds(15));
//_client.MessageReceived += _client_MessageReceived; //_client.MessageReceived += _client_MessageReceived;
_client.MessageUpdated += _client_MessageUpdated; _client.MessageUpdated += _client_MessageUpdated;
_client.MessageDeleted += _client_MessageDeleted; _client.MessageDeleted += _client_MessageDeleted;
@@ -96,28 +91,34 @@ public sealed class LogCommandService : ILogCommandService
#endif #endif
} }
private async void Callback(object? state) public async Task OnReadyAsync()
{ {
try #if GLOBAL_NADEKO
var timer = new PeriodicTimer(TimeSpan.FromSeconds(15));
while (await timer.WaitForNextTickAsync())
{ {
var keys = PresenceUpdates.Keys.ToList(); try
{
await keys.Select(key => var keys = PresenceUpdates.Keys.ToList();
{
if (!((SocketGuild)key.Guild).CurrentUser.GetPermissions(key).SendMessages)
return Task.CompletedTask;
if (PresenceUpdates.TryRemove(key, out var msgs))
{
var title = GetText(key.Guild, strs.presence_updates);
var desc = string.Join(Environment.NewLine, msgs);
return key.SendConfirmAsync(_eb, title, desc.TrimTo(2048)!);
}
await keys.Select(key =>
{
if (!((SocketGuild)key.Guild).CurrentUser.GetPermissions(key).SendMessages)
return Task.CompletedTask; return Task.CompletedTask;
}) if (PresenceUpdates.TryRemove(key, out var msgs))
.WhenAll(); {
var title = GetText(key.Guild, strs.presence_updates);
var desc = string.Join(Environment.NewLine, msgs);
return key.SendConfirmAsync(_eb, title, desc.TrimTo(2048)!);
}
return Task.CompletedTask;
})
.WhenAll();
}
catch { }
} }
catch { } #endif
} }
public LogSetting? GetGuildLogSettings(ulong guildId) public LogSetting? GetGuildLogSettings(ulong guildId)

View File

@@ -362,7 +362,7 @@ public partial class Help : NadekoModule<HelpService>
ContentType = "application/json", ContentType = "application/json",
ContentBody = uploadData, ContentBody = uploadData,
// either use a path provided in the argument or the default one for public nadeko, other/cmds.json // either use a path provided in the argument or the default one for public nadeko, other/cmds.json
Key = $"cmds/{StatsService.BotVersion}.json", Key = $"cmds/{StatsService.BOT_VERSION}.json",
CannedACL = S3CannedACL.PublicRead CannedACL = S3CannedACL.PublicRead
}); });
} }
@@ -372,11 +372,11 @@ public partial class Help : NadekoModule<HelpService>
var versionListString = Encoding.UTF8.GetString(ms.ToArray()); var versionListString = Encoding.UTF8.GetString(ms.ToArray());
var versionList = JsonSerializer.Deserialize<List<string>>(versionListString); var versionList = JsonSerializer.Deserialize<List<string>>(versionListString);
if (versionList is not null && !versionList.Contains(StatsService.BotVersion)) if (versionList is not null && !versionList.Contains(StatsService.BOT_VERSION))
{ {
// save the file with new version added // save the file with new version added
// versionList.Add(StatsService.BotVersion); // versionList.Add(StatsService.BotVersion);
versionListString = JsonSerializer.Serialize(versionList.Prepend(StatsService.BotVersion), versionListString = JsonSerializer.Serialize(versionList.Prepend(StatsService.BOT_VERSION),
new JsonSerializerOptions { WriteIndented = true }); new JsonSerializerOptions { WriteIndented = true });
// upload the updated version list // upload the updated version list
@@ -395,7 +395,7 @@ public partial class Help : NadekoModule<HelpService>
{ {
Log.Warning( Log.Warning(
"Version {Version} already exists in the version file. " + "Did you forget to increment it?", "Version {Version} already exists in the version file. " + "Did you forget to increment it?",
StatsService.BotVersion); StatsService.BOT_VERSION);
} }
} }

View File

@@ -253,7 +253,7 @@ public partial class Utility : NadekoModule
await ctx.Channel.EmbedAsync(_eb.Create() await ctx.Channel.EmbedAsync(_eb.Create()
.WithOkColor() .WithOkColor()
.WithAuthor($"NadekoBot v{StatsService.BotVersion}", .WithAuthor($"NadekoBot v{StatsService.BOT_VERSION}",
"https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/avatar.png", "https://nadeko-pictures.nyc3.digitaloceanspaces.com/other/avatar.png",
"https://nadekobot.readthedocs.io/en/latest/") "https://nadekobot.readthedocs.io/en/latest/")
.AddField(GetText(strs.author), _stats.Author, true) .AddField(GetText(strs.author), _stats.Author, true)

View File

@@ -1,4 +1,5 @@
#nullable disable #nullable disable
using Humanizer.Localisation;
using NadekoBot.Common.ModuleBehaviors; using NadekoBot.Common.ModuleBehaviors;
using System.Diagnostics; using System.Diagnostics;
@@ -6,7 +7,7 @@ namespace NadekoBot.Services;
public class StatsService : IStatsService, IReadyExecutor, INService, IDisposable public class StatsService : IStatsService, IReadyExecutor, INService, IDisposable
{ {
public const string BotVersion = "4.0.0"; public const string BOT_VERSION = "4.0.0";
public string Author public string Author
=> "Kwoth#2452"; => "Kwoth#2452";
@@ -18,28 +19,27 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
=> MessageCounter / GetUptime().TotalSeconds; => MessageCounter / GetUptime().TotalSeconds;
public long TextChannels public long TextChannels
=> Interlocked.Read(ref _textChannels); => Interlocked.Read(ref textChannels);
public long VoiceChannels public long VoiceChannels
=> Interlocked.Read(ref _voiceChannels); => Interlocked.Read(ref voiceChannels);
public long MessageCounter public long MessageCounter
=> Interlocked.Read(ref _messageCounter); => Interlocked.Read(ref messageCounter);
public long CommandsRan public long CommandsRan
=> Interlocked.Read(ref _commandsRan); => Interlocked.Read(ref commandsRan);
private readonly Process _currentProcess = Process.GetCurrentProcess(); private readonly Process _currentProcess = Process.GetCurrentProcess();
private readonly DiscordSocketClient _client; private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds; private readonly IBotCredentials _creds;
private readonly DateTime _started; private readonly DateTime _started;
private long _textChannels; private long textChannels;
private long _voiceChannels; private long voiceChannels;
private long _messageCounter; private long messageCounter;
private long _commandsRan; private long commandsRan;
private readonly Timer _botlistTimer;
private readonly IHttpClientFactory _httpFactory; private readonly IHttpClientFactory _httpFactory;
public StatsService( public StatsService(
@@ -53,17 +53,17 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
_httpFactory = factory; _httpFactory = factory;
_started = DateTime.UtcNow; _started = DateTime.UtcNow;
_client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref _messageCounter)); _client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter));
cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref _commandsRan)); cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan));
_client.ChannelCreated += c => _client.ChannelCreated += c =>
{ {
var _ = Task.Run(() => var _ = Task.Run(() =>
{ {
if (c is ITextChannel) if (c is ITextChannel)
Interlocked.Increment(ref _textChannels); Interlocked.Increment(ref textChannels);
else if (c is IVoiceChannel) else if (c is IVoiceChannel)
Interlocked.Increment(ref _voiceChannels); Interlocked.Increment(ref voiceChannels);
}); });
return Task.CompletedTask; return Task.CompletedTask;
@@ -74,9 +74,9 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
var _ = Task.Run(() => var _ = Task.Run(() =>
{ {
if (c is ITextChannel) if (c is ITextChannel)
Interlocked.Decrement(ref _textChannels); Interlocked.Decrement(ref textChannels);
else if (c is IVoiceChannel) else if (c is IVoiceChannel)
Interlocked.Decrement(ref _voiceChannels); Interlocked.Decrement(ref voiceChannels);
}); });
return Task.CompletedTask; return Task.CompletedTask;
@@ -88,8 +88,8 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
{ {
var tc = g.Channels.Count(cx => cx is ITextChannel); var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc; var vc = g.Channels.Count - tc;
Interlocked.Add(ref _textChannels, tc); Interlocked.Add(ref textChannels, tc);
Interlocked.Add(ref _voiceChannels, vc); Interlocked.Add(ref voiceChannels, vc);
}); });
return Task.CompletedTask; return Task.CompletedTask;
}; };
@@ -100,8 +100,8 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
{ {
var tc = g.Channels.Count(cx => cx is ITextChannel); var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc; var vc = g.Channels.Count - tc;
Interlocked.Add(ref _textChannels, tc); Interlocked.Add(ref textChannels, tc);
Interlocked.Add(ref _voiceChannels, vc); Interlocked.Add(ref voiceChannels, vc);
}); });
return Task.CompletedTask; return Task.CompletedTask;
}; };
@@ -112,8 +112,8 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
{ {
var tc = g.Channels.Count(cx => cx is ITextChannel); var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc; var vc = g.Channels.Count - tc;
Interlocked.Add(ref _textChannels, -tc); Interlocked.Add(ref textChannels, -tc);
Interlocked.Add(ref _voiceChannels, -vc); Interlocked.Add(ref voiceChannels, -vc);
}); });
return Task.CompletedTask; return Task.CompletedTask;
@@ -125,60 +125,57 @@ public class StatsService : IStatsService, IReadyExecutor, INService, IDisposabl
{ {
var tc = g.Channels.Count(cx => cx is ITextChannel); var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc; var vc = g.Channels.Count - tc;
Interlocked.Add(ref _textChannels, -tc); Interlocked.Add(ref textChannels, -tc);
Interlocked.Add(ref _voiceChannels, -vc); Interlocked.Add(ref voiceChannels, -vc);
}); });
return Task.CompletedTask; return Task.CompletedTask;
}; };
_botlistTimer = new(async _ =>
{
if (string.IsNullOrWhiteSpace(_creds.BotListToken))
return;
try
{
using var http = _httpFactory.CreateClient();
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "shard_count", _creds.TotalShards.ToString() },
{ "shard_id", client.ShardId.ToString() },
{ "server_count", client.Guilds.Count().ToString() }
});
content.Headers.Clear();
content.Headers.Add("Content-Type", "application/x-www-form-urlencoded");
http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken);
using (await http.PostAsync(
new Uri($"https://discordbots.org/api/bots/{client.CurrentUser.Id}/stats"),
content)) { }
}
catch (Exception ex)
{
Log.Error(ex, "Error ");
// ignored
}
},
null,
TimeSpan.FromMinutes(5),
TimeSpan.FromHours(1));
} }
public async Task OnReadyAsync()
{
var guilds = _client.Guilds;
textChannels = guilds.Sum(g => g.Channels.Count(cx => cx is ITextChannel));
voiceChannels = guilds.Sum(g => g.Channels.Count(cx => cx is IVoiceChannel));
var timer = new PeriodicTimer(TimeSpan.FromHours(1));
do
{
if (string.IsNullOrWhiteSpace(_creds.BotListToken))
continue;
try
{
using var http = _httpFactory.CreateClient();
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "shard_count", _creds.TotalShards.ToString() },
{ "shard_id", _client.ShardId.ToString() },
{ "server_count", _client.Guilds.Count().ToString() }
});
content.Headers.Clear();
content.Headers.Add("Content-Type", "application/x-www-form-urlencoded");
http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken);
using var res = await http.PostAsync(
new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"),
content);
}
catch (Exception ex)
{
Log.Error(ex, "Error in botlist post");
}
} while (await timer.WaitForNextTickAsync());
}
public TimeSpan GetUptime() public TimeSpan GetUptime()
=> DateTime.UtcNow - _started; => DateTime.UtcNow - _started;
public string GetUptimeString(string separator = ", ") public string GetUptimeString(string separator = ", ")
{ {
var time = GetUptime(); var time = GetUptime();
return $"{time.Days} days{separator}{time.Hours} hours{separator}{time.Minutes} minutes"; return time.Humanize(3, maxUnit: TimeUnit.Day, minUnit: TimeUnit.Minute);
}
public Task OnReadyAsync()
{
var guilds = _client.Guilds;
_textChannels = guilds.Sum(g => g.Channels.Count(cx => cx is ITextChannel));
_voiceChannels = guilds.Sum(g => g.Channels.Count(cx => cx is IVoiceChannel));
return Task.CompletedTask;
} }
public double GetPrivateMemory() public double GetPrivateMemory()

View File

@@ -81,6 +81,6 @@ prefix: .
# and (slightly) reduce the greet spam in those servers. # and (slightly) reduce the greet spam in those servers.
groupGreets: false groupGreets: false
# Whether the bot will rotate through all specified statuses. # Whether the bot will rotate through all specified statuses.
# This setting can be changed via .rots command. # This setting can be changed via .ropl command.
# See RotatingStatuses submodule in Administration. # See RotatingStatuses submodule in Administration.
rotateStatuses: false rotateStatuses: false