Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,345 changes: 0 additions & 1,345 deletions docs/superpowers/plans/2026-06-17-rustplusbot-3b-ii-team-intel.md

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,12 @@ public interface IRustServerQuery
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A team snapshot, or null when there is no live socket.</returns>
Task<TeamInfoSnapshot?> GetTeamInfoAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);

/// <summary>Promotes a team member to team leader; returns false when there is no live socket or the API fails.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="steamId">Steam64 id of the member to promote.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>True if promoted; false when no live socket or the API fails.</returns>
Task<bool> PromoteToLeaderAsync(ulong guildId, Guid serverId, ulong steamId, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using RustPlusBot.Discord;
using RustPlusBot.Features.Commands.Dispatching;
using RustPlusBot.Features.Commands.Handlers;
using RustPlusBot.Features.Commands.Help;
using RustPlusBot.Features.Commands.Hosting;
using RustPlusBot.Features.Commands.Leader;
using RustPlusBot.Features.Commands.Localization;

namespace RustPlusBot.Features.Commands;
Expand Down Expand Up @@ -38,6 +41,11 @@ public static IServiceCollection AddCommands(this IServiceCollection services)
services.AddScoped<CommandDispatcher>();
services.AddHostedService<CommandsHostedService>();

// Discord surfaces (3c): help renderer, leader service, and the interaction modules.
services.AddScoped<HelpEmbedRenderer>();
services.AddScoped<LeaderService>();
services.AddSingleton(new InteractionModuleAssembly(typeof(CommandServiceCollectionExtensions).Assembly));

return services;
}
}
17 changes: 17 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandGroup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace RustPlusBot.Features.Commands.Help;

/// <summary>The display grouping for a command in the <c>/help</c> listing.</summary>
internal enum CommandGroup
{
/// <summary>Bot control commands (mute/unmute).</summary>
Control = 0,

/// <summary>Server-state commands (pop/time/wipe).</summary>
Server = 1,

/// <summary>Team-intel commands (online/offline/team/steamid/alive/prox).</summary>
TeamIntel = 2,

/// <summary>Bot-meta commands (uptime).</summary>
Bot = 3,
}
36 changes: 36 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/CommandHelpCatalog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace RustPlusBot.Features.Commands.Help;

/// <summary>One entry in the help listing.</summary>
/// <param name="Name">The command name without prefix (in-game) or slash command name.</param>
/// <param name="Group">The display group.</param>
/// <param name="DescriptionKey">The localization key for the description.</param>
internal sealed record CommandHelpEntry(string Name, CommandGroup Group, string DescriptionKey);

/// <summary>The curated help manifest. Tested against the live handler registry to catch drift.</summary>
internal static class CommandHelpCatalog
{
/// <summary>The in-game <c>!commands</c>, in display order.</summary>
public static IReadOnlyList<CommandHelpEntry> InGame { get; } =
[
new("mute", CommandGroup.Control, "help.mute"),
new("unmute", CommandGroup.Control, "help.unmute"),
new("pop", CommandGroup.Server, "help.pop"),
new("time", CommandGroup.Server, "help.time"),
new("wipe", CommandGroup.Server, "help.wipe"),
new("online", CommandGroup.TeamIntel, "help.online"),
new("offline", CommandGroup.TeamIntel, "help.offline"),
new("team", CommandGroup.TeamIntel, "help.team"),
new("steamid", CommandGroup.TeamIntel, "help.steamid"),
new("alive", CommandGroup.TeamIntel, "help.alive"),
new("prox", CommandGroup.TeamIntel, "help.prox"),
new("uptime", CommandGroup.Bot, "help.uptime"),
];

/// <summary>The Discord slash commands, in display order.</summary>
public static IReadOnlyList<CommandHelpEntry> Slash { get; } =
[
new("help", CommandGroup.Bot, "help.slash.help"),
new("uptime", CommandGroup.Bot, "help.slash.uptime"),
new("leader", CommandGroup.Bot, "help.slash.leader"),
];
}
56 changes: 56 additions & 0 deletions src/RustPlusBot.Features.Commands/Help/HelpEmbedRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Globalization;
using Discord;
using RustPlusBot.Features.Commands.Localization;

namespace RustPlusBot.Features.Commands.Help;

