Skip to content

Commit 00f1e10

Browse files
committed
feat(commands): in-game !afk command over IAfkState
1 parent 6fc2a42 commit 00f1e10

5 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/RustPlusBot.Features.Commands/CommandServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
3737
services.AddScoped<ICommandHandler, TeamCommandHandler>();
3838
services.AddScoped<ICommandHandler, SteamIdCommandHandler>();
3939
services.AddScoped<ICommandHandler, AliveCommandHandler>();
40+
services.AddScoped<ICommandHandler, AfkCommandHandler>();
4041
services.AddScoped<ICommandHandler, ProxCommandHandler>();
4142
services.AddScoped<ICommandHandler, CargoCommandHandler>();
4243
services.AddScoped<ICommandHandler, HeliCommandHandler>();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using RustPlusBot.Features.Commands.Dispatching;
2+
using RustPlusBot.Features.Commands.Formatting;
3+
using RustPlusBot.Features.Commands.Localization;
4+
using RustPlusBot.Features.Connections.Listening;
5+
6+
namespace RustPlusBot.Features.Commands.Handlers;
7+
8+
/// <summary>!afk — lists team members who have been still (online + alive) past the AFK threshold.</summary>
9+
/// <param name="afk">The live AFK state.</param>
10+
/// <param name="localizer">The reply localizer.</param>
11+
internal sealed class AfkCommandHandler(IAfkState afk, ICommandLocalizer localizer) : ICommandHandler
12+
{
13+
/// <inheritdoc />
14+
public string Name => "afk";
15+
16+
/// <inheritdoc />
17+
public async Task<string?> ExecuteAsync(CommandContext context, CancellationToken cancellationToken)
18+
{
19+
ArgumentNullException.ThrowIfNull(context);
20+
var members = await afk.GetAfkMembersAsync(context.GuildId, context.ServerId, cancellationToken)
21+
.ConfigureAwait(false);
22+
if (members is null)
23+
{
24+
return localizer.Get("command.notconnected", context.Culture);
25+
}
26+
27+
if (members.Count == 0)
28+
{
29+
return localizer.Get("command.afk.none", context.Culture);
30+
}
31+
32+
var parts = members
33+
.OrderByDescending(m => m.StillFor)
34+
.Select(m => localizer.Get(
35+
"command.afk.member", context.Culture, m.Name, DurationFormat.Compact(m.StillFor)));
36+
return localizer.Get("command.afk.ok", context.Culture, string.Join(", ", parts));
37+
}
38+
}

