Skip to content

Commit 4891830

Browse files
HandyS11claude
andcommitted
feat(alarms): add AlarmEmbedRenderer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1788dc4 commit 4891830

3 files changed

Lines changed: 450 additions & 0 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
using System.Globalization;
2+
using Discord;
3+
using RustPlusBot.Abstractions.Time;
4+
using RustPlusBot.Domain.Alarms;
5+
6+
namespace RustPlusBot.Features.Alarms.Rendering;
7+
8+
/// <summary>Renders a Smart Alarm as a Discord embed + control row, and the pairing-prompt embed + row. Pure.</summary>
9+
/// <param name="localizer">The alarm localizer.</param>
10+
/// <param name="clock">The clock used to compute relative trigger times.</param>
11+
internal sealed class AlarmEmbedRenderer(AlarmLocalizer localizer, IClock clock)
12+
{
13+
/// <summary>Renders the alarm embed and its control buttons.</summary>
14+
/// <param name="alarm">The alarm to render.</param>
15+
/// <param name="unreachable">When true the alarm entity is currently unreachable.</param>
16+
/// <param name="culture">The guild culture (BCP-47 primary tag).</param>
17+
/// <returns>The embed and the component rows.</returns>
18+
public (Embed Embed, MessageComponent Components) RenderAlarm(SmartAlarm alarm, bool unreachable, string culture)
19+
{
20+
ArgumentNullException.ThrowIfNull(alarm);
21+
22+
string statusKey;
23+
if (unreachable)
24+
{
25+
statusKey = "alarm.status.unreachable";
26+
}
27+
else if (alarm.LastIsActive)
28+
{
29+
statusKey = "alarm.status.active";
30+
}
31+
else
32+
{
33+
statusKey = "alarm.status.armed";
34+
}
35+
36+
var triggered = alarm.LastTriggeredUtc is { } t
37+
? localizer.Get("alarm.embed.lasttriggered", culture, CompactDuration(clock.UtcNow - t))
38+
: localizer.Get("alarm.embed.nevertriggered", culture);
39+
40+
var embed = new EmbedBuilder()
41+
.WithTitle(alarm.Name)
42+
.WithDescription($"{localizer.Get(statusKey, culture)}\n{triggered}")
43+
.WithFooter(localizer.Get("alarm.embed.footer", culture, alarm.EntityId))
44+
.Build();
45+
46+
var tail = $"{alarm.ServerId}:{alarm.EntityId}";
47+
var pingKey = alarm.PingEveryone ? "alarm.button.ping.on" : "alarm.button.ping.off";
48+
var relayKey = alarm.RelayToTeamChat ? "alarm.button.relay.on" : "alarm.button.relay.off";
49+
var components = new ComponentBuilder()
50+
.WithButton(localizer.Get(pingKey, culture), AlarmComponentIds.PingTogglePrefix + tail,
51+
alarm.PingEveryone ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
52+
.WithButton(localizer.Get(relayKey, culture), AlarmComponentIds.RelayTogglePrefix + tail,
53+
alarm.RelayToTeamChat ? ButtonStyle.Success : ButtonStyle.Secondary, disabled: unreachable)
54+
.WithButton(localizer.Get("alarm.button.rename", culture), AlarmComponentIds.RenamePrefix + tail,
55+
ButtonStyle.Secondary, disabled: unreachable)
56+
.Build();
57+
58+
return (embed, components);
59+
}
60+
61+
/// <summary>Renders the transient "New alarm detected — Add it?" prompt.</summary>
62+
/// <param name="serverId">The server id.</param>
63+
/// <param name="entityId">The entity id.</param>
64+
/// <param name="defaultName">The generated default name.</param>
65+
/// <param name="culture">The guild culture (BCP-47 primary tag).</param>
66+
/// <returns>The prompt embed and Accept/Dismiss row.</returns>
67+
public (Embed Embed, MessageComponent Components) RenderPrompt(
68+
Guid serverId,
69+
ulong entityId,
70+
string defaultName,
71+
string culture)
72+
{
73+
var embed = new EmbedBuilder()
74+
.WithTitle(localizer.Get("alarm.prompt.title", culture))
75+
.WithDescription(localizer.Get("alarm.prompt.body", culture, defaultName))
76+
.Build();
77+
78+
var tail = $"{serverId}:{entityId}";
79+
var components = new ComponentBuilder()
80+
.WithButton(localizer.Get("alarm.prompt.accept", culture), AlarmComponentIds.AcceptPrefix + tail,
81+
ButtonStyle.Success)
82+
.WithButton(localizer.Get("alarm.prompt.dismiss", culture), AlarmComponentIds.DismissPrefix + tail,
83+
ButtonStyle.Secondary)
84+
.Build();
85+
86+
return (embed, components);
87+
}
88+
89+
/// <summary>Formats a duration compactly: "5m", "2h 10m", "3d 4h", "&lt;1m".</summary>
90+
/// <param name="span">The duration to format.</param>
91+
/// <returns>A compact human-readable duration string.</returns>
92+
private static string CompactDuration(TimeSpan span)
93+
{
94+
if (span.TotalDays >= 1)
95+
{
96+
return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h");
97+
}
98+
99+
if (span.TotalHours >= 1)
100+
{
101+
return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m");
102+
}
103+
104+
if (span.TotalMinutes >= 1)
105+
{
106+
return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m");
107+
}
108+
109+
return "<1m";
110+
}
111+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
using System.Globalization;
2+
3+
namespace RustPlusBot.Features.Alarms.Rendering;
4+
5+
/// <summary>Dictionary-backed localizer for Smart Alarms with English fallback and region normalization.</summary>
6+
/// <remarks>Mirrors the SwitchLocalizer pattern; a future refactor may hoist a shared implementation.</remarks>
7+
/// <param name="catalog">The culture → (key → value) catalog.</param>
8+
internal sealed class AlarmLocalizer(IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> catalog)
9+
{
10+
private const string FallbackCulture = "en";
11+
12+
/// <summary>Gets the localized string for a key, or the key itself if not found.</summary>
13+
/// <param name="key">The string key to resolve.</param>
14+
/// <param name="culture">The BCP-47 culture tag (e.g. "en", "fr").</param>
15+
public string Get(string key, string culture)
16+
{
17+
var normalized = Normalize(culture);
18+
if (catalog.TryGetValue(normalized, out var map) && map.TryGetValue(key, out var value))
19+
{
20+
return value;
21+
}
22+
23+
if (catalog.TryGetValue(FallbackCulture, out var fallback) &&
24+
fallback.TryGetValue(key, out var fallbackValue))
25+
{
26+
return fallbackValue;
27+
}
28+
29+
return key;
30+
}
31+
32+
/// <summary>Gets the localized, <see cref="string.Format(IFormatProvider, string, object?[])"/>-applied string.</summary>
33+
/// <param name="key">The string key to resolve.</param>
34+
/// <param name="culture">The BCP-47 culture tag (e.g. "en", "fr").</param>
35+
/// <param name="args">Format arguments.</param>
36+
public string Get(string key, string culture, params object[] args)
37+
{
38+
var format = Get(key, culture);
39+
var provider = ResolveFormatProvider(Normalize(culture));
40+
return string.Format(provider, format, args);
41+
}
42+
43+
private static string Normalize(string culture)
44+
{
45+
if (string.IsNullOrWhiteSpace(culture))
46+
{
47+
return FallbackCulture;
48+
}
49+
50+
var dash = culture.IndexOf('-', StringComparison.Ordinal);
51+
var primary = dash >= 0 ? culture[..dash] : culture;
52+
return primary.ToLowerInvariant();
53+
}
54+
55+
private static CultureInfo ResolveFormatProvider(string culture)
56+
{
57+
try
58+
{
59+
return CultureInfo.GetCultureInfo(culture);
60+
}
61+
catch (CultureNotFoundException)
62+
{
63+
return CultureInfo.InvariantCulture;
64+
}
65+
}
66+
}

0 commit comments

Comments
 (0)