/// <summary>Builds the <c>/help</c> embed from the catalog, the server prefix, and the guild culture.</summary>
/// <param name="localizer">Resolves the title, group headings, and per-command descriptions.</param>
internal sealed class HelpEmbedRenderer(ICommandLocalizer localizer)
{
private static readonly (CommandGroup Group, string HeadingKey)[] GroupOrder =
[
(CommandGroup.Control, "help.group.control"),
(CommandGroup.Server, "help.group.server"),
(CommandGroup.TeamIntel, "help.group.teamintel"),
(CommandGroup.Bot, "help.group.bot"),
];

/// <summary>Renders the help embed.</summary>
/// <param name="prefix">The in-game command prefix to display (e.g. "!").</param>
/// <param name="culture">The guild culture ("en"/"fr").</param>
/// <param name="showPrefixNote">True to add the multi-server prefix note to the description.</param>
/// <returns>The built embed.</returns>
public Embed Render(string prefix, string culture, bool showPrefixNote)
{
var embed = new EmbedBuilder()
.WithTitle(localizer.Get("help.title", culture))
.WithColor(Color.Blue);

if (showPrefixNote)
{
embed.WithDescription(localizer.Get("help.prefixnote", culture));
}

foreach (var (group, headingKey) in GroupOrder)
{
var lines = CommandHelpCatalog.InGame
.Where(e => e.Group == group)
.Select(e => string.Create(CultureInfo.InvariantCulture,
$"`{prefix}{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}"))
.ToList();
if (lines.Count > 0)
{
embed.AddField(localizer.Get(headingKey, culture), string.Join('\n', lines));
}
}

var slashLines = CommandHelpCatalog.Slash
.Select(e => string.Create(CultureInfo.InvariantCulture,
$"`/{e.Name}` — {localizer.Get(e.DescriptionKey, culture)}"))
.ToList();
embed.AddField(localizer.Get("help.group.slash", culture), string.Join('\n', slashLines));

return embed.Build();
}
}
73 changes: 73 additions & 0 deletions src/RustPlusBot.Features.Commands/Leader/LeaderService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using RustPlusBot.Features.Commands.Localization;
using RustPlusBot.Features.Connections.Listening;

namespace RustPlusBot.Features.Commands.Leader;

/// <summary>One selectable team member for the <c>/leader</c> promote select.</summary>
/// <param name="SteamId">The member's Steam64 id.</param>
/// <param name="Name">The member's in-game name.</param>
/// <param name="IsLeader">True if this member is the current team leader.</param>
internal sealed record LeaderMemberOption(ulong SteamId, string Name, bool IsLeader);

/// <summary>The members to offer, or an error message to show instead.</summary>
/// <param name="Members">The selectable members (empty when <paramref name="ErrorMessage"/> is set).</param>
/// <param name="ErrorMessage">A localized message to show instead of a select, or null to proceed.</param>
internal sealed record LeaderTeamResult(IReadOnlyList<LeaderMemberOption> Members, string? ErrorMessage);

/// <summary>The testable logic behind <c>/leader</c>: fetch live members, then promote one.</summary>
/// <param name="query">The live-server query seam.</param>
/// <param name="localizer">Resolves the result/error messages.</param>
internal sealed class LeaderService(IRustServerQuery query, ICommandLocalizer localizer)
{
/// <summary>Fetches the current team for the promote select, or an error message.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="culture">The guild culture.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The members to offer, or an error message.</returns>
public async Task<LeaderTeamResult> GetMembersAsync(
ulong guildId,
Guid serverId,
string culture,
CancellationToken cancellationToken)
{
var team = await query.GetTeamInfoAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
if (team is null)
{
return new LeaderTeamResult([], localizer.Get("leader.notconnected", culture));
}

if (team.Members.Count == 0)
{
return new LeaderTeamResult([], localizer.Get("leader.noteam", culture));
}

var members = team.Members
.Select(m => new LeaderMemberOption(m.SteamId, m.Name, m.SteamId == team.LeaderSteamId))
.ToList();
return new LeaderTeamResult(members, null);
}

/// <summary>Promotes a member and returns the localized result message.</summary>
/// <param name="guildId">The owning guild snowflake.</param>
/// <param name="serverId">The target server id.</param>
/// <param name="steamId">The Steam64 id to promote.</param>
/// <param name="memberName">The member's name (for the success message).</param>
/// <param name="culture">The guild culture.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The localized result message.</returns>
public async Task<string> PromoteAsync(
ulong guildId,
Guid serverId,
ulong steamId,
string memberName,
string culture,
CancellationToken cancellationToken)
{
var promoted = await query.PromoteToLeaderAsync(guildId, serverId, steamId, cancellationToken)
.ConfigureAwait(false);
return promoted
? localizer.Get("leader.success", culture, memberName)
: localizer.Get("leader.failure", culture);
}
}
Loading
Loading