src/RustPlusBot.Features.Commands/Localization/CommandLocalizationCatalog.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ internal sealed class CommandLocalizationCatalog
3131
["command.team.none"] = "No team members.",
3232
["command.team.nomatch"] = "No teammate matches '{0}'.",
3333
["command.steamid.ok"] = "{0}",
34+
["command.afk.ok"] = "AFK: {0}",
35+
["command.afk.none"] = "Nobody is AFK.",
36+
["command.afk.member"] = "{0} ({1})",
3437
["command.alive.ok"] = "Alive: {0}",
3538
["command.alive.dead"] = "{0} dead",
3639
["command.alive.member"] = "{0} {1}",
@@ -111,6 +114,9 @@ internal sealed class CommandLocalizationCatalog
111114
["command.team.none"] = "Aucun membre d'équipe.",
112115
["command.team.nomatch"] = "Aucun coéquipier ne correspond à « {0} ».",
113116
["command.steamid.ok"] = "{0}",
117+
["command.afk.ok"] = "AFK : {0}",
118+
["command.afk.none"] = "Personne n'est AFK.",
119+
["command.afk.member"] = "{0} ({1})",
114120
["command.alive.ok"] = "En vie : {0}",
115121
["command.alive.dead"] = "{0} mort",
116122
["command.alive.member"] = "{0} {1}",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using NSubstitute;
2+
using RustPlusBot.Features.Commands.Dispatching;
3+
using RustPlusBot.Features.Commands.Handlers;
4+
using RustPlusBot.Features.Commands.Localization;
5+
using RustPlusBot.Features.Connections.Listening;
6+
7+
namespace RustPlusBot.Features.Commands.Tests;
8+
9+
public sealed class AfkCommandHandlerTests
10+
{
11+
private readonly IAfkState _afk = Substitute.For<IAfkState>();
12+
private readonly ICommandLocalizer _localizer = new CommandLocalizer(CommandLocalizationCatalog.Default);
13+
14+
private static CommandContext Ctx() => new(1, Guid.NewGuid(), "en", 99, "Caller", []);
15+
16+
[Fact]
17+
public async Task Name_is_afk() => Assert.Equal("afk", new AfkCommandHandler(_afk, _localizer).Name);
18+
19+
[Fact]
20+
public async Task Reports_not_connected_when_state_null()
21+
{
22+
_afk.GetAfkMembersAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
23+
.Returns((IReadOnlyList<AfkMember>?)null);
24+
var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
25+
Assert.Equal(_localizer.Get("command.notconnected", "en"), reply);
26+
}
27+
28+
[Fact]
29+
public async Task Reports_none_when_empty()
30+
{
31+
_afk.GetAfkMembersAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
32+
.Returns([]);
33+
var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
34+
Assert.Equal(_localizer.Get("command.afk.none", "en"), reply);
35+
}
36+
37+
[Fact]
38+
public async Task Lists_afk_members_with_durations()
39+
{
40+
_afk.GetAfkMembersAsync(Arg.Any<ulong>(), Arg.Any<Guid>(), Arg.Any<CancellationToken>())
41+
.Returns(new List<AfkMember> { new(1, "Bob", TimeSpan.FromMinutes(6)) });
42+
var reply = await new AfkCommandHandler(_afk, _localizer).ExecuteAsync(Ctx(), CancellationToken.None);
43+
Assert.Contains("Bob", reply, StringComparison.Ordinal);
44+
}
45+
}

tests/RustPlusBot.Features.Commands.Tests/CommandRegistrationTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public void Dispatcher_and_handlers_resolve()
2929
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
3030
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
3131
services.AddSingleton<IRigState>(_ => Substitute.For<IRigState>());
32+
services.AddSingleton<IAfkState>(_ => Substitute.For<IAfkState>());
3233
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
3334
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
3435
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());
@@ -47,7 +48,7 @@ public void Dispatcher_and_handlers_resolve()
4748
using var scope = provider.CreateScope();
4849
Assert.NotNull(scope.ServiceProvider.GetRequiredService<CommandDispatcher>());
4950
var handlers = scope.ServiceProvider.GetServices<ICommandHandler>().ToList();
50-
Assert.Equal(18, handlers.Count);
51+
Assert.Equal(19, handlers.Count);
5152
Assert.Contains(handlers, h => h.Name == "mute");
5253
Assert.Contains(handlers, h => h.Name == "pop");
5354
Assert.Contains(handlers, h => h.Name == "time");
@@ -64,6 +65,7 @@ public void Dispatcher_and_handlers_resolve()
6465
Assert.Contains(handlers, h => h.Name == "events");
6566
Assert.Contains(handlers, h => h.Name == "small");
6667
Assert.Contains(handlers, h => h.Name == "large");
68+
Assert.Contains(handlers, h => h.Name == "afk");
6769
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerResolver>());
6870
Assert.NotNull(scope.ServiceProvider.GetRequiredService<ServerQueryService>());
6971
}
@@ -79,6 +81,7 @@ public void Commands_contribute_an_interaction_module_assembly()
7981
services.AddSingleton<IRustServerQuery>(Substitute.For<IRustServerQuery>());
8082
services.AddSingleton<IEventState>(_ => Substitute.For<IEventState>());
8183
services.AddSingleton<IRigState>(_ => Substitute.For<IRigState>());
84+
services.AddSingleton<IAfkState>(_ => Substitute.For<IAfkState>());
8285
services.AddScoped<IMuteStore>(_ => Substitute.For<IMuteStore>());
8386
services.AddScoped<IWorkspaceStore>(_ => Substitute.For<IWorkspaceStore>());
8487
services.AddScoped<IServerService>(_ => Substitute.For<IServerService>());

0 commit comments

Comments
 (0)