diff --git a/RustPlusBot.slnx b/RustPlusBot.slnx
index ea26c15..d4f64b7 100644
--- a/RustPlusBot.slnx
+++ b/RustPlusBot.slnx
@@ -12,6 +12,7 @@
+
@@ -24,6 +25,7 @@
+
diff --git a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
index e948f30..65ef81a 100644
--- a/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
+++ b/src/RustPlusBot.Abstractions/Connections/IRustServerQuery.cs
@@ -67,6 +67,18 @@ Task> GetMonumentsAsync(
ulong entityId,
CancellationToken cancellationToken);
+ /// Reads a storage monitor's contents for a (guild, server), or null when there is no live socket.
+ /// The guild snowflake.
+ /// The server id.
+ /// The storage-monitor entity id.
+ /// A cancellation token.
+ /// The contents snapshot, or null when unreachable.
+ Task GetStorageContentsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken);
+
/// Sets a smart switch on/off; returns false when there is no live socket or the call fails.
/// The owning guild snowflake.
/// The target server id.
diff --git a/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs
new file mode 100644
index 0000000..77c8c0d
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Connections/StorageContentsSnapshot.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Abstractions.Connections;
+
+/// A point-in-time read of a storage monitor's contents and protection state.
+/// Total slot count (24 = Tool Cupboard, 48 = large box, 12 = small box), or null if unknown.
+/// For a Tool Cupboard: whether decay protection is active. Null for non-TC monitors.
+/// When decay protection expires (UTC). Only meaningful when is true.
+/// The stacks currently inside the monitor.
+public sealed record StorageContentsSnapshot(
+ int? Capacity,
+ bool? HasProtection,
+ DateTimeOffset? ProtectionExpiry,
+ IReadOnlyList Items);
diff --git a/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs b/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs
new file mode 100644
index 0000000..1af0093
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Connections/StorageItemSnapshot.cs
@@ -0,0 +1,7 @@
+namespace RustPlusBot.Abstractions.Connections;
+
+/// One stack inside a storage monitor: the item id, its quantity, and whether it is a blueprint.
+/// The Rust item id (resolve to a display name via the item-name lookup).
+/// The stack quantity.
+/// True when the stack is a blueprint rather than the item itself.
+public sealed record StorageItemSnapshot(int ItemId, int Quantity, bool IsBlueprint);
diff --git a/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs b/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs
new file mode 100644
index 0000000..414b8b9
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/StorageMonitorPairedEvent.cs
@@ -0,0 +1,7 @@
+namespace RustPlusBot.Abstractions.Events;
+
+/// A storage monitor was paired in-game via FCM; the feature offers an "Add it?" prompt.
+/// The owning Discord guild snowflake.
+/// The local Rust server id.
+/// The in-game storage-monitor entity id.
+public sealed record StorageMonitorPairedEvent(ulong GuildId, Guid ServerId, ulong EntityId);
diff --git a/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs b/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs
new file mode 100644
index 0000000..cba3ea1
--- /dev/null
+++ b/src/RustPlusBot.Abstractions/Events/StorageMonitorTriggeredEvent.cs
@@ -0,0 +1,14 @@
+using RustPlusBot.Abstractions.Connections;
+
+namespace RustPlusBot.Abstractions.Events;
+
+/// A managed storage monitor's contents were (re)read — on connect-prime or on an in-game change.
+/// The owning Discord guild snowflake.
+/// The local Rust server id.
+/// The in-game entity id (the discriminant — the feature filters to ids it manages).
+/// The contents snapshot carried on the read/broadcast.
+public sealed record StorageMonitorTriggeredEvent(
+ ulong GuildId,
+ Guid ServerId,
+ ulong EntityId,
+ StorageContentsSnapshot Contents);
diff --git a/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs b/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs
new file mode 100644
index 0000000..bf9086f
--- /dev/null
+++ b/src/RustPlusBot.Domain/StorageMonitors/SmartStorageMonitor.cs
@@ -0,0 +1,29 @@
+namespace RustPlusBot.Domain.StorageMonitors;
+
+/// A paired Smart Storage Monitor the bot manages, surviving restarts. Guild- and server-scoped.
+public sealed class SmartStorageMonitor
+{
+ /// Surrogate primary key.
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ /// The owning Discord guild snowflake.
+ public ulong GuildId { get; set; }
+
+ /// The server this monitor belongs to (FK to RustServer, cascade delete).
+ public Guid ServerId { get; set; }
+
+ /// The in-game storage-monitor entity id.
+ public ulong EntityId { get; set; }
+
+ /// User-facing label; defaults to a generated "Storage Monitor <EntityId>" (the FCM event carries no name).
+ public string Name { get; set; } = string.Empty;
+
+ /// The Discord message id of this monitor's embed, or null until first posted.
+ public ulong? MessageId { get; set; }
+
+ /// The Discord user who accepted (validated) the pairing.
+ public ulong PairedByUserId { get; set; }
+
+ /// When the monitor was accepted (UTC).
+ public DateTimeOffset CreatedUtc { get; set; }
+}
diff --git a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
index 0ddc88b..7f22ead 100644
--- a/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/IRustServerConnection.cs
@@ -56,6 +56,15 @@ internal interface IRustServerConnection : IAsyncDisposable
/// True/false for on/off, or null on failure/timeout.
Task GetSmartDeviceInfoAsync(ulong entityId, TimeSpan timeout, CancellationToken cancellationToken);
+ /// Reads a storage monitor's contents, or null on failure/timeout. Also primes the socket's interest so triggers fire for it thereafter.
+ /// The in-game storage-monitor entity id.
+ /// How long to wait for the response.
+ /// A cancellation token.
+ /// The contents snapshot, or null on failure/timeout.
+ Task GetStorageMonitorInfoAsync(ulong entityId,
+ TimeSpan timeout,
+ CancellationToken cancellationToken);
+
/// Sets a smart switch on/off; returns true on success, false on failure/timeout.
/// The in-game smart-switch entity id.
/// True to turn on, false to turn off.
@@ -111,4 +120,7 @@ Task> GetMonumentsAsync(TimeSpan timeout,
/// Raised when a managed smart device's state changes in-game; carries the entity id and new state.
event EventHandler? SmartDeviceTriggered;
+
+ /// Raised when a managed storage monitor's contents change in-game; carries the entity id and the new contents.
+ event EventHandler? StorageMonitorTriggered;
}
diff --git a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
index 8184a07..01f7dea 100644
--- a/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
+++ b/src/RustPlusBot.Features.Connections/Listening/RustPlusSocketSource.cs
@@ -1,4 +1,5 @@
using System.Globalization;
+using System.Linq;
using Microsoft.Extensions.Logging;
using RustPlusApi;
using RustPlusBot.Abstractions.Connections;
@@ -54,6 +55,11 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
CancellationToken cancellationToken) =>
Task.FromResult(null);
+ public Task GetStorageMonitorInfoAsync(ulong entityId,
+ TimeSpan timeout,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(null);
+
public Task SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
@@ -94,6 +100,12 @@ public event EventHandler? SmartDeviceTriggered
remove { _ = value; }
}
+ public event EventHandler? StorageMonitorTriggered
+ {
+ add { _ = value; }
+ remove { _ = value; }
+ }
+
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
@@ -118,6 +130,7 @@ public RustPlusServerConnection(string ip, int port, ulong steamId, int playerTo
_rustPlus = new RustPlus(connection);
_rustPlus.OnTeamChatReceived += OnTeamChatReceived;
_rustPlus.OnSmartDeviceTriggered += OnSmartDeviceTriggered;
+ _rustPlus.OnStorageMonitorTriggered += OnStorageMonitorTriggered;
}
public async Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken)
@@ -348,6 +361,8 @@ public async Task PromoteToLeaderAsync(ulong steamId,
public event EventHandler? SmartDeviceTriggered;
+ public event EventHandler? StorageMonitorTriggered;
+
public async Task GetSmartDeviceInfoAsync(ulong entityId,
TimeSpan timeout,
CancellationToken cancellationToken)
@@ -377,6 +392,35 @@ public async Task PromoteToLeaderAsync(ulong steamId,
}
}
+ ///
+ public async Task GetStorageMonitorInfoAsync(
+ ulong entityId,
+ TimeSpan timeout,
+ CancellationToken cancellationToken)
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(timeout);
+ try
+ {
+ // CONFIRMED (2.0.0-beta.3): GetStorageMonitorInfoAsync(ulong, CancellationToken) returns
+ // Task>; the read also primes the entity so OnStorageMonitorTriggered
+ // fires for it thereafter.
+ var response = await _rustPlus.GetStorageMonitorInfoAsync(entityId, timeoutCts.Token)
+ .WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+ return response is { IsSuccess: true, Data: { } info } ? MapContents(info) : null;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return null;
+ }
+#pragma warning disable CA1031 // Broad catch: a failed/timed-out storage read returns null; the caller treats null as unreachable.
+ catch (Exception)
+#pragma warning restore CA1031
+ {
+ return null;
+ }
+ }
+
public async Task SetSmartSwitchValueAsync(ulong entityId,
bool value,
TimeSpan timeout,
@@ -558,6 +602,7 @@ public async ValueTask DisposeAsync()
{
_rustPlus.OnTeamChatReceived -= OnTeamChatReceived;
_rustPlus.OnSmartDeviceTriggered -= OnSmartDeviceTriggered;
+ _rustPlus.OnStorageMonitorTriggered -= OnStorageMonitorTriggered;
try
{
// CONFIRMED: RustPlusSocket implements IAsyncDisposable in 2.0.0-beta.1.
@@ -575,6 +620,26 @@ public async ValueTask DisposeAsync()
private void OnSmartDeviceTriggered(object? sender, RustPlusApi.Data.Events.SmartDeviceEventArg e) =>
SmartDeviceTriggered?.Invoke(this, new SmartDeviceTrigger(e.Id, e.IsActive));
+ private void OnStorageMonitorTriggered(object? sender, RustPlusApi.Data.Events.StorageMonitorEventArg e) =>
+ StorageMonitorTriggered?.Invoke(this, new StorageMonitorTrigger(e.Id, MapContents(e)));
+
+ private static StorageContentsSnapshot MapContents(RustPlusApi.Data.Entities.StorageMonitorInfo info)
+ {
+ var items = info.Items is null
+ ? (IReadOnlyList)[]
+ :
+ [
+ .. info.Items.Select(i =>
+ new StorageItemSnapshot(i.Id, i.Quantity ?? 0, i.IsItemBlueprint ?? false))
+ ];
+
+ DateTimeOffset? expiry = info.HasProtection == true
+ ? new DateTimeOffset(DateTime.SpecifyKind(info.ProtectionExpiry, DateTimeKind.Utc))
+ : null;
+
+ return new StorageContentsSnapshot(info.Capacity, info.HasProtection, expiry, items);
+ }
+
private static void AddMarkers(
List into,
IReadOnlyDictionary source,
diff --git a/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs b/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs
new file mode 100644
index 0000000..0707d85
--- /dev/null
+++ b/src/RustPlusBot.Features.Connections/Listening/StorageMonitorTrigger.cs
@@ -0,0 +1,8 @@
+using RustPlusBot.Abstractions.Connections;
+
+namespace RustPlusBot.Features.Connections.Listening;
+
+/// An in-game storage-monitor broadcast: the entity id and its current contents.
+/// The in-game entity id.
+/// The contents snapshot carried on the broadcast.
+internal sealed record StorageMonitorTrigger(ulong EntityId, StorageContentsSnapshot Contents);
diff --git a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
index 0604975..614ba61 100644
--- a/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
+++ b/src/RustPlusBot.Features.Connections/Supervisor/ConnectionSupervisor.cs
@@ -14,6 +14,7 @@
using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.StorageMonitors;
using RustPlusBot.Persistence.Switches;
namespace RustPlusBot.Features.Connections.Supervisor;
@@ -266,6 +267,23 @@ public async Task> GetMonumentsAsync(
.ConfigureAwait(false);
}
+ ///
+ public async Task GetStorageContentsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken)
+ {
+ if (!_liveSockets.TryGetValue((guildId, serverId), out var live))
+ {
+ return null;
+ }
+
+ return await live.Connection
+ .GetStorageMonitorInfoAsync(entityId, _options.HeartbeatTimeout, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
///
public async Task SetSmartSwitchAsync(
ulong guildId,
@@ -481,9 +499,17 @@ void OnSmartDevice(object? sender, SmartDeviceTrigger trigger)
}
#pragma warning restore RCS1163
+#pragma warning disable RCS1163 // Unused 'sender': required by the EventHandler delegate shape.
+ void OnStorage(object? sender, StorageMonitorTrigger trigger)
+ {
+ _ = PublishStorageTriggerAsync(key, trigger);
+ }
+#pragma warning restore RCS1163
+
var tracker = new TeamStateTracker();
connection.TeamMessageReceived += OnTeamMessage;
connection.SmartDeviceTriggered += OnSmartDevice;
+ connection.StorageMonitorTriggered += OnStorage;
_liveSockets[key] = new LiveSocket(connection, activeSteamId, tracker);
await PrimeDevicesAsync(key, connection, ct).ConfigureAwait(false);
using var pollCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -510,6 +536,7 @@ void OnSmartDevice(object? sender, SmartDeviceTrigger trigger)
_liveSockets.TryRemove(key, out _);
connection.TeamMessageReceived -= OnTeamMessage;
connection.SmartDeviceTriggered -= OnSmartDevice;
+ connection.StorageMonitorTriggered -= OnStorage;
}
}
@@ -868,6 +895,48 @@ await PrimeEntityIdsAsync(key, connection,
await PrimeEntityIdsAsync(key, connection,
(IReadOnlyList)[.. alarms.Select(a => a.EntityId)]).ConfigureAwait(false);
+
+ IReadOnlyList monitors;
+ try
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ monitors = await store.ListByServerAsync(key.Guild, key.Server, ct).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // Broad catch: a failed monitor-list read just skips storage priming for this connection.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDeviceListFailed(logger, ex, key.Server);
+ return;
+ }
+
+#pragma warning disable S3267 // Not a projection: each iteration awaits with per-entity best-effort error handling.
+ foreach (var monitor in monitors)
+#pragma warning restore S3267
+ {
+ try
+ {
+ await PublishStoragePrimeAsync(key, connection, monitor.EntityId).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+#pragma warning disable CA1031 // Broad catch: a single monitor's prime failure is logged and skipped.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDevicePrimeFailed(logger, ex, monitor.EntityId, key.Server);
+ }
+ }
}
private async Task PrimeEntityIdsAsync(
@@ -963,6 +1032,76 @@ await eventBus.PublishAsync(
}
}
+ /// Trigger path: contents are carried on the broadcast arg — no re-read.
+ /// The (guild, server) routing key.
+ /// The storage trigger carrying the entity id and contents.
+ private async Task PublishStorageTriggerAsync((ulong Guild, Guid Server) key, StorageMonitorTrigger trigger)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ await eventBus.PublishAsync(
+ new StorageMonitorTriggeredEvent(key.Guild, key.Server, trigger.EntityId, trigger.Contents),
+ _shutdown.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a storage publish failure must not crash the socket callback.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDevicePublishFailed(logger, ex, trigger.EntityId, key.Server);
+ }
+ }
+
+ /// Prime path: read contents on connect (also primes the socket's interest), then publish.
+ /// The (guild, server) routing key.
+ /// The live connection used to read contents.
+ /// The storage-monitor entity id to prime.
+ private async Task PublishStoragePrimeAsync(
+ (ulong Guild, Guid Server) key,
+ IRustServerConnection connection,
+ ulong entityId)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ var contents = await connection
+ .GetStorageMonitorInfoAsync(entityId, _options.HeartbeatTimeout, _shutdown.Token)
+ .ConfigureAwait(false);
+ if (contents is null)
+ {
+ return; // unreachable read; the relay leaves the embed as-is (or unreachable via status events).
+ }
+
+ await eventBus.PublishAsync(
+ new StorageMonitorTriggeredEvent(key.Guild, key.Server, entityId, contents),
+ _shutdown.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a storage prime failure must not crash the connect path.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogDevicePublishFailed(logger, ex, entityId, key.Server);
+ }
+ }
+
[LoggerMessage(Level = LogLevel.Warning, Message = "Marker poll for server {ServerId} failed.")]
private static partial void LogMarkerPollFailed(ILogger logger, Exception exception, Guid serverId);
diff --git a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
index f91f7f0..ed37378 100644
--- a/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
+++ b/src/RustPlusBot.Features.Pairing/Listening/RustPlusFcmPairingSource.cs
@@ -70,6 +70,7 @@ public RustPlusFcmListener(
_fcm.OnServerPairing += OnServerPairing;
_fcm.OnSmartSwitchPairing += OnSmartSwitchPairing;
_fcm.OnSmartAlarmPairing += OnSmartAlarmPairing;
+ _fcm.OnStorageMonitorPairing += OnStorageMonitorPairing;
}
///
@@ -103,6 +104,7 @@ public async ValueTask DisposeAsync()
_fcm.OnServerPairing -= OnServerPairing;
_fcm.OnSmartSwitchPairing -= OnSmartSwitchPairing;
_fcm.OnSmartAlarmPairing -= OnSmartAlarmPairing;
+ _fcm.OnStorageMonitorPairing -= OnStorageMonitorPairing;
await _fcm.DisposeAsync().ConfigureAwait(false);
}
@@ -169,6 +171,23 @@ private void OnSmartAlarmPairing(object? sender, Notification e)
EntityKind: RustPlusBot.Domain.Entities.PairedEntityKind.SmartAlarm));
}
+ private void OnStorageMonitorPairing(object? sender, Notification e)
+ {
+ if (e?.Data is not { } entityId)
+ {
+ return;
+ }
+
+ Dispatch(new PairingNotification(
+ Kind: PairingKind.Entity,
+ ServerName: string.Empty, Ip: string.Empty, Port: 0,
+ PlayerId: e.PlayerId,
+ PlayerToken: e.PlayerToken.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ FacepunchServerId: e.ServerId,
+ EntityId: entityId,
+ EntityKind: RustPlusBot.Domain.Entities.PairedEntityKind.StorageMonitor));
+ }
+
private void Dispatch(PairingNotification notification)
{
// Fire-and-forget bridge from synchronous event to async callback.
diff --git a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
index 16455ad..1343e13 100644
--- a/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
+++ b/src/RustPlusBot.Features.Pairing/Pairing/PairingHandler.cs
@@ -83,6 +83,12 @@ await eventBus
.PublishAsync(new AlarmPairedEvent(guildId, server.Id, notification.EntityId), cancellationToken)
.ConfigureAwait(false);
break;
+ case RustPlusBot.Domain.Entities.PairedEntityKind.StorageMonitor:
+ await eventBus
+ .PublishAsync(new StorageMonitorPairedEvent(guildId, server.Id, notification.EntityId),
+ cancellationToken)
+ .ConfigureAwait(false);
+ break;
default:
LogUnroutedEntityKind(logger, notification.EntityKind);
break;
diff --git a/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs
new file mode 100644
index 0000000..e5ea54f
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Hosting/StorageMonitorsHostedService.cs
@@ -0,0 +1,133 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.StorageMonitors.Pairing;
+using RustPlusBot.Features.StorageMonitors.Relaying;
+
+namespace RustPlusBot.Features.StorageMonitors.Hosting;
+
+/// Runs the storage-monitor pairing loop, the triggered relay loop, and the connection-status relay loop.
+/// The in-process event bus.
+/// Handles paired storage monitors.
+/// Re-renders storage monitors on trigger/connection changes.
+/// The logger.
+internal sealed partial class StorageMonitorsHostedService(
+ IEventBus eventBus,
+ StorageMonitorPairingCoordinator coordinator,
+ StorageMonitorStateRelay relay,
+ ILogger logger) : IHostedService, IDisposable
+{
+ private readonly CancellationTokenSource _cts = new();
+ private Task? _pairedLoop;
+ private Task? _statusLoop;
+ private Task? _triggeredLoop;
+
+ ///
+ public void Dispose() => _cts.Dispose();
+
+ ///
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _pairedLoop = Task.Run(() => ConsumePairedAsync(_cts.Token), CancellationToken.None);
+ _triggeredLoop = Task.Run(() => ConsumeTriggeredAsync(_cts.Token), CancellationToken.None);
+ _statusLoop = Task.Run(() => ConsumeStatusAsync(_cts.Token), CancellationToken.None);
+ return Task.CompletedTask;
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+ foreach (var loop in new[]
+ {
+ _pairedLoop, _triggeredLoop, _statusLoop
+ }.Where(t => t is not null))
+ {
+ try
+ {
+#pragma warning disable VSTHRD003 // Our own loop tasks, joined on stop.
+ await loop!.ConfigureAwait(false);
+#pragma warning restore VSTHRD003
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected on shutdown.
+ }
+ }
+ }
+
+ private async Task ConsumePairedAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await coordinator.HandlePairedAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogPairedLoopFaulted(logger, ex);
+ }
+ }
+
+ private async Task ConsumeTriggeredAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await relay.HandleTriggeredAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogTriggeredLoopFaulted(logger, ex);
+ }
+ }
+
+ private async Task ConsumeStatusAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await foreach (var evt in eventBus.SubscribeAsync(cancellationToken)
+ .ConfigureAwait(false))
+ {
+ await relay.HandleConnectionStatusAsync(evt, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Shutting down.
+ }
+#pragma warning disable CA1031 // Broad catch: a faulting consumer must not crash the host.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ LogStatusLoopFaulted(logger, ex);
+ }
+ }
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor pairing loop faulted.")]
+ private static partial void LogPairedLoopFaulted(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor triggered relay loop faulted.")]
+ private static partial void LogTriggeredLoopFaulted(ILogger logger, Exception exception);
+
+ [LoggerMessage(Level = LogLevel.Error, Message = "Storage monitor connection-status relay loop faulted.")]
+ private static partial void LogStatusLoopFaulted(ILogger logger, Exception exception);
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs
new file mode 100644
index 0000000..62dc06d
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorComponentModule.cs
@@ -0,0 +1,185 @@
+using System.Globalization;
+using Discord;
+using Discord.Interactions;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Connections;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.StorageMonitors.Pairing;
+using RustPlusBot.Features.StorageMonitors.Rendering;
+using RustPlusBot.Persistence.StorageMonitors;
+
+namespace RustPlusBot.Features.StorageMonitors.Modules;
+
+/// Thin handler for the #storagemonitors pairing prompt + Refresh/Rename. Any guild member.
+/// Creates a short-lived DI scope per interaction.
+/// Live socket read (for Refresh).
+/// Publishes a triggered event to drive an embed refresh.
+public sealed class StorageMonitorComponentModule(
+ IServiceScopeFactory scopeFactory,
+ IRustServerQuery query,
+ IEventBus eventBus) : InteractionModuleBase
+{
+ private const string InvalidControlMessage = "That control wasn't valid.";
+
+ /// Accepts a pending pairing prompt and starts managing the monitor.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(StorageMonitorComponentIds.AcceptPrefix + "*")]
+ public async Task AcceptAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var coordinator = scope.ServiceProvider.GetRequiredService();
+ var accepted = await coordinator
+ .TryAcceptAsync(Context.Guild.Id, serverId, entityId, Context.User.Id, CancellationToken.None)
+ .ConfigureAwait(false);
+ await FollowupAsync(accepted ? "Storage monitor added." : "That monitor is already managed.",
+ ephemeral: true).ConfigureAwait(false);
+ }
+ }
+
+ /// Dismisses a pending pairing prompt and removes the transient prompt message.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(StorageMonitorComponentIds.DismissPrefix + "*")]
+ public async Task DismissAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var coordinator = scope.ServiceProvider.GetRequiredService();
+ coordinator.TryDismiss(Context.Guild.Id, serverId, entityId);
+ }
+
+ await DeletePromptMessageSafeAsync().ConfigureAwait(false);
+ await RespondAsync("Dismissed.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ /// Re-reads the monitor's contents and republishes them so the embed refreshes.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(StorageMonitorComponentIds.RefreshPrefix + "*")]
+ public async Task RefreshAsync(string tail)
+ {
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+ var contents = await query
+ .GetStorageContentsAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
+ .ConfigureAwait(false);
+ if (contents is null)
+ {
+ await FollowupAsync("Storage monitor is unreachable right now.", ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await eventBus
+ .PublishAsync(new StorageMonitorTriggeredEvent(Context.Guild.Id, serverId, entityId, contents))
+ .ConfigureAwait(false);
+ await FollowupAsync("Refreshed.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ /// Opens the rename modal, carrying the target tail in the modal custom id.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ [ComponentInteraction(StorageMonitorComponentIds.RenamePrefix + "*")]
+ public async Task RenamePromptAsync(string tail)
+ {
+ if (!TryParse(tail, out _, out _) || Context.Guild is null)
+ {
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ await RespondWithModalAsync(StorageMonitorComponentIds.RenameModalPrefix + tail)
+ .ConfigureAwait(false);
+ }
+
+ /// Persists the new name, then republishes current contents so the embed refreshes.
+ /// The "{serverId}:{entityId}" custom-id tail.
+ /// The submitted rename modal.
+ [ModalInteraction(StorageMonitorComponentIds.RenameModalPrefix + "*")]
+ public async Task RenameSubmitAsync(string tail, StorageMonitorRenameModal modal)
+ {
+ ArgumentNullException.ThrowIfNull(modal);
+ if (!TryParse(tail, out var serverId, out var entityId) || Context.Guild is null)
+ {
+ await RespondAsync(InvalidControlMessage, ephemeral: true).ConfigureAwait(false);
+ return;
+ }
+
+ var name = string.IsNullOrWhiteSpace(modal.Name)
+ ? "Storage Monitor " + entityId.ToString(CultureInfo.InvariantCulture)
+ : modal.Name.Trim();
+ await DeferAsync(ephemeral: true).ConfigureAwait(false);
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ await store.RenameAsync(Context.Guild.Id, serverId, entityId, name, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+
+ // Re-read (may be null if unreachable) and republish so the relay re-renders the renamed embed.
+ var contents = await query
+ .GetStorageContentsAsync(Context.Guild.Id, serverId, entityId, CancellationToken.None)
+ .ConfigureAwait(false);
+ if (contents is not null)
+ {
+ await eventBus
+ .PublishAsync(new StorageMonitorTriggeredEvent(Context.Guild.Id, serverId, entityId, contents))
+ .ConfigureAwait(false);
+ }
+
+ await FollowupAsync("Renamed.", ephemeral: true).ConfigureAwait(false);
+ }
+
+ private static bool TryParse(string tail, out Guid serverId, out ulong entityId)
+ {
+ serverId = Guid.Empty;
+ entityId = 0UL;
+ if (tail is null)
+ {
+ return false;
+ }
+
+ var parts = tail.Split(':');
+ return parts.Length == 2
+ && Guid.TryParse(parts[0], out serverId)
+ && ulong.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out entityId);
+ }
+
+ private async Task DeletePromptMessageSafeAsync()
+ {
+ try
+ {
+ // The source message of a component interaction is the prompt that carries the button.
+ if (Context.Interaction is IComponentInteraction component)
+ {
+ await component.Message.DeleteAsync().ConfigureAwait(false);
+ }
+ }
+#pragma warning disable CA1031 // Best-effort prompt cleanup; a delete failure is non-fatal.
+ catch (Exception ex)
+#pragma warning restore CA1031
+ {
+ // Ignore: the prompt is transient and harmless if it lingers.
+ _ = ex;
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs
new file mode 100644
index 0000000..b85e35d
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Modules/StorageMonitorRenameModal.cs
@@ -0,0 +1,17 @@
+using Discord;
+using Discord.Interactions;
+using RustPlusBot.Features.StorageMonitors.Rendering;
+
+namespace RustPlusBot.Features.StorageMonitors.Modules;
+
+/// The modal that collects a new storage-monitor name. Handled by the storage-monitor component module.
+public sealed class StorageMonitorRenameModal : IModal
+{
+ /// The new name.
+ [InputLabel("Storage monitor name")]
+ [ModalTextInput(StorageMonitorComponentIds.RenameInputId, TextInputStyle.Short, maxLength: 128)]
+ public string Name { get; set; } = string.Empty;
+
+ ///
+ public string Title => "Rename storage monitor";
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs b/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs
new file mode 100644
index 0000000..cbfed0a
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Naming/EmbeddedItemNameResolver.cs
@@ -0,0 +1,37 @@
+using System.Collections.Frozen;
+using System.Globalization;
+using System.Text.Json;
+
+namespace RustPlusBot.Features.StorageMonitors.Naming;
+
+/// Resolves item ids from the bundled items.json (Facepunch's published item names). Singleton.
+public sealed class EmbeddedItemNameResolver : IItemNameResolver
+{
+ private static readonly FrozenDictionary Names = Load();
+
+ ///
+ public string Resolve(int itemId) =>
+ Names.TryGetValue(itemId, out var name)
+ ? name
+ : "Item " + itemId.ToString(CultureInfo.InvariantCulture);
+
+ private static FrozenDictionary Load()
+ {
+ var assembly = typeof(EmbeddedItemNameResolver).Assembly;
+ var resourceName = assembly.GetManifestResourceNames()
+ .Single(n => n.EndsWith("items.json", StringComparison.Ordinal));
+ using var stream = assembly.GetManifestResourceStream(resourceName)
+ ?? throw new InvalidOperationException("Embedded items.json not found.");
+ var raw = JsonSerializer.Deserialize>(stream)
+ ?? throw new InvalidOperationException("items.json deserialized to null.");
+ // Group by the parsed id (last wins) before freezing: two distinct string keys can parse to the
+ // same int (NumberStyles.Integer allows leading sign/whitespace), and ToFrozenDictionary throws on a
+ // duplicate key — which, in a static initializer, would hard-fault the feature. Degrade, never crash.
+ return raw
+ .Select(kvp => (Parsed: int.TryParse(kvp.Key, NumberStyles.Integer, CultureInfo.InvariantCulture,
+ out var id), Id: id, kvp.Value))
+ .Where(x => x.Parsed)
+ .GroupBy(x => x.Id, x => x.Value)
+ .ToFrozenDictionary(g => g.Key, g => g.Last());
+ }
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs b/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs
new file mode 100644
index 0000000..bbe9a01
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Naming/IItemNameResolver.cs
@@ -0,0 +1,10 @@
+namespace RustPlusBot.Features.StorageMonitors.Naming;
+
+/// Resolves a Rust item id to a human-readable display name.
+public interface IItemNameResolver
+{
+ /// Gets the display name for an item id, or a stable "Item {id}" fallback when unknown.
+ /// The Rust item id.
+ /// The display name, or "Item {id}" if not in the bundled lookup.
+ string Resolve(int itemId);
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Naming/items.json b/src/RustPlusBot.Features.StorageMonitors/Naming/items.json
new file mode 100644
index 0000000..531d7b0
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Naming/items.json
@@ -0,0 +1,1207 @@
+{
+"-2139580305":"Auto Turret",
+"-2134097299":"Tripod Spot Light",
+"-2133781216":"Outbreak Scientist Suit",
+"-2124352573":"Acoustic Guitar",
+"-2123125470":"Advanced Healing Tea",
+"-2115299615":"Small Boat Engine",
+"-2110553371":"Bamboo Salvaged Shelves",
+"-2107018088":"Shovel Bass",
+"-2103694546":"Sunglasses",
+"-2100458529":"Raw Snake Meat",
+"-2099697608":"Stones",
+"-2097376851":"Nailgun Nails",
+"-2095813057":"Raw Big Cat Meat",
+"-2094954543":"Wood Armor Helmet",
+"-2086926071":"Potato",
+"-2084071424":"Potato Seed",
+"-2073432256":"Skinning Knife",
+"-2072273936":"Bandage",
+"-2069578888":"M249",
+"-2068194497":"Lunar New Year Horse Armor",
+"-2067472972":"Sheet Metal Door",
+"-2058362263":"Small Candle Set",
+"-2049214035":"Pressure Pad",
+"-2047081330":"Movember Moustache",
+"-2040817543":"Pan Flute",
+"-2035449523":"Spoiled Deer Meat",
+"-2027988285":"Locomotive",
+"-2027793839":"Advent Calendar",
+"-2026042603":"Baseball Bat",
+"-2025184684":"Shirt",
+"-2024549027":"Heavy Frankenstein Legs",
+"-2022172587":"Diving Tank",
+"-2018158920":"Chicken Coop",
+"-2016974826":"Wall Divider Pack",
+"-2012470695":"Improvised Balaclava",
+"-2002277461":"Road Sign Jacket",
+"-2001260025":"Instant Camera",
+"-1999722522":"Furnace",
+"-1998423571":"Explosives Storage Box",
+"-1997698639":"Sunglasses",
+"-1997543660":"Horse Saddle",
+"-1994909036":"Sheet Metal",
+"-1993883724":"High External Legacy Wall",
+"-1992717673":"Large Furnace",
+"-1989600732":"Hot Air Balloon Armor",
+"-1987565603":"Incendiary Bolt",
+"-1985799200":"Rug",
+"-1982036270":"High Quality Metal Ore",
+"-1978999529":"Salvaged Cleaver",
+"-1973785141":"Fogger-3000",
+"-1966748496":"Mace",
+"-1962971928":"Mushroom",
+"-1961560162":"Firecracker String",
+"-1958316066":"Scientist Suit",
+"-1950721390":"Concrete Barricade",
+"-1944704288":"Ice Throne",
+"-1941646328":"Can of Tuna",
+"-1938052175":"Charcoal",
+"-1937799374":"Naval Scientist Suit",
+"-1923843855":"Half Height Bamboo Shelves",
+"-1916473915":"Chinese Lantern",
+"-1913996738":"Fish Trophy",
+"-1904821376":"Orange Roughy",
+"-1903165497":"Bone Helmet",
+"-1901993050":"Ornate Frame Medium",
+"-1899491405":"Glue",
+"-1896395719":"Advanced Blueprint Fragment",
+"-1880870149":"Red Keycard",
+"-1880231361":"Flatbed Vehicle Module",
+"-1878764039":"Small Trout",
+"-1878475007":"Satchel Charge",
+"-1866909924":"Steering Wheel",
+"-1863559151":"Water Barrel",
+"-1863063690":"Rocking Chair",
+"-1861522751":"Research Table",
+"-1850571427":"Military Silencer",
+"-1850297170":"Small Spike Trap",
+"-1848736516":"Cooked Chicken",
+"-1843426638":"MLRS Rocket",
+"-1841918730":"High Velocity Rocket",
+"-1836526520":"Ornate Frame Small",
+"-1832422579":"One Sided Town Sign Post",
+"-1827561369":"Propane Explosive Bomb",
+"-1824943010":"Jack O Lantern Happy",
+"-1824770114":"Sky Lantern - Orange",
+"-1819763926":"Wind Turbine",
+"-1819233322":"Medium Wooden Sign",
+"-1815301988":"Water Pistol",
+"-1812555177":"LR-300 Assault Rifle",
+"-1811234677":"Beehive Nucleus",
+"-1804515496":"Gold Mirror Medium",
+"-1802083073":"High Quality Valves",
+"-1800345240":"Speargun Spear",
+"-1796837031":"Spoiled Crocodile Meat",
+"-1790885730":"Wheat Seed",
+"-1785248332":"Fish Pie",
+"-1785231475":"Surgeon Scrubs",
+"-1782127806":"Star Balloon",
+"-1780802565":"Salvaged Icepick",
+"-1779203452":"Portable Easel",
+"-1779183908":"Paper",
+"-1779180711":"Water",
+"-1778897469":"Button",
+"-1778159885":"Heavy Plate Pants",
+"-1776128552":"Green Berry Seed",
+"-1774190142":"Shutter Frame Standing",
+"-1773144852":"Hide Skirt",
+"-1772746857":"Heavy Scientist Suit",
+"-1770889433":"Sky Lantern - Green",
+"-1770281406":"Krieg chainsword",
+"-1768880890":"Small Shark",
+"-1767794021":"Gas Compression Overdrive",
+"-1758372725":"Thompson",
+"-1754948969":"Sleeping Bag",
+"-1736356576":"Reactive Target",
+"-1732475823":"Medium Frankenstein Head",
+"-1729415579":"Advanced Anti-Rad Tea",
+"-1709878924":"Raw Human Meat",
+"-1707425764":"Fishing Tackle",
+"-1698937385":"Herring",
+"-1696379844":"Hazmat Youtooz",
+"-1695367501":"Shorts",
+"-1693832478":"Large Flatbed Vehicle Module",
+"-1691396643":"HV Pistol Ammo",
+"-1685290200":"12 Gauge Buckshot",
+"-1679267738":"Graveyard Fence",
+"-1677315902":"Pure Healing Tea",
+"-1673693549":"Empty Propane Tank",
+"-1671551935":"Torpedo",
+"-1667224349":"Decorative Baubels",
+"-1663759755":"Homemade Landmine",
+"-1659598760":"Soda Can Silencer",
+"-1654401345":"Medieval Sheet Metal Door",
+"-1654233406":"Sardine",
+"-1652561344":"Bamboo Barrel",
+"-1651220691":"Pookie Bear",
+"-1647846966":"Two Sided Ornate Hanging Sign",
+"-1647389398":"Frankenstein Mask",
+"-1624770297":"Light Frankenstein Torso",
+"-1622660759":"Large Present",
+"-1622110948":"Bandit Guard Gear",
+"-1621539785":"Beach Parasol",
+"-1616704051":"Spoiled Snake Meat",
+"-1615281216":"Armored Passenger Vehicle Module",
+"-1614955425":"Strengthened Glass Window",
+"-1607980696":"Workbench Level 3",
+"-1593678393":"Ammo Storage Box",
+"-1588628467":"Computer Station",
+"-1583967946":"Stone Hatchet",
+"-1581843485":"Sulfur",
+"-1579932985":"Horse Dung",
+"-1569700847":"Headset",
+"-1559420426":"Double Diving Tank",
+"-1557377697":"Empty Tuna Can",
+"-1553999294":"Red Boomer",
+"-1549739227":"Boots",
+"-1541706279":"Wood Frame Medium",
+"-1539025626":"Miners Hat",
+"-1538109120":"Violet Volcano Firework",
+"-1536855921":"Shovel",
+"-1535621066":"Stone Fireplace",
+"-1530414568":"Cassette Recorder",
+"-1528767189":"Ornate Frame Standing",
+"-1520560807":"Raw Bear Meat",
+"-1519126340":"Drop Box",
+"-1518883088":"Night Vision Goggles",
+"-1517740219":"Speargun",
+"-1513203236":"Honeycomb",
+"-1511285251":"Pumpkin Seed",
+"-1510616686":"Chandelier",
+"-1509851560":"Cooked Deer Meat",
+"-1507239837":"HBHF Sensor",
+"-1506417026":"Ninja Suit",
+"-1506397857":"Salvaged Hammer",
+"-1501451746":"Cockpit Vehicle Module",
+"-1498613415":"Advanced Cooling Tea",
+"-1497205569":"Wood Mirror Small",
+"-1488408786":"Pumpkin Pie",
+"-1488398114":"Composter",
+"-1486461488":"Red Roman Candle",
+"-1478855279":"Ice Metal Chest Plate",
+"-1478445584":"Tuna Can Lamp",
+"-1478212975":"Wolf Headdress",
+"-1478094705":"Boogie Board",
+"-1476814093":"Pure Warming Tea",
+"-1476278729":"Wood Frame Small",
+"-1469578201":"Longsword",
+"-1467876094":"#50cal",
+"-1449152644":"MLRS",
+"-1448252298":"Electrical Branch",
+"-1444650226":"Gold Mirror Small",
+"-1442559428":"Hobo Barrel",
+"-1442496789":"Pinata",
+"-1442339204":"High External Legacy Gate",
+"-1440987069":"Raw Chicken Breast",
+"-1440443161":"Clump of Latex Balloons",
+"-1433390281":"Sky Lantern - Red",
+"-1432674913":"Anti-Radiation Pills",
+"-1430299277":"Ornate Frame XL",
+"-1429456799":"Prison Cell Wall",
+"-1423304443":"Medium Neon Sign",
+"-1421257350":"Storage Barrel Horizontal",
+"-1417478274":"Motorbike",
+"-1416322465":"Walkie Talkie",
+"-1408336705":"Sunglasses",
+"-1405508498":"Muzzle Boost",
+"-1386082991":"Purple ID Tag",
+"-1385721419":"Advanced Harvesting Tea",
+"-1380144986":"Scrap Mirror Standing",
+"-1379835144":"Festive Window Garland",
+"-1379036069":"Canbourine",
+"-1370759135":"Portrait Picture Frame",
+"-1368584029":"Sickle",
+"-1367281941":"Waterpipe Shotgun",
+"-1366326648":"Spray Can Decal",
+"-1364246987":"Snowmobile",
+"-1360171080":"Concrete Pickaxe",
+"-1344017968":"Wanted Poster",
+"-1336109173":"Wood Double Door",
+"-1335497659":"Ice Assault Rifle",
+"-1334569149":"Hockey Mask",
+"-1334255764":"Minicopter",
+"-1331212963":"Star Tree Topper",
+"-1330640246":"Junkyard Drum Kit",
+"-1323101799":"Double Horse Saddle",
+"-1322332389":"Ornate Frame XXL",
+"-1321651331":"Explosive 5.56 Rifle Ammo",
+"-1318837358":"Cooked Big Cat Meat",
+"-1316706473":"Camera",
+"-1315992997":"Dragon Rocket Launcher",
+"-1314079879":"Snake mask",
+"-1310391395":"Legacy Furnace",
+"-1306288356":"Green Roman Candle",
+"-1305326964":"Green Berry Clone",
+"-1302129395":"Pickaxe",
+"-1294739579":"Light-Up Frame Medium",
+"-1293296287":"Small Oil Refinery",
+"-1290278434":"Siege Tower",
+"-1286302544":"OR Switch",
+"-1284169891":"Water Pump",
+"-1274093662":"Bath Tub Planter",
+"-1273339005":"Bed",
+"-1266045928":"Bunny Onesie",
+"-1265020883":"Wanted Poster 3",
+"-1262185308":"Binoculars",
+"-1260229965":"Basic Cooling Tea",
+"-1258821205":"Spot Light",
+"-1252059217":"Hatchet",
+"-1247485104":"Command Block",
+"-1244287686":"Shutter Frame XL",
+"-1234735557":"Wooden Arrow",
+"-1230433643":"Festive Double Doorway Garland",
+"-1220928936":"Leather Beanbag Seat",
+"-1215753368":"Flame Thrower",
+"-1215166612":"A Barrel Costume",
+"-1214542497":"HMLMG",
+"-1211801774":"Shutter Frame XXL",
+"-1211268013":"Basic Horse Shoes",
+"-1211166256":"5.56 Rifle Ammo",
+"-1199897172":"Metal Vertical embrasure",
+"-1199897169":"Metal horizontal embrasure",
+"-1196547867":"Electric Furnace",
+"-1184406448":"Basic Max Health Tea",
+"-1183726687":"Wooden Window Bars",
+"-1175656359":"Cultist Deer Torch",
+"-1167031859":"Spoiled Wolf Meat",
+"-1166712463":"Fluid Splitter",
+"-1163943815":"Weapon Rack Light",
+"-1163532624":"Jacket",
+"-1162759543":"Cooked Horse Meat",
+"-1160621614":"Red Industrial Wall Light",
+"-1157596551":"Sulfur Ore",
+"-1151332840":"Wooden Frontier Bar Doors",
+"-1142222427":"Basic Warming Tea",
+"-1138208076":"Small Wooden Sign",
+"-1137865085":"Machete",
+"-1130709577":"Pump Jack",
+"-1130350864":"Raw Horse Meat",
+"-1127003365":"Piercer Bolt",
+"-1123473824":"Multiple Grenade Launcher",
+"-1117626326":"Chainlink Fence",
+"-1113501606":"Boom Box",
+"-1112793865":"Door Key",
+"-1108136649":"Tactical Gloves",
+"-1104881824":"Bear Skin Rug",
+"-1102429027":"Heavy Plate Jacket",
+"-1101924344":"Wetsuit",
+"-1100422738":"Spinning Wheel",
+"-1100168350":"Large Water Catcher",
+"-1094453063":"Shutter Frame large",
+"-1081599445":"Raw Crocodile Meat",
+"-1078639462":"Skull Spikes",
+"-1073015016":"Skull Spikes",
+"-1063073030":"Wooden Boat Door",
+"-1060567807":"Shutter Frame Medium",
+"-1056824343":"Diver propulsion vehicle",
+"-1050697733":"Scrap Mirror Small",
+"-1049881973":"Cowbell",
+"-1049172752":"Storage Adaptor",
+"-1044468317":"RF Broadcaster",
+"-1043618880":"Ghost Costume",
+"-1040518150":"Camper Vehicle Module",
+"-1039528932":"Small Water Bottle",
+"-1039234836":"Paintable Reactive Target",
+"-1037472336":"Rose Seed",
+"-1036635990":"12 Gauge Incendiary Shell",
+"-1035206446":"Clothing Mannequin",
+"-1023374709":"Wood Shutters",
+"-1023065463":"High Velocity Arrow",
+"-1022661119":"Baseball Cap",
+"-1021495308":"Metal Spring",
+"-1018587433":"Animal Fat",
+"-1014934560":"Paintball Overalls",
+"-1009359066":"SAM Site",
+"-1004426654":"Bunny Ears",
+"-1003665711":"Super Serum",
+"-1002156085":"Gold Egg",
+"-1000573653":"Frog Boots",
+"-996920608":"Blueprint",
+"-996235148":"Ornate Frame large",
+"-996185386":"XL Picture Frame",
+"-992286106":"White Berry Seed",
+"-989755543":"Burnt Bear Meat",
+"-986782031":"Rabbit Mask",
+"-985781766":"High Ice Wall",
+"-979951147":"Jerry Can Guitar",
+"-979302481":"Easter Door Wreath",
+"-967648160":"High External Stone Wall",
+"-965336208":"Chocolate Bar",
+"-963820355":"Survivor's Pie",
+"-963819285":"Incapacitate Dart",
+"-961457160":"New Year Gong",
+"-956706906":"Prison Cell Gate",
+"-952411326":"Plank",
+"-948291630":"Seismic Sensor",
+"-946599131":"Artist Canvas Medium",
+"-946599114":"Artist Canvas Standing",
+"-946599113":"Artist Canvas Large",
+"-946369541":"Low Grade Fuel",
+"-945708533":"Knights armour skirt plates",
+"-939424778":"Flasher Light",
+"-936921910":"Flashbang",
+"-935322684":"4 Module Car",
+"-932201673":"Scrap",
+"-930193596":"Fertilizer",
+"-929092070":"Basic Healing Tea",
+"-924959988":"Skull Trophy",
+"-919882824":"Abyss Vertical Storage Tank",
+"-912398867":"Cassette - Medium",
+"-907422733":"Large Backpack",
+"-904863145":"Semi-Automatic Rifle",
+"-903796529":"Asbestos Armor Insert",
+"-902423513":"Krieg Hazmat",
+"-901370585":"Twitch Rivals Trophy 2023",
+"-892718768":"Prisoner Hood",
+"-888153050":"Halloween Candy",
+"-886280491":"Hemp Clone",
+"-885833256":"Vampire Stake",
+"-880494890":"Abyss Horizontal Storage Tank",
+"-880412831":"Blunderbuss",
+"-874908751":"Waterwell NPC Jumpsuit",
+"-874650016":"Krieg Large Backpack",
+"-870140677":"Snake Venom",
+"-869598982":"Small Hunting Trophy",
+"-866121090":"2 Module Car",
+"-858312878":"Cloth",
+"-855748505":"Simple Handmade Sight",
+"-854270928":"Dragon Door Knocker",
+"-852563019":"M92 Pistol",
+"-851988960":"Salmon",
+"-851288382":"Blow Pipe",
+"-850982208":"Key Lock",
+"-849373693":"Frontier Horseshoe Single Item Rack",
+"-845557339":"Landscape Picture Frame",
+"-842267147":"Snowman Helmet",
+"-839576748":"Handcuffs",
+"-831725027":"3 Module Car",
+"-819720157":"Metal Window Bars",
+"-816769770":"Artist Canvas XXL",
+"-810326667":"Work Cart",
+"-804769727":"Plant Fiber",
+"-803263829":"Coffee Can Helmet",
+"-800824218":"Meds Storage Box",
+"-798662404":"Orchid Clone",
+"-798293154":"Laser Detector",
+"-797592358":"Abyss Pack",
+"-796583652":"Shop Front",
+"-784870360":"Electric Heater",
+"-781866273":"Oil Filter Silencer",
+"-781014061":"Sprinkler",
+"-778875547":"Corn Clone",
+"-778367295":"L96 Rifle",
+"-770304148":"Chinese Lantern White",
+"-769647921":"Skull Trophy",
+"-765183617":"Double Barrel Shotgun",
+"-763071910":"Lumberjack Hoodie",
+"-761829530":"Burlap Shoes",
+"-759279626":"Mounted Ballista",
+"-751151717":"Spoiled Chicken",
+"-747743875":"Egg Suit",
+"-746647361":"Memory Cell",
+"-746030907":"Granola Bar",
+"-742865266":"Rocket",
+"-739993590":"Twitch Rivals Flag",
+"-733625651":"Paddling Pool",
+"-727717969":"12 Gauge Slug",
+"-724146494":"Spoiled Horse Meat",
+"-722629980":"Heavy Scientist Youtooz",
+"-722241321":"Small Present",
+"-707792719":"Paintball Gun",
+"-702051347":"Bandana Mask",
+"-700591459":"Can of Beans",
+"-699558439":"Road Sign Gloves",
+"-697981032":"Inner Tube",
+"-695978112":"Smart Alarm",
+"-695124222":"Giant Candy Decor",
+"-692338819":"Small Rechargeable Battery",
+"-691113464":"High External Stone Gate",
+"-690968985":"Blocker",
+"-690276911":"Glowing Eyes",
+"-682687162":"Burnt Human Meat",
+"-656349006":"Green Boomer",
+"-652889722":"Advanced Crafting Quality Tea",
+"-649128577":"Basic Wood Tea",
+"-648077743":"Mr Spice Can",
+"-635951327":"Wood Frame Large",
+"-629028935":"Electric Fuse",
+"-626174997":"Taxi Vehicle Module",
+"-611118083":"Sunflower",
+"-606898372":"#clothingmannequin",
+"-602717596":"Red Dog Tags",
+"-601133933":"Armoured Triangle Laddder Hatch",
+"-596876839":"Spray Can",
+"-594596146":"Radiation Dart",
+"-593892112":"Wooden Armor Insert",
+"-592016202":"Explosives",
+"-587989372":"Catfish",
+"-586784898":"Mail Box",
+"-586342290":"Blueberries",
+"-583379016":"Megaphone",
+"-582782051":"Snap Trap",
+"-582467439":"Coconut Armor Helmet",
+"-576866254":"Fabric Beanbag Seat",
+"-575744869":"Party Hat",
+"-575483084":"Santa Hat",
+"-568419968":"Grub",
+"-567909622":"Pumpkin",
+"-566907190":"RF Pager",
+"-563624462":"Splitter",
+"-561148628":"Tugboat",
+"-560304835":"Space Suit",
+"-559599960":"Sandbag Barricade",
+"-558880549":"Gingerbread Suit",
+"-557539629":"Pure Wood Tea",
+"-555122905":"Sofa",
+"-551431036":"Wallpaper Flooring",
+"-544317637":"Research Paper",
+"-544295594":"Guns Storage Box",
+"-542577259":"Minnows",
+"-541206665":"Advanced Wood Tea",
+"-526026171":"Wicker Barrel",
+"-520133715":"Yellow Berry Seed",
+"-515830359":"Blue Roman Candle",
+"-507248640":"Wellipets Hat",
+"-502177121":"Door Controller",
+"-498301781":"Shutter Frame Small",
+"-496584751":"Rad. Removal Tea",
+"-493159321":"Medium Quality Spark Plugs",
+"-489848205":"Large Candle Set",
+"-487356515":"Basic Anti-Rad Tea",
+"-484206264":"Blue Keycard",
+"-484006286":"Firebomb",
+"-482348853":"Mini Crossbow",
+"-479314201":"Battering Ram Head",
+"-478923685":"Armored Triangle Ladder Hatch",
+"-470439097":"Arctic Suit",
+"-465682601":"SUPER Stocking",
+"-463122489":"Watch Tower",
+"-463012608":"Salvaged Ejector Seat",
+"-459159118":"Wood Armor Gloves",
+"-458565393":"Root Combiner",
+"-455286320":"Gray ID Tag",
+"-454370658":"Red Volcano Firework",
+"-451310088":"Documents",
+"-450890885":"Wall Divider Pack",
+"-430416124":"Single Plant Pot",
+"-427072335":"Knights armour helmet",
+"-424687710":"Medieval Barricade",
+"-420889602":"Krieg Shotgun",
+"-418359052":"Horse Mask",
+"-413663149":"Comps Storage Box",
+"-411735114":"Cannonball",
+"-401905610":"High External Adobe Gate",
+"-399173933":"Prototype Hatchet",
+"-395377963":"Raw Wolf Meat",
+"-389796733":"Light-Up Mirror Small",
+"-384243979":"SAM Ammo",
+"-380502678":"Medieval Sheet Metal Double Door",
+"-379734527":"Pattern Boomer",
+"-379403794":"Water Wheel",
+"-374457631":"Sedan",
+"-369760990":"Small Stash",
+"-365097295":"Powered Water Purifier",
+"-363689972":"Snowball",
+"-357442017":"Pitchfork Bolt",
+"-348232115":"SKS",
+"-343857907":"Sound Light",
+"-335089230":"High External Wooden Gate",
+"-334418777":"Advanced Warming Tea",
+"-333406828":"Sled",
+"-324675402":"Reindeer Antlers",
+"-321733511":"Crude Oil",
+"-321431890":"Beach Chair",
+"-321247698":"Boat Building Plan",
+"-316250604":"Wooden Ladder",
+"-297099594":"Heavy Frankenstein Head",
+"-295829489":"Test Generator",
+"-282193997":"Orange ID Tag",
+"-282113991":"Simple Light",
+"-280812482":"Triangle Planter Box",
+"-280223496":"Violet Boomer",
+"-277057363":"Salt Water",
+"-274709858":"Wood Dart",
+"-265876753":"Gun Powder",
+"-265292885":"Fluid Combiner",
+"-262590403":"Salvaged Axe",
+"-258574361":"Dracula Cape",
+"-258457936":"Unused Storage Barrel Vertical",
+"-253079493":"Scientist Suit",
+"-246672609":"Horizontal Weapon Rack",
+"-243540612":"Twitch Rivals Desk",
+"-242084766":"Cooked Pork",
+"-237809779":"Hemp Seed",
+"-226151558":"2 Module Car Chassis",
+"-218009552":"Homing Missile Launcher",
+"-216999575":"Counter",
+"-216116642":"Skull Door Knocker",
+"-211235948":"Xylobone",
+"-209869746":"Decorative Plastic Candy Canes",
+"-196667575":"Flashlight",
+"-194953424":"Metal Facemask",
+"-194509282":"Butcher Knife",
+"-193519904":"Single Shallow Wall Shelves",
+"-187304968":"Battering Ram",
+"-187031121":"Solo Submarine",
+"-180129657":"Wood Storage Box",
+"-176608084":"Sunglasses",
+"-173268138":"Rustigé Egg - Amethyst",
+"-173268132":"Rustigé Egg - Blue",
+"-173268131":"Rustigé Egg - Purple",
+"-173268129":"Rustigé Egg - Red",
+"-173268128":"Rustigé Egg - White",
+"-173268127":"Rustigé Egg - Cerulean",
+"-173268126":"Rustigé Egg - Ivory",
+"-173268125":"Rustigé Egg - Green",
+"-170436364":"Cooked Snake Meat",
+"-158718378":"Small Ramp",
+"-156748077":"Skull Trophy",
+"-152332823":"Chicken Costume",
+"-151838493":"Wood",
+"-151387974":"Deluxe Christmas Lights",
+"-148794216":"Garage Door",
+"-148229307":"Metal Shop Front",
+"-144513264":"Pipe Tool",
+"-144417939":"Wire Tool",
+"-143481979":"Basic Blueprint Fragment",
+"-143132326":"Huge Wooden Sign",
+"-139037392":"Abyss Assault Rifle",
+"-135252633":"Sled",
+"-134959124":"Light Frankenstein Head",
+"-132516482":"Weapon Lasersight",
+"-132247350":"Small Water Catcher",
+"-129230242":"Decorative Pinecones",
+"-126305173":"Painted Egg",
+"-119235651":"Water Jug",
+"-113413047":"Diving Mask",
+"-110921842":"Locker",
+"-105415879":"Frontier Suit",
+"-105343718":"Circle Balloon",
+"-99886070":"Violet Roman Candle",
+"-97956382":"Tool Cupboard",
+"-97459906":"Jumpsuit",
+"-96256997":"Wide Weapon Rack",
+"-92759291":"Wooden Floor Spikes",
+"-92315244":"High Caliber Revolver",
+"-89874794":"Low Quality Spark Plugs",
+"-82758111":"Scrap Mirror Large",
+"-78533081":"Burnt Deer Meat",
+"-75944661":"Eoka Pistol",
+"-73195037":"Legacy Bow",
+"-52398594":"Frontier Horns Single Item Rack",
+"-48090175":"Snow Jacket",
+"-44876289":"Igniter",
+"-44066823":"Medium Chassis",
+"-44066790":"Large Chassis",
+"-44066600":"Small Chassis",
+"-41896755":"Workbench Level 2",
+"-41440462":"Spas-12 Shotgun",
+"-34498533":"Cannon",
+"-33009419":"Pure Anti-Rad Tea",
+"-25740268":"Skull Spikes",
+"-24571537":"Coconut",
+"-23994173":"Boonie Hat",
+"-22883916":"Dragon Mask",
+"-20045316":"Mobile Phone",
+"-19360132":"Rose Clone",
+"-19318653":"Hammerhead Bolt",
+"-17123659":"Smoke Rocket WIP!!!!",
+"-10594280":"Sulfur Storage Box",
+"-8312704":"Beach Towel",
+"-7270019":"Orange Boomer",
+"-4031221":"Metal Ore",
+"3222790":"Hide Halterneck",
+"3380160":"Card Movember Moustache",
+"4384538":"Apple Pie",
+"14241751":"Fire Arrow",
+"15388698":"Stone Barricade",
+"20489901":"Purple Sunglasses",
+"21402876":"Burlap Gloves",
+"22947882":"White ID Tag",
+"23352662":"Large Banner Hanging",
+"23391694":"Bunny Hat",
+"28201841":"M39 Rifle",
+"37122747":"Green Keycard",
+"39600618":"Microphone Stand",
+"42535890":"Medium Animated Neon Sign",
+"51984655":"Incendiary Pistol Bullet",
+"54265286":"Crocodile Pie",
+"54436981":"Fairy Lights",
+"60528587":"Roadsign Horse Armor",
+"62577426":"Photograph",
+"69511070":"Metal Fragments",
+"70102328":"Red ID Tag",
+"73681876":"Tech Trash",
+"81423963":"Yellow ID Tag",
+"82772055":"Horse",
+"86840834":"NVGM Scientist Suit",
+"94971664":"Stone Storage Box",
+"95950017":"Metal Pipe",
+"97903330":"Pure Crafting Quality Tea",
+"98508942":"XXL Picture Frame",
+"99588025":"High External Wooden Wall",
+"104856514":"Bulb String Lights",
+"106959911":"Light Frankenstein Legs",
+"110116923":"Ice Metal Facemask",
+"120820987":"Chicken Pie",
+"121049755":"Tall Picture Frame",
+"122783240":"Black Berry Clone",
+"140006625":"PTZ CCTV Camera",
+"143803535":"F1 Grenade",
+"146221721":"Heavy Scientist Plushie",
+"158303804":"Obsidian Bone Knife",
+"162882477":"#50cal",
+"170758448":"Cockpit With Engine Vehicle Module",
+"171931394":"Stone Pickaxe",
+"174866732":"Variable Zoom Scope",
+"176787552":"Rifle Body",
+"177226991":"Scarecrow",
+"180752235":"Pink ID Tag",
+"184516676":"Beehive",
+"185586769":"Inner Tube",
+"190184021":"Kayak",
+"192249897":"Green",
+"196700171":"Hide Vest",
+"196784377":"Improvised Shield",
+"198438816":"Vending Machine",
+"200773292":"Hammer",
+"204391461":"Coal :(",
+"204970153":"Wrapped Gift",
+"209218760":"Head Bag",
+"210787554":"Engineering Workbench",
+"215754713":"Bone Arrow",
+"223891266":"T-Shirt",
+"236677901":"Prototype Pickaxe",
+"237239288":"Pants",
+"240752557":"Tall Weapon Rack",
+"242421166":"Light-Up Frame Large",
+"242933621":"Frontier Mirror Large",
+"248643189":"Spoiled Big Cat Meat",
+"254522515":"Large Medkit",
+"255305250":"Wooden Boat Ladder",
+"261913429":"White Volcano Firework",
+"263834859":"Basic Scrap Tea",
+"268565518":"Storage Vehicle Module",
+"271048478":"Rat Mask",
+"273172220":"Plumber's Trumpet",
+"273951840":"Scarecrow Suit",
+"277730763":"Mummy Suit",
+"281099360":"Bread Loaf",
+"282103175":"Giant Lollipop Decor",
+"286193827":"Pickles",
+"286648290":"Disco Floor",
+"296519935":"Diving Fins",
+"301063058":"Wanted Poster 2",
+"304481038":"Flare",
+"309017792":"Big Cat Pie",
+"317398316":"High Quality Metal",
+"320438357":"Hunters Pie",
+"340210699":"Frontier Mirror Small",
+"342438846":"Anchovy",
+"343045591":"MLRS Aiming Module",
+"349762871":"40mm HE Grenade",
+"352130972":"Rotten Apple",
+"352321488":"Sunglasses",
+"352499047":"Shotgun Trap",
+"355877490":"Minigun Ammo Pack",
+"359723196":"Chippy Arcade Game",
+"362863314":"Heart Balloon",
+"363163265":"Hose Tool",
+"368008432":"Basic Crafting Quality Tea",
+"375473148":"Scrap Transport Helicopter",
+"377750553":"Pure Harvesting Tea",
+"381595627":"Twitch Rivals Neon Sign",
+"385099196":"4 Module Car Chassis",
+"385645417":"Paintball",
+"390728933":"Yellow Berry Clone",
+"392828520":"Cooked Crocodile Meat",
+"405904531":"Soccer Ball",
+"405905095":"Sail",
+"418081930":"Wood Chestplate",
+"442289265":"Holosight",
+"442886268":"Rocket Launcher",
+"443432036":"Fluid Switch & Pump",
+"445662288":"Scientist Plushie",
+"446206234":"Torch Holder",
+"450531685":"Light-Up Mirror Large",
+"468313189":"Twitch Rivals Hazmat Suit",
+"472505338":"Medieval AR",
+"476066818":"Cassette - Long",
+"479143914":"Gears",
+"479292118":"Large Loot Bag",
+"486661382":"Clan Table",
+"491263800":"Nomad Suit",
+"492357192":"RAND Switch",
+"494161326":"RPG Launcher",
+"504109620":"Ice Sculpture",
+"507284030":"Coconut Armor Pants",
+"524678627":"Advanced Scrap Tea",
+"528668503":"Flame Turret",
+"533993281":"Space LR-300 Assault Rifle",
+"537946062":"Flight Recorder Box",
+"547862680":"Knights armour cuirass",
+"550753330":"Snowball Gun Ammo",
+"553270375":"Large Rechargeable Battery",
+"553887414":"Skull Fire Pit",
+"553967074":"Wallpaper Wall",
+"559147458":"Survival Fish Trap",
+"567235583":"8x Zoom Scope",
+"567871954":"Secretlab Chair",
+"571949408":"Clump of Mixed Balloons",
+"573676040":"Coffin",
+"573926264":"Semi Automatic Body",
+"574701440":"Scrap Storage Box",
+"576509618":"Portable Boom Box",
+"588596902":"Handmade Shell",
+"593465182":"Table",
+"594041190":"Compass",
+"596469572":"RF Transmitter",
+"602628465":"Parachute",
+"602741290":"Burlap Shirt",
+"603811464":"Advanced Max Health Tea",
+"605467368":"Incendiary 5.56 Rifle Ammo",
+"607400343":"Legacy Wood Shelter",
+"607785075":"Armored Ladder Hatch",
+"609049394":"Battery - Small",
+"610102428":"Industrial Conveyor",
+"613961768":"Bota Bag",
+"615112838":"Rail Road Planter",
+"621915341":"Raw Pork",
+"625599716":"Metal Shield",
+"634478325":"CCTV Camera",
+"640470230":"Ceiling Fluorescent Light",
+"642482233":"Sticks",
+"647240052":"Triangle Rail Road Planter",
+"649912614":"Revolver",
+"652793345":"Krieg Storage Crates",
+"656371026":"High Quality Carburetor",
+"656371027":"Medium Quality Carburetor",
+"656371028":"Low Quality Carburetor",
+"656829501":"Wall Cabinet",
+"657352755":"Beach Table",
+"665332906":"Timer",
+"671063303":"Riot Helmet",
+"671706427":"Reinforced Glass Window",
+"674734128":"Festive Doorway Garland",
+"678698219":"M4 Shotgun",
+"679690962":"Tools Storage Box",
+"680234026":"Yellow Perch",
+"695450239":"Lunar New Year Spear",
+"696029452":"Paper Map",
+"696029539":"Hot Air Balloon",
+"699075597":"Wooden Cross",
+"703057617":"Military Flame Thrower",
+"709206314":"Tiger Mask",
+"721798950":"Car Radio",
+"722955039":"Water Gun",
+"723407026":"Wood Mirror Standing",
+"734320711":"Orchid",
+"738611016":"Paintable Window",
+"742745918":"Industrial Splitter",
+"755224797":"Vodka Bottle",
+"756125481":"Wood Mirror Medium",
+"756517185":"Medium Present",
+"756890702":"High External Adobe Wall",
+"762289806":"Siren Light",
+"782422285":"Sofa - Pattern",
+"785728077":"Pistol Bullet",
+"789333045":"Sunken Combat Knife",
+"794356786":"Hide Boots",
+"794443127":"Christmas Tree",
+"795236088":"Torch",
+"795371088":"Pump Shotgun",
+"803222026":"Repair Bench",
+"803954639":"Blue Berry Seed",
+"809199956":"Gravestone",
+"809689733":"Mummy Mask",
+"809942731":"Scarecrow Wrap",
+"813023040":"Cooked Wolf Meat",
+"814297925":"Medieval Large Wood Box",
+"818733919":"Industrial Door",
+"818877484":"Semi-Automatic Pistol",
+"821588319":"Bicycle",
+"826309791":"Two Sided Town Sign Post",
+"829641693":"Anchor",
+"830839496":"Red Berry Seed",
+"831955134":"Sky Lantern - Purple",
+"832133926":"Wood Armor Pants",
+"833533164":"Large Wood Box",
+"835042040":"Medium Frankenstein Legs",
+"838308300":"Burst Module",
+"838831151":"Blue Berry Clone",
+"839738457":"Scrap Mirror Medium",
+"844440409":"Bronze Egg",
+"850280505":"Bucket Helmet",
+"853471967":"Laser Light",
+"854447607":"White Berry",
+"858486327":"Green Berry",
+"861513346":"Lumberjack Suit",
+"866332017":"Large Neon Sign",
+"866889860":"Wooden Barricade",
+"878301596":"Generic vehicle module",
+"882559853":"Spider Webs",
+"884424049":"Compound Bow",
+"888415708":"RF Receiver",
+"895374329":"Passenger Vehicle Module",
+"912235912":"Sunflower Clone",
+"915408809":"40mm Smoke Grenade",
+"920930831":"Blue Industrial Wall Light",
+"924598634":"Wheat Clone",
+"926800282":"Medium Quality Valves",
+"935606207":"Minigun",
+"935692442":"Longsleeve T-Shirt",
+"936496778":"Floor grill",
+"952603248":"Weapon flashlight",
+"960673498":"Large Hunting Trophy",
+"962186730":"Tin Can Alarm",
+"963400638":"Speech Bubble Balloon",
+"963906841":"Rock",
+"968019378":"Clatter Helmet",
+"968421290":"Connected Speaker",
+"969768382":"Reinforced Wooden Shield",
+"971362526":"Skull Trophy",
+"972302244":"Kick Hazmat",
+"975983052":"Twitch Rivals Trophy",
+"980333378":"Hide Poncho",
+"988652725":"Smart Switch",
+"989925924":"Raw Fish",
+"992944937":"Ore Storage Box",
+"996293980":"Human Skull",
+"996757362":"Wagon",
+"998894949":"Corn Seed",
+"999690781":"Geiger Counter",
+"1004843240":"Orchid Seed",
+"1015352446":"Duo Submarine",
+"1023919015":"Food Storage Box",
+"1028889957":"Light-Up Mirror Medium",
+"1036321299":"Blue Dog Tags",
+"1044081720":"Wood Storage Box",
+"1046904719":"Abyss Metal Hatchet",
+"1052926200":"Mining Quarry",
+"1055319033":"40mm Shotgun Round",
+"1058261682":"Christmas Lights",
+"1065594600":"Pilot Hazmat",
+"1072924620":"High Quality Spark Plugs",
+"1079279582":"Medical Syringe",
+"1081315464":"Nest Hat",
+"1081921512":"Card Table",
+"1090916276":"Pitchfork",
+"1094293920":"Wrapping Paper",
+"1099314009":"Barbeque",
+"1099611828":"Metal Armor Insert",
+"1103488722":"Snowball Gun",
+"1104520648":"Chainsaw",
+"1107575710":"Arctic Scientist Suit",
+"1110385766":"Metal Chest Plate",
+"1112162468":"Blue Berry",
+"1113514903":"Attack Helicopter",
+"1115193056":"Wall Divider Pack",
+"1121416193":"Pure Cooling Tea",
+"1121925526":"Candy Cane",
+"1127417055":"Armoured Ladder Hatch",
+"1130729138":"Spoiled Fish Meat",
+"1132603396":"Weapon Rack Stand",
+"1142993169":"Ceiling Light",
+"1145722690":"Catapult",
+"1149964039":"Storage Monitor",
+"1153652756":"Large Wooden Sign",
+"1158340331":"Medium Quality Crankshaft",
+"1158340332":"High Quality Crankshaft",
+"1158340334":"Low Quality Crankshaft",
+"1159991980":"Code Lock",
+"1160881421":"Hitch & Trough",
+"1168856825":"Metal Detector",
+"1168916338":"Bee Grenade",
+"1171735914":"AND Switch",
+"1174484438":"Mini Fridge",
+"1174957864":"Shockbyte Tool Cupboard",
+"1176355476":"Concrete Hatchet",
+"1177596584":"Elevator",
+"1178325727":"Wheat",
+"1181207482":"Heavy Plate Helmet",
+"1184215560":"Spoiled Produce",
+"1186655046":"Fuel Tank Vehicle Module",
+"1189981699":"Crate Costume",
+"1199391518":"Road Signs",
+"1205084994":"Large Photo Frame",
+"1205607945":"Two Sided Hanging Sign",
+"1221063409":"Armored Double Door",
+"1223729384":"Lavender ID Tag",
+"1223900335":"Dog Tag",
+"1230323789":"SMG Body",
+"1230691307":"Captain's Log",
+"1234878710":"Telephone",
+"1234880403":"Sewing Kit",
+"1242482355":"Jack O Lantern Angry",
+"1242522330":"Cursed Cauldron",
+"1248356124":"Timed Explosive Charge",
+"1248383659":"#50cal",
+"1254295946":"Armor Storage Box",
+"1258768145":"Sunglasses",
+"1259919256":"Mixing Table",
+"1263920163":"Smoke Grenade",
+"1266491000":"Hazmat Suit",
+"1268178466":"Green Industrial Wall Light",
+"1272194103":"Red Berry",
+"1272430949":"Wheelbarrow Piano",
+"1272768630":"Spoiled Human Meat",
+"1277159544":"Weapon Rack Double Light",
+"1285226495":"Bunny Costume",
+"1293102274":"XOR Switch",
+"1295301598":"Latex Balloon",
+"1296788329":"Homing Missile",
+"1305578813":"Small Neon Sign",
+"1305765685":"Krieg Storage Barrel",
+"1307626005":"Storage Barrel Vertical",
+"1312679249":"Wood Mirror Large",
+"1312843609":"Skull",
+"1315082560":"Ox Mask",
+"1318558775":"MP5A4",
+"1319617282":"Small Loot Bag",
+"1324203999":"Champagne Boomer",
+"1326180354":"Salvaged Sword",
+"1327005675":"Short Ice Wall",
+"1330084809":"Low Quality Valves",
+"1346158228":"Pumpkin Basket",
+"1348294923":"Spoiled Bear Meat",
+"1350707894":"Jungle Rock",
+"1353298668":"Armored Door",
+"1358643074":"Snow Machine",
+"1361520181":"Minecart Planter",
+"1364514421":"Blue ID Tag",
+"1365234594":"Gold Mirror large",
+"1366282552":"Leather Gloves",
+"1367190888":"Corn",
+"1371909803":"Tesla Coil",
+"1373240771":"Wooden Barricade Cover",
+"1373971859":"Python Revolver",
+"1376065505":"Rear Seats Vehicle Module",
+"1381010055":"Leather",
+"1382263453":"Barbed Wooden Barricade",
+"1390353317":"Sheet Metal Double Door",
+"1391703481":"Burnt Pork",
+"1394042569":"RHIB",
+"1397052267":"Supply Signal",
+"1400460850":"Saddle bag",
+"1401987718":"Duct Tape",
+"1409529282":"Door Closer",
+"1412103380":"Sunflower Seed",
+"1413014235":"Fridge",
+"1414245162":"Note",
+"1414245519":"Rose",
+"1414245522":"Rope",
+"1420547167":"Horse Costume",
+"1422530437":"Raw Deer Meat",
+"1424075905":"Water Bucket",
+"1426097945":"Coconut Armor Chestplate",
+"1426574435":"Minicopter",
+"1428574144":"Hopper",
+"1430085198":"Industrial Crafter",
+"1443579727":"Hunting Bow",
+"1447138977":"Light-Up Frame XXL",
+"1451568081":"Chainlink Fence Gate",
+"1456143403":"Cooking Workbench",
+"1463862472":"Wanted Poster 4",
+"1465782238":"Metal Storage Box",
+"1467878256":"Pork Pie",
+"1478091698":"Muzzle Brake",
+"1480022580":"Basic Ore Tea",
+"1482871705":"3 Module Car Chassis",
+"1488606552":"Retro Tool Cupboard",
+"1488979457":"Jackhammer",
+"1491189398":"Paddle",
+"1491753484":"Medium Frankenstein Torso",
+"1494014226":"Discord Trophy",
+"1512054436":"Potato Clone",
+"1516531815":"Basic Harvesting Tea",
+"1516985844":"Netting",
+"1521286012":"Double Sign Post",
+"1523195708":"Targeting Computer",
+"1523403414":"Cassette - Short",
+"1524187186":"Workbench Level 1",
+"1524980732":"Carvable Pumpkin",
+"1525520776":"Building Plan",
+"1533551194":"White Berry Clone",
+"1534542921":"Chair",
+"1536610005":"Cooked Human Meat",
+"1538126328":"Industrial Combiner",
+"1540934679":"Wooden Spear",
+"1542290441":"Single Sign Post",
+"1545779598":"Assault Rifle",
+"1548091822":"Apple",
+"1553078977":"Bleach",
+"1556365900":"Molotov Cocktail",
+"1557173737":"Sunglasses",
+"1559779253":"Engine Vehicle Module",
+"1559915778":"Single Horse Saddle",
+"1561022037":"Abyss Metal Pickaxe",
+"1562867678":"Artist Canvas XL",
+"1568388703":"Diesel Fuel",
+"1569882109":"Handmade Fishing Rod",
+"1572152877":"Mint ID Tag",
+"1575635062":"Frankenstein Table",
+"1578317134":"Hazmat Plushy",
+"1581210395":"Large Planter Box",
+"1586884551":"Flight Control Codelock",
+"1588298435":"Bolt Action Rifle",
+"1588492232":"Drone",
+"1601468620":"Blue Jumpsuit",
+"1601800933":"Jar of Honey",
+"1602646136":"Stone Spear",
+"1603174987":"Confetti Cannon",
+"1604092540":"Twitch Rivals 2025 Sofa",
+"1604837581":"Wooden Shield",
+"1608640313":"Tank Top",
+"1609921845":"Artist Canvas Small",
+"1614528785":"Heavy Frankenstein Torso",
+"1619039771":"Digital Clock",
+"1621942085":"Outbreak Sprayer",
+"1623701499":"Industrial Wall Light",
+"1629293099":"Snowman",
+"1629564540":"Wallpaper Tool",
+"1633553557":"Birthday Candle Hat",
+"1638322904":"Incendiary Rocket",
+"1643667218":"Large Animated Neon Sign",
+"1655650836":"Metal Barricade",
+"1655979682":"Empty Can Of Beans",
+"1658229558":"Lantern",
+"1659114910":"Gas Mask",
+"1659447559":"Wooden Horse Armor",
+"1660145984":"Yellow Berry",
+"1668129151":"Cooked Fish",
+"1668858301":"Small Stocking",
+"1673224590":"M15 Semi-Automatic Pistol",
+"1675639563":"Beenie Hat",
+"1680793490":"Boomerang",
+"1686524871":"Decorative Gingerbread Men",
+"1691223771":"Light-Up Frame Small",
+"1696050067":"Modular Car Lift",
+"1697996440":"Landscape Photo Frame",
+"1711033574":"Bone Club",
+"1712070256":"HV 5.56 Rifle Ammo",
+"1712261904":"Pure Max Health Tea",
+"1714496074":"Candle Hat",
+"1714509152":"Ballista",
+"1717250161":"Electric Table Lamp",
+"1719587208":"Targeting Attachment",
+"1719978075":"Bone Fragments",
+"1722154847":"Hide Pants",
+"1723747470":"Tree Lights",
+"1729120840":"Wooden Door",
+"1729374708":"Pure Ore Tea",
+"1729712564":"Portrait Photo Frame",
+"1730664641":"Wallpaper Ceiling",
+"1732236518":"Caboose",
+"1735402444":"Disco Floor",
+"1736620421":"Clothing Storage Box",
+"1744298439":"Blue Boomer",
+"1746956556":"Bone Armor",
+"1751045826":"Hoodie",
+"1757265204":"Silver Egg",
+"1758333838":"Teal",
+"1762167092":"Green ID Tag",
+"1768112091":"Tomaha Snowmobile",
+"1769475390":"Wood Frame Standing",
+"1770475779":"Worm",
+"1770744540":"Generic vehicle chassis",
+"1771755747":"Black Berry",
+"1776460938":"Blood",
+"1783512007":"Cactus Flesh",
+"1784005657":"Parachute (Deployed)",
+"1784406797":"Sousaphone",
+"1787198294":"Frontier Mirror Standing",
+"1789825282":"Candy Cane Club",
+"1796682209":"Custom SMG",
+"1801656689":"Light-Up Frame XL",
+"1803831286":"Garry's Mod Tool Gun",
+"1811780502":"Radioactive Water",
+"1814288539":"Bone Knife",
+"1819863051":"Sky Lantern",
+"1827479659":"Burnt Wolf Meat",
+"1831249347":"Scattershot",
+"1835946060":"Cable Tunnel",
+"1840570710":"Above Ground Pool",
+"1840822026":"Beancan Grenade",
+"1846605708":"Abyss Torch",
+"1849409072":"Silly Horse Mask",
+"1849887541":"Small Generator",
+"1850456855":"Road Sign Kilt",
+"1856217390":"Egg Basket",
+"1858828593":"Egg",
+"1865253052":"Dracula Mask",
+"1869224826":"Motorbike With Sidecar",
+"1873004466":"Coconut Armor Gloves",
+"1873897110":"Cooked Bear Meat",
+"1874610722":"Armored Cockpit Vehicle Module",
+"1877339384":"Burlap Headwrap",
+"1878053256":"Rowboat",
+"1882709339":"Metal Blade",
+"1883981798":"Low Quality Pistons",
+"1883981800":"High Quality Pistons",
+"1883981801":"Medium Quality Pistons",
+"1884461210":"Charcoal Storage Box",
+"1885488976":"Spooky Speaker",
+"1892536031":"Fluorescent Light",
+"1895235349":"Disco Ball",
+"1898094925":"Pumpkin Plant Clone",
+"1899610628":"Medium Loot Bag",
+"1903654061":"Small Planter Box",
+"1905387657":"Pure Rad. Removal Tea",
+"1911552868":"Black Berry Seed",
+"1914691295":"Prototype 17",
+"1916016738":"Light-Up Mirror Standing",
+"1917703890":"Burnt Horse Meat",
+"1925646349":"Spoiled Pork Meat",
+"1931713481":"Black Raspberries",
+"1933140008":"PT Boat",
+"1937380239":"Frontier Hatchet",
+"1946219319":"Camp Fire",
+"1948067030":"Ladder Hatch",
+"1950013766":"Light-Up Frame Standing",
+"1950721418":"Salvaged Shelves",
+"1951603367":"Switch",
+"1953903201":"Nailgun",
+"1954597876":"Bee Catapult Bomb",
+"1965232394":"Crossbow",
+"1973165031":"Birthday Cake",
+"1973684065":"Burnt Chicken",
+"1973949960":"Frontier Bolts Single Item Rack",
+"1975934948":"Survey Charge",
+"1983621560":"Floor triangle grill",
+"1989785143":"High Quality Horse Shoes",
+"1991794121":"Trike",
+"1992974553":"Burlap Trousers",
+"1993693904":"Boat Building Station",
+"2005491391":"Extended Magazine",
+"2009734114":"Christmas Door Wreath",
+"2019042823":"Tarp",
+"2021351233":"Advanced Rad. Removal Tea",
+"2023888403":"Medium Rechargeable Battery",
+"2024467711":"Pure Scrap Tea",
+"2036395619":"Scatter Dart",
+"2039177180":"Bear Pie",
+"2040726127":"Combat Knife",
+"2041899972":"Triangle Ladder Hatch",
+"2047789913":"Lead Armor Insert",
+"2048317869":"Wolf Skull",
+"2052270186":"Inner Tube",
+"2054391128":"Factory Door",
+"2054929933":"Jungle Relic Assault Rifle",
+"2055695285":"Frontier Mirror Medium",
+"2063916636":"Advanced Ore Tea",
+"2068884361":"Small Backpack",
+"2070189026":"Large Banner on pole",
+"2083256995":"Handmade SMG",
+"2087678962":"Search Light",
+"2090395347":"Large Solar Panel",
+"2100007442":"Audio Alarm",
+"2104517339":"Strobe Light",
+"2106561762":"Decorative Tinsel",
+"2114754781":"Water Purifier",
+"2120241887":"Gold Mirror Standing",
+"2126889441":"Santa Beard",
+"2130820932":"Cancer Research UK Plushie",
+"2130820933":"Ronald McDonald House UK Plushie",
+"2133269020":"Red Berry Clone"
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs b/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs
new file mode 100644
index 0000000..c4038c1
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Pairing/StorageMonitorPairingCoordinator.cs
@@ -0,0 +1,143 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Features.StorageMonitors.Posting;
+using RustPlusBot.Features.StorageMonitors.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.StorageMonitors;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.StorageMonitors.Pairing;
+
+/// Turns a into an "Add it?" prompt and, on Accept, a managed storage monitor.
+/// Opens scopes for the scoped storage monitor/workspace stores.
+/// Resolves the #storagemonitors channel id.
+/// Posts/edits storage monitor + prompt messages.
+/// Renders the prompt and storage monitor embeds.
+internal sealed class StorageMonitorPairingCoordinator(
+ IServiceScopeFactory scopeFactory,
+ IStorageMonitorChannelLocator locator,
+ IStorageMonitorChannelPoster poster,
+ StorageMonitorEmbedRenderer renderer)
+{
+ private readonly ConcurrentDictionary<(ulong Guild, Guid Server, ulong Entity), Pending> _pending = new();
+
+ /// Gets the held default name for a pending pairing, or null.
+ /// The guild id.
+ /// The server id.
+ /// The storage monitor entity id.
+ /// The held default name, or null when no pending pairing exists.
+ public string? PendingName(ulong guildId, Guid serverId, ulong entityId) =>
+ _pending.TryGetValue((guildId, serverId, entityId), out var p) ? p.DefaultName : null;
+
+ /// Handles a paired storage monitor: ignore if already managed, else post the prompt and hold pending state.
+ /// The paired-storage-monitor event.
+ /// A token to cancel the operation.
+ /// A task that completes when the prompt has been posted (or the storage monitor was ignored).
+ public async Task HandlePairedAsync(StorageMonitorPairedEvent evt, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ if (await ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ var channelId = await locator.GetChannelIdAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (channelId is not { } channel)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(evt.GuildId, cancellationToken).ConfigureAwait(false);
+ var defaultName = $"Storage Monitor {evt.EntityId}";
+ var (embed, components) = renderer.RenderPrompt(evt.ServerId, evt.EntityId, defaultName, culture);
+ var messageId = await poster.EnsureAsync(channel, null, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ _pending[(evt.GuildId, evt.ServerId, evt.EntityId)] = new Pending(defaultName, messageId);
+ }
+
+ /// Accepts a pending pairing: persist + replace prompt with the storage monitor embed. Race-guarded.
+ /// The guild id.
+ /// The server id.
+ /// The storage monitor entity id.
+ /// The id of the user who accepted the pairing.
+ /// A token to cancel the operation.
+ /// True when the storage monitor was persisted; false when it was already managed (race).
+ public async Task TryAcceptAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong acceptingUserId,
+ CancellationToken cancellationToken)
+ {
+ if (await ExistsAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false))
+ {
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+ return false;
+ }
+
+ _pending.TryGetValue((guildId, serverId, entityId), out var pending);
+ var name = pending?.DefaultName ?? $"Storage Monitor {entityId}";
+
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ var added = await store.AddAsync(guildId, serverId, entityId, name, acceptingUserId, cancellationToken)
+ .ConfigureAwait(false);
+
+ var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (channelId is { } channel)
+ {
+ var culture = await GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+
+ // The storage monitor is freshly accepted; contents are unknown until the next prime/trigger arrives
+ // moments later. Render with contents: null (unreachable). The supervisor's prime path republishes
+ // real contents shortly (same pattern as switches).
+ var (embed, components) = renderer.RenderMonitor(added, contents: null, culture);
+ var newMessageId = await poster
+ .EnsureAsync(channel, pending?.MessageId, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ if (newMessageId is { } mid)
+ {
+ await store.SetMessageIdAsync(guildId, serverId, entityId, mid, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+ return true;
+ }
+
+ /// Drops a pending pairing; returns whether one was present.
+ /// The guild id.
+ /// The server id.
+ /// The storage monitor entity id.
+ /// True when a pending pairing was removed; false when none was held.
+ public bool TryDismiss(ulong guildId, Guid serverId, ulong entityId) =>
+ _pending.TryRemove((guildId, serverId, entityId), out _);
+
+ private async Task ExistsAsync(ulong guildId, Guid serverId, ulong entityId, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ return await store.ExistsAsync(guildId, serverId, entityId, ct).ConfigureAwait(false);
+ }
+ }
+
+ private async Task GetCultureAsync(ulong guildId, CancellationToken ct)
+ {
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, ct).ConfigureAwait(false);
+ }
+ }
+
+ private sealed record Pending(string DefaultName, ulong? MessageId);
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs
new file mode 100644
index 0000000..1406c6b
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Posting/DiscordStorageMonitorChannelPoster.cs
@@ -0,0 +1,24 @@
+using Discord;
+using Discord.WebSocket;
+using Microsoft.Extensions.Logging;
+using RustPlusBot.Discord.Posting;
+
+namespace RustPlusBot.Features.StorageMonitors.Posting;
+
+/// Posts/edits storage monitor embeds in #storagemonitors by message id. Untested integration shim.
+/// The Discord socket client.
+/// The logger.
+internal sealed class DiscordStorageMonitorChannelPoster(
+ DiscordSocketClient client,
+ ILogger logger) : IStorageMonitorChannelPoster
+{
+ ///
+ public Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ Embed embed,
+ MessageComponent components,
+ CancellationToken cancellationToken)
+ => DiscordChannelMessenger.EnsureAsync(client, channelId, messageId, embed, components, logger,
+ cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs
new file mode 100644
index 0000000..399a1e1
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Posting/IStorageMonitorChannelPoster.cs
@@ -0,0 +1,19 @@
+namespace RustPlusBot.Features.StorageMonitors.Posting;
+
+/// Posts/edits a storage monitor embed in #storagemonitors by message id, self-healing a deleted message.
+internal interface IStorageMonitorChannelPoster
+{
+ /// Edits the message at if present and found; otherwise posts a new one.
+ /// The #storagemonitors channel id.
+ /// The known embed message id, or null to post fresh.
+ /// The embed to show.
+ /// The control row.
+ /// A cancellation token.
+ /// The (possibly new) message id, or null on failure.
+ Task EnsureAsync(
+ ulong channelId,
+ ulong? messageId,
+ global::Discord.Embed embed,
+ global::Discord.MessageComponent components,
+ CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs
new file mode 100644
index 0000000..beddd43
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Relaying/StorageMonitorStateRelay.cs
@@ -0,0 +1,129 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Connections;
+using RustPlusBot.Abstractions.Events;
+using RustPlusBot.Domain.Connections;
+using RustPlusBot.Domain.StorageMonitors;
+using RustPlusBot.Features.StorageMonitors.Posting;
+using RustPlusBot.Features.StorageMonitors.Rendering;
+using RustPlusBot.Features.Workspace.Locating;
+using RustPlusBot.Persistence.Connections;
+using RustPlusBot.Persistence.StorageMonitors;
+using RustPlusBot.Persistence.Workspace;
+
+namespace RustPlusBot.Features.StorageMonitors.Relaying;
+
+/// Keeps storage monitor embeds in sync: trigger events re-render with the carried contents; a non-Connected server marks them unreachable.
+/// Opens scopes for the scoped stores.
+/// Resolves the #storagemonitors channel id.
+/// Posts/edits storage monitor embeds.
+/// Renders storage monitor embeds.
+internal sealed class StorageMonitorStateRelay(
+ IServiceScopeFactory scopeFactory,
+ IStorageMonitorChannelLocator locator,
+ IStorageMonitorChannelPoster poster,
+ StorageMonitorEmbedRenderer renderer)
+{
+ /// Handles a storage monitor trigger: ignore unmanaged ids; else render the event's contents directly.
+ /// The storage monitor triggered event.
+ /// A cancellation token.
+ /// A task that completes when the embed has been re-rendered (or the id was ignored).
+ public async Task HandleTriggeredAsync(StorageMonitorTriggeredEvent evt, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var store = scope.ServiceProvider.GetRequiredService();
+ if (!await store.ExistsAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
+ .ConfigureAwait(false))
+ {
+ return; // not a storage monitor this relay manages — ignore.
+ }
+
+ var monitor = await store.GetAsync(evt.GuildId, evt.ServerId, evt.EntityId, cancellationToken)
+ .ConfigureAwait(false);
+ if (monitor is null)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
+ .ConfigureAwait(false);
+ await RenderAsync(store, monitor, evt.Contents, evt.GuildId, evt.ServerId, culture, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ /// Handles a connection-status change: a non-Connected server marks its storage monitor embeds unreachable.
+ /// The connection-status change.
+ /// A cancellation token.
+ /// A task that completes when every affected embed has been re-rendered.
+ public async Task HandleConnectionStatusAsync(
+ ConnectionStatusChangedEvent evt,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(evt);
+ var scope = scopeFactory.CreateAsyncScope();
+ await using (scope.ConfigureAwait(false))
+ {
+ var connections = scope.ServiceProvider.GetRequiredService();
+ var state = await connections.GetStateAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (state is { Status: ConnectionStatus.Connected })
+ {
+ // The supervisor's prime path republishes real state on connect; nothing to do here.
+ return;
+ }
+
+ var store = scope.ServiceProvider.GetRequiredService();
+ var monitors = await store.ListByServerAsync(evt.GuildId, evt.ServerId, cancellationToken)
+ .ConfigureAwait(false);
+ if (monitors.Count == 0)
+ {
+ return;
+ }
+
+ var culture = await GetCultureAsync(scope.ServiceProvider, evt.GuildId, cancellationToken)
+ .ConfigureAwait(false);
+ foreach (var monitor in monitors)
+ {
+ await RenderAsync(store, monitor, contents: null, evt.GuildId, evt.ServerId, culture,
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task RenderAsync(
+ IStorageMonitorStore store,
+ SmartStorageMonitor monitor,
+ StorageContentsSnapshot? contents,
+ ulong guildId,
+ Guid serverId,
+ string culture,
+ CancellationToken cancellationToken)
+ {
+ var channelId = await locator.GetChannelIdAsync(guildId, serverId, cancellationToken).ConfigureAwait(false);
+ if (channelId is not { } channel)
+ {
+ return;
+ }
+
+ var (embed, components) = renderer.RenderMonitor(monitor, contents, culture);
+ var newMessageId = await poster.EnsureAsync(channel, monitor.MessageId, embed, components, cancellationToken)
+ .ConfigureAwait(false);
+ if (newMessageId is { } mid && mid != monitor.MessageId)
+ {
+ await store.SetMessageIdAsync(guildId, serverId, monitor.EntityId, mid, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+
+ private static async Task GetCultureAsync(
+ IServiceProvider provider,
+ ulong guildId,
+ CancellationToken cancellationToken)
+ {
+ var store = provider.GetRequiredService();
+ return await store.GetCultureAsync(guildId, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs
new file mode 100644
index 0000000..4a30ef2
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorComponentIds.cs
@@ -0,0 +1,23 @@
+namespace RustPlusBot.Features.StorageMonitors.Rendering;
+
+/// Custom ids for storage-monitor components. Tails encode "{serverId}:{entityId}".
+internal static class StorageMonitorComponentIds
+{
+ /// Pairing-prompt Accept button; tail "{serverId}:{entityId}".
+ public const string AcceptPrefix = "storage:accept:";
+
+ /// Pairing-prompt Dismiss button; tail "{serverId}:{entityId}".
+ public const string DismissPrefix = "storage:dismiss:";
+
+ /// Refresh button (re-reads contents); tail "{serverId}:{entityId}".
+ public const string RefreshPrefix = "storage:refresh:";
+
+ /// Rename button (opens the modal); tail "{serverId}:{entityId}".
+ public const string RenamePrefix = "storage:rename:";
+
+ /// Rename modal id; tail "{serverId}:{entityId}".
+ public const string RenameModalPrefix = "storage:rename:modal:";
+
+ /// The rename modal's text input id.
+ public const string RenameInputId = "storage:rename:input";
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs
new file mode 100644
index 0000000..2f0a5b8
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/Rendering/StorageMonitorEmbedRenderer.cs
@@ -0,0 +1,151 @@
+using System.Globalization;
+using System.Text;
+using Discord;
+using RustPlusBot.Abstractions.Connections;
+using RustPlusBot.Domain.StorageMonitors;
+using RustPlusBot.Features.StorageMonitors.Naming;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.StorageMonitors.Rendering;
+
+/// Renders a Smart Storage Monitor as a Discord embed + control row, and the pairing prompt. Pure.
+/// The shared localizer.
+/// Resolves item ids to display names.
+internal sealed class StorageMonitorEmbedRenderer(ILocalizer localizer, IItemNameResolver names)
+{
+ /// Renders the monitor embed and its Refresh/Rename buttons. null ⇒ unreachable.
+ /// The monitor.
+ /// The current contents, or null when unreachable.
+ /// The guild culture.
+ /// The embed and the component row.
+ public (Embed Embed, MessageComponent Components) RenderMonitor(
+ SmartStorageMonitor monitor,
+ StorageContentsSnapshot? contents,
+ string culture)
+ {
+ ArgumentNullException.ThrowIfNull(monitor);
+ var unreachable = contents is null;
+
+ var description = new StringBuilder();
+ if (unreachable)
+ {
+ description.Append(localizer.Get("storage.status.unreachable", culture));
+ }
+ else
+ {
+ description.AppendLine(localizer.Get(TypeKey(contents!.Capacity), culture));
+ AppendProtection(description, contents, culture);
+ AppendContents(description, contents, culture);
+ }
+
+ var embed = new EmbedBuilder()
+ .WithTitle(monitor.Name)
+ .WithDescription(description.ToString())
+ .WithFooter(localizer.Get("storage.embed.footer", culture, monitor.EntityId))
+ .Build();
+
+ var tail = $"{monitor.ServerId}:{monitor.EntityId}";
+ var components = new ComponentBuilder()
+ .WithButton(localizer.Get("storage.button.refresh", culture),
+ StorageMonitorComponentIds.RefreshPrefix + tail, ButtonStyle.Primary, disabled: unreachable)
+ .WithButton(localizer.Get("storage.button.rename", culture),
+ StorageMonitorComponentIds.RenamePrefix + tail, ButtonStyle.Secondary, disabled: unreachable)
+ .Build();
+
+ return (embed, components);
+ }
+
+ /// Renders the transient "New storage monitor detected — Add it?" prompt.
+ /// The server id.
+ /// The entity id.
+ /// The generated default name.
+ /// The guild culture.
+ /// The prompt embed and Accept/Dismiss row.
+ public (Embed Embed, MessageComponent Components) RenderPrompt(
+ Guid serverId,
+ ulong entityId,
+ string defaultName,
+ string culture)
+ {
+ var embed = new EmbedBuilder()
+ .WithTitle(localizer.Get("storage.prompt.title", culture))
+ .WithDescription(localizer.Get("storage.prompt.body", culture, defaultName))
+ .Build();
+
+ var tail = $"{serverId}:{entityId}";
+ var components = new ComponentBuilder()
+ .WithButton(localizer.Get("storage.prompt.accept", culture),
+ StorageMonitorComponentIds.AcceptPrefix + tail, ButtonStyle.Success)
+ .WithButton(localizer.Get("storage.prompt.dismiss", culture),
+ StorageMonitorComponentIds.DismissPrefix + tail, ButtonStyle.Secondary)
+ .Build();
+
+ return (embed, components);
+ }
+
+ private static string TypeKey(int? capacity) => capacity switch
+ {
+ 24 => "storage.type.toolcupboard",
+ 48 => "storage.type.largebox",
+ 12 => "storage.type.smallbox",
+ _ => "storage.type.unknown",
+ };
+
+ private static string FormatRemaining(TimeSpan span)
+ {
+ if (span.TotalDays >= 1)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalDays}d {span.Hours}h");
+ }
+
+ if (span.TotalHours >= 1)
+ {
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalHours}h {span.Minutes}m");
+ }
+
+ return string.Create(CultureInfo.InvariantCulture, $"{(int)span.TotalMinutes}m");
+ }
+
+ private void AppendProtection(StringBuilder sb, StorageContentsSnapshot contents, string culture)
+ {
+ // Protection is only meaningful for a Tool Cupboard (capacity 24).
+ if (contents.Capacity != 24)
+ {
+ return;
+ }
+
+ if (contents is { HasProtection: true, ProtectionExpiry: { } expiry })
+ {
+ var remaining = expiry - DateTimeOffset.UtcNow;
+ sb.AppendLine(localizer.Get("storage.protection.on", culture,
+ FormatRemaining(remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining)));
+ }
+ else
+ {
+ sb.AppendLine(localizer.Get("storage.protection.off", culture));
+ }
+ }
+
+ private void AppendContents(StringBuilder sb, StorageContentsSnapshot contents, string culture)
+ {
+ var capacityText = contents.Capacity?.ToString(CultureInfo.InvariantCulture) ?? "?";
+ sb.AppendLine(localizer.Get("storage.slots", culture, contents.Items.Count, capacityText));
+
+ if (contents.Items.Count == 0)
+ {
+ sb.AppendLine(localizer.Get("storage.contents.empty", culture));
+ return;
+ }
+
+ foreach (var item in contents.Items.OrderByDescending(i => i.Quantity))
+ {
+ var name = names.Resolve(item.ItemId);
+ if (item.IsBlueprint)
+ {
+ name = localizer.Get("storage.contents.bp", culture, name);
+ }
+
+ sb.AppendLine(localizer.Get("storage.contents.line", culture, name, item.Quantity));
+ }
+ }
+}
diff --git a/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj b/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj
new file mode 100644
index 0000000..c04677d
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/RustPlusBot.Features.StorageMonitors.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3d7efe8
--- /dev/null
+++ b/src/RustPlusBot.Features.StorageMonitors/StorageMonitorServiceCollectionExtensions.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Discord;
+using RustPlusBot.Features.StorageMonitors.Hosting;
+using RustPlusBot.Features.StorageMonitors.Naming;
+using RustPlusBot.Features.StorageMonitors.Pairing;
+using RustPlusBot.Features.StorageMonitors.Posting;
+using RustPlusBot.Features.StorageMonitors.Relaying;
+using RustPlusBot.Features.StorageMonitors.Rendering;
+using RustPlusBot.Localization;
+
+namespace RustPlusBot.Features.StorageMonitors;
+
+/// DI registration for the Smart Storage Monitors feature.
+public static class StorageMonitorServiceCollectionExtensions
+{
+ /// Registers the item-name resolver, renderer, poster, coordinator, relay, modules, and hosted service.
+ /// The service collection to add to.
+ /// The same service collection, for chaining.
+ public static IServiceCollection AddStorageMonitors(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddRustPlusBotLocalization();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+
+ services.AddSingleton(new InteractionModuleAssembly(
+ typeof(StorageMonitorServiceCollectionExtensions).Assembly));
+
+ return services;
+ }
+}
diff --git a/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs
new file mode 100644
index 0000000..3f8fe23
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/IStorageMonitorChannelLocator.cs
@@ -0,0 +1,12 @@
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Resolves the per-server #storagemonitors channel (used to post/edit storage-monitor embeds).
+public interface IStorageMonitorChannelLocator
+{
+ /// Gets the Discord channel id of #storagemonitors for (, ), or null.
+ /// The guild snowflake.
+ /// The server id.
+ /// A cancellation token.
+ /// The Discord channel id, or null if not provisioned.
+ Task GetChannelIdAsync(ulong guildId, Guid serverId, CancellationToken cancellationToken);
+}
diff --git a/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs b/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs
new file mode 100644
index 0000000..d183313
--- /dev/null
+++ b/src/RustPlusBot.Features.Workspace/Locating/StorageMonitorChannelLocator.cs
@@ -0,0 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
+using RustPlusBot.Abstractions.Time;
+
+namespace RustPlusBot.Features.Workspace.Locating;
+
+/// Resolves the #storagemonitors channel id for a (guild, server).
+/// Opens scopes for the scoped workspace store.
+/// Drives the cache TTL.
+internal sealed class StorageMonitorChannelLocator(IServiceScopeFactory scopeFactory, IClock clock)
+ : CachingChannelLocator(scopeFactory, clock, WorkspaceChannelKeys.ServerStorageMonitors),
+ IStorageMonitorChannelLocator;
diff --git a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
index fb0f980..bf894ed 100644
--- a/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
+++ b/src/RustPlusBot.Features.Workspace/Specs/ServerWorkspaceSpecProvider.cs
@@ -20,6 +20,8 @@ public IEnumerable GetChannelSpecs() =>
ChannelPermissionProfile.Interactive, 4),
new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerAlarms, "channel.alarms.name",
ChannelPermissionProfile.Interactive, 5),
+ new(WorkspaceScope.PerServer, WorkspaceChannelKeys.ServerStorageMonitors, "channel.storagemonitors.name",
+ ChannelPermissionProfile.Interactive, 6),
];
///
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
index ef75abd..7d9841c 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceKeys.cs
@@ -29,6 +29,9 @@ internal static class WorkspaceChannelKeys
/// Key for the per-server #alarms channel.
public const string ServerAlarms = "alarms";
+
+ /// Per-server storage-monitors channel key.
+ public const string ServerStorageMonitors = "storagemonitors";
}
/// Stable message keys persisted as ProvisionedMessage.MessageKey.
diff --git a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
index 9cbcd6e..c52bb29 100644
--- a/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs
@@ -64,6 +64,7 @@ public static IServiceCollection AddWorkspace(this IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddHostedService();
diff --git a/src/RustPlusBot.Host/Program.cs b/src/RustPlusBot.Host/Program.cs
index fcdf266..172b2ff 100644
--- a/src/RustPlusBot.Host/Program.cs
+++ b/src/RustPlusBot.Host/Program.cs
@@ -14,6 +14,7 @@
using RustPlusBot.Features.Map;
using RustPlusBot.Features.Pairing;
using RustPlusBot.Features.Players;
+using RustPlusBot.Features.StorageMonitors;
using RustPlusBot.Features.Switches;
using RustPlusBot.Features.Workspace;
using RustPlusBot.Host.Credentials;
@@ -79,6 +80,7 @@
builder.Services.AddMap();
builder.Services.AddSwitches();
builder.Services.AddAlarms();
+builder.Services.AddStorageMonitors();
var host = builder.Build();
diff --git a/src/RustPlusBot.Host/RustPlusBot.Host.csproj b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
index 1b9cd69..df5e607 100644
--- a/src/RustPlusBot.Host/RustPlusBot.Host.csproj
+++ b/src/RustPlusBot.Host/RustPlusBot.Host.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/RustPlusBot.Localization/Strings.fr.resx b/src/RustPlusBot.Localization/Strings.fr.resx
index 6ccfa30..7745fac 100644
--- a/src/RustPlusBot.Localization/Strings.fr.resx
+++ b/src/RustPlusBot.Localization/Strings.fr.resx
@@ -90,6 +90,9 @@
configuration
+
+ moniteurs-stockage
+
interrupteurs
@@ -588,6 +591,60 @@
L'interrupteur est injoignable pour le moment.
+
+ Actualiser
+
+
+ Renommer
+
+
+ {0} (PL)
+
+
+ Vide
+
+
+ {0} ×{1}
+
+
+ Entité {0}
+
+
+ Non protégé
+
+
+ Protégé — expire dans {0}
+
+
+ Ajouter
+
+
+ Ajouter **{0}** ?
+
+
+ Ignorer
+
+
+ Nouveau moniteur de stockage détecté
+
+
+ {0} / {1} emplacements
+
+
+ ⚠️ Injoignable
+
+
+ Grande caisse
+
+
+ Petite caisse
+
+
+ Armoire à outils
+
+
+ Moniteur de stockage
+
Disponibilité du bot : {0}
diff --git a/src/RustPlusBot.Localization/Strings.resx b/src/RustPlusBot.Localization/Strings.resx
index 8875517..b319292 100644
--- a/src/RustPlusBot.Localization/Strings.resx
+++ b/src/RustPlusBot.Localization/Strings.resx
@@ -90,6 +90,9 @@
setup
+
+ storage-monitors
+
switches
@@ -588,6 +591,60 @@
Switch is unreachable right now.
+
+ Refresh
+
+
+ Rename
+
+
+ {0} (BP)
+
+
+ Empty
+
+
+ {0} ×{1}
+
+
+ Entity {0}
+
+
+ Not protected
+
+
+ Protected — expires in {0}
+
+
+ Add it
+
+
+ Add **{0}**?
+
+
+ Dismiss
+
+
+ New storage monitor detected
+
+
+ {0} / {1} slots
+
+
+ ⚠️ Unreachable
+
+
+ Large Box
+
+
+ Small Box
+
+
+ Tool Cupboard
+
+
+ Storage Monitor
+
Bot uptime: {0}
diff --git a/src/RustPlusBot.Persistence/BotDbContext.cs b/src/RustPlusBot.Persistence/BotDbContext.cs
index f6e0f96..7c3b2d7 100644
--- a/src/RustPlusBot.Persistence/BotDbContext.cs
+++ b/src/RustPlusBot.Persistence/BotDbContext.cs
@@ -9,6 +9,7 @@
using RustPlusBot.Domain.Guilds;
using RustPlusBot.Domain.Map;
using RustPlusBot.Domain.Servers;
+using RustPlusBot.Domain.StorageMonitors;
using RustPlusBot.Domain.Switches;
using RustPlusBot.Domain.Workspace;
using RustPlusBot.Persistence.Configurations;
@@ -52,6 +53,9 @@ public sealed class BotDbContext(DbContextOptions options) : Disco
/// Paired and managed Smart Alarms.
public DbSet SmartAlarms => Set();
+ /// Managed Smart Storage Monitors.
+ public DbSet SmartStorageMonitors => Set();
+
/// Per-guild event subscriptions.
public DbSet EventSubscriptions => Set();
@@ -81,6 +85,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.ApplyConfiguration(new PairedEntityConfiguration())
.ApplyConfiguration(new SmartSwitchConfiguration())
.ApplyConfiguration(new SmartAlarmConfiguration())
+ .ApplyConfiguration(new SmartStorageMonitorConfiguration())
.ApplyConfiguration(new EventSubscriptionConfiguration())
.ApplyConfiguration(new ProvisionedCategoryConfiguration())
.ApplyConfiguration(new ProvisionedChannelConfiguration())
diff --git a/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs b/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs
new file mode 100644
index 0000000..1ad02b1
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Configurations/SmartStorageMonitorConfiguration.cs
@@ -0,0 +1,26 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using RustPlusBot.Domain.Servers;
+using RustPlusBot.Domain.StorageMonitors;
+
+namespace RustPlusBot.Persistence.Configurations;
+
+internal sealed class SmartStorageMonitorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ builder.HasKey(s => s.Id);
+ builder.Property(s => s.Name).IsRequired().HasMaxLength(128);
+ builder.HasIndex(s => new
+ {
+ s.GuildId, s.ServerId, s.EntityId
+ }).IsUnique();
+
+ // Removing a RustServer cascades to its storage monitors so no orphaned rows linger.
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(s => s.ServerId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs
new file mode 100644
index 0000000..d9360f7
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.Designer.cs
@@ -0,0 +1,708 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using RustPlusBot.Persistence;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ [DbContext(typeof(BotDbContext))]
+ [Migration("20260626175614_SmartStorageMonitors")]
+ partial class SmartStorageMonitors
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParentId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("ParentId");
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.GuildEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("OwnerId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Guilds");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.MemberEntity", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("JoinedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Nickname")
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId", "UserId");
+
+ b.ToTable("Members");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.RoleEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("Color")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Permissions")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("Roles");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalName")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastTriggeredUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PingEveryone")
+ .HasColumnType("INTEGER");
+
+ b.Property("RelayToTeamChat")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartAlarms");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Muted")
+ .HasColumnType("INTEGER");
+
+ b.Property("Prefix")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("TEXT");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerCommandSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("ActiveCredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PlayerCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("RustServerId");
+
+ b.ToTable("ConnectionStates");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.FcmRegistration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedFcmCredentials")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("FcmRegistrations");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ProtectedPlayerToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("Status")
+ .HasColumnType("INTEGER");
+
+ b.Property("SteamId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "OwnerUserId")
+ .IsUnique();
+
+ b.ToTable("PlayerCredentials");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Entities.PairedEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("PairedEntities");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Events.EventSubscription", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EventKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GuildId", "RustServerId");
+
+ b.ToTable("EventSubscriptions");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Guilds.GuildSettings", b =>
+ {
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Culture")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.HasKey("GuildId");
+
+ b.ToTable("GuildSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowGrid")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMarkers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowMonuments")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowPlayers")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowRigs")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowVendor")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ServerId");
+
+ b.ToTable("ServerMapSettings");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Servers.RustServer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("FacepunchServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Ip")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Port")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FacepunchServerId");
+
+ b.HasIndex("GuildId");
+
+ b.HasIndex("GuildId", "Ip", "Port")
+ .IsUnique();
+
+ b.ToTable("RustServers");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartStorageMonitors");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastIsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartSwitches");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordCategoryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId")
+ .IsUnique();
+
+ b.ToTable("ProvisionedCategories");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ChannelKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "ChannelKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedChannels");
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("DiscordChannelId")
+ .HasColumnType("INTEGER");
+
+ b.Property("DiscordMessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("RustServerId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RustServerId");
+
+ b.HasIndex("GuildId", "RustServerId", "MessageKey")
+ .IsUnique();
+
+ b.ToTable("ProvisionedMessages");
+ });
+
+ modelBuilder.Entity("Persistord.Core.Entities.ChannelEntity", b =>
+ {
+ b.HasOne("Persistord.Core.Entities.ChannelEntity", null)
+ .WithMany()
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Alarms.SmartAlarm", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Commands.ServerCommandSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Commands.ServerCommandSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Connections.ConnectionState", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Connections.ConnectionState", "RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Credentials.PlayerCredential", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Map.ServerMapSettings", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithOne()
+ .HasForeignKey("RustPlusBot.Domain.Map.ServerMapSettings", "ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedCategory", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedChannel", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("RustPlusBot.Domain.Workspace.ProvisionedMessage", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("RustServerId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs
new file mode 100644
index 0000000..b427b80
--- /dev/null
+++ b/src/RustPlusBot.Persistence/Migrations/20260626175614_SmartStorageMonitors.cs
@@ -0,0 +1,57 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace RustPlusBot.Persistence.Migrations
+{
+ ///
+ public partial class SmartStorageMonitors : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "SmartStorageMonitors",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ GuildId = table.Column(type: "INTEGER", nullable: false),
+ ServerId = table.Column(type: "TEXT", nullable: false),
+ EntityId = table.Column(type: "INTEGER", nullable: false),
+ Name = table.Column(type: "TEXT", maxLength: 128, nullable: false),
+ MessageId = table.Column(type: "INTEGER", nullable: true),
+ PairedByUserId = table.Column(type: "INTEGER", nullable: false),
+ CreatedUtc = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SmartStorageMonitors", x => x.Id);
+ table.ForeignKey(
+ name: "FK_SmartStorageMonitors_RustServers_ServerId",
+ column: x => x.ServerId,
+ principalTable: "RustServers",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SmartStorageMonitors_GuildId_ServerId_EntityId",
+ table: "SmartStorageMonitors",
+ columns: new[] { "GuildId", "ServerId", "EntityId" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_SmartStorageMonitors_ServerId",
+ table: "SmartStorageMonitors",
+ column: "ServerId");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "SmartStorageMonitors");
+ }
+ }
+}
diff --git a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
index 1afec44..6d298b7 100644
--- a/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
+++ b/src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs
@@ -424,6 +424,45 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("RustServers");
});
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EntityId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GuildId")
+ .HasColumnType("INTEGER");
+
+ b.Property("MessageId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("PairedByUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ServerId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServerId");
+
+ b.HasIndex("GuildId", "ServerId", "EntityId")
+ .IsUnique();
+
+ b.ToTable("SmartStorageMonitors");
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
{
b.Property("Id")
@@ -619,6 +658,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.IsRequired();
});
+ modelBuilder.Entity("RustPlusBot.Domain.StorageMonitors.SmartStorageMonitor", b =>
+ {
+ b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
+ .WithMany()
+ .HasForeignKey("ServerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("RustPlusBot.Domain.Switches.SmartSwitch", b =>
{
b.HasOne("RustPlusBot.Domain.Servers.RustServer", null)
diff --git a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
index d8f42c3..614a4d6 100644
--- a/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
+++ b/src/RustPlusBot.Persistence/PersistenceServiceCollectionExtensions.cs
@@ -7,6 +7,7 @@
using RustPlusBot.Persistence.Credentials;
using RustPlusBot.Persistence.Map;
using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.StorageMonitors;
using RustPlusBot.Persistence.Switches;
using RustPlusBot.Persistence.Workspace;
@@ -39,6 +40,7 @@ public static IServiceCollection AddBotPersistence(this IServiceCollection servi
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddScoped();
services.AddScoped();
diff --git a/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs b/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs
new file mode 100644
index 0000000..5e07a1c
--- /dev/null
+++ b/src/RustPlusBot.Persistence/StorageMonitors/IStorageMonitorStore.cs
@@ -0,0 +1,97 @@
+using RustPlusBot.Domain.StorageMonitors;
+
+namespace RustPlusBot.Persistence.StorageMonitors;
+
+/// Persists managed Smart Storage Monitors (accepted pairings only; pending pairings stay in-memory).
+public interface IStorageMonitorStore
+{
+ /// Adds a managed storage monitor and returns the persisted row.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// The display name.
+ /// The user who accepted the pairing.
+ /// A cancellation token.
+ /// The persisted storage monitor.
+ Task AddAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ ulong pairedByUserId,
+ CancellationToken cancellationToken = default);
+
+ /// Gets a storage monitor by identity, or null.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// A cancellation token.
+ /// The storage monitor, or null.
+ Task GetAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default);
+
+ /// Lists every managed storage monitor for a server.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// A cancellation token.
+ /// The managed storage monitors for the server.
+ Task> ListByServerAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default);
+
+ /// True when a managed storage monitor with this identity exists.
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// A cancellation token.
+ /// True if a matching storage monitor exists.
+ Task ExistsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default);
+
+ /// Renames a storage monitor (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// The new display name.
+ /// A cancellation token.
+ /// A task that completes when the rename has been persisted.
+ Task RenameAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ CancellationToken cancellationToken = default);
+
+ /// Sets the embed message id (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// The Discord embed message id.
+ /// A cancellation token.
+ /// A task that completes when the message id has been persisted.
+ Task SetMessageIdAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong messageId,
+ CancellationToken cancellationToken = default);
+
+ /// Removes a storage monitor (no-op if absent).
+ /// Owning Discord guild snowflake.
+ /// The Rust server id.
+ /// The in-game storage-monitor entity id.
+ /// A cancellation token.
+ /// A task that completes when the storage monitor has been removed.
+ Task RemoveAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs
new file mode 100644
index 0000000..9de17da
--- /dev/null
+++ b/src/RustPlusBot.Persistence/StorageMonitors/StorageMonitorStore.cs
@@ -0,0 +1,137 @@
+using Microsoft.EntityFrameworkCore;
+using RustPlusBot.Abstractions.Time;
+using RustPlusBot.Domain.StorageMonitors;
+
+namespace RustPlusBot.Persistence.StorageMonitors;
+
+/// EF-backed .
+/// The bot database context.
+/// Supplies the creation timestamp.
+public sealed class StorageMonitorStore(BotDbContext context, IClock clock) : IStorageMonitorStore
+{
+ ///
+ public async Task AddAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ ulong pairedByUserId,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = new SmartStorageMonitor
+ {
+ GuildId = guildId,
+ ServerId = serverId,
+ EntityId = entityId,
+ Name = name,
+ PairedByUserId = pairedByUserId,
+ CreatedUtc = clock.UtcNow,
+ };
+ context.SmartStorageMonitors.Add(entity);
+ try
+ {
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return entity;
+ }
+ catch (DbUpdateException)
+ {
+ // Two users accepted the same pending pairing concurrently (both saw ExistsAsync == false); the
+ // unique (GuildId, ServerId, EntityId) index rejects the second insert. Recover idempotently by
+ // detaching the failed insert and returning the row the winner persisted. If no such row exists,
+ // the failure was not the uniqueness race — let it propagate.
+ context.Entry(entity).State = EntityState.Detached;
+ var existing = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false);
+ if (existing is null)
+ {
+ throw;
+ }
+
+ return existing;
+ }
+ }
+
+ ///
+ public Task GetAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default) =>
+ context.SmartStorageMonitors.SingleOrDefaultAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId, cancellationToken);
+
+ ///
+ public async Task> ListByServerAsync(
+ ulong guildId,
+ Guid serverId,
+ CancellationToken cancellationToken = default)
+ {
+ // SQLite cannot ORDER BY a DateTimeOffset column, so order oldest-first on the client side.
+ var monitors = await context.SmartStorageMonitors
+ .Where(s => s.GuildId == guildId && s.ServerId == serverId)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return [.. monitors.OrderBy(s => s.CreatedUtc)];
+ }
+
+ ///
+ public Task ExistsAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default) =>
+ context.SmartStorageMonitors.AnyAsync(
+ s => s.GuildId == guildId && s.ServerId == serverId && s.EntityId == entityId, cancellationToken);
+
+ ///
+ public Task RenameAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ string name,
+ CancellationToken cancellationToken = default) =>
+ MutateAsync(guildId, serverId, entityId, s => s.Name = name, cancellationToken);
+
+ ///
+ public Task SetMessageIdAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ ulong messageId,
+ CancellationToken cancellationToken = default) =>
+ MutateAsync(guildId, serverId, entityId, s => s.MessageId = messageId, cancellationToken);
+
+ ///
+ public async Task RemoveAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ CancellationToken cancellationToken = default)
+ {
+ var entity = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false);
+ if (entity is null)
+ {
+ return;
+ }
+
+ context.SmartStorageMonitors.Remove(entity);
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task MutateAsync(
+ ulong guildId,
+ Guid serverId,
+ ulong entityId,
+ Action mutate,
+ CancellationToken cancellationToken)
+ {
+ var entity = await GetAsync(guildId, serverId, entityId, cancellationToken).ConfigureAwait(false);
+ if (entity is null)
+ {
+ return;
+ }
+
+ mutate(entity);
+ await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
index b5e7870..8a9693d 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/AlarmPrimingTests.cs
@@ -16,6 +16,7 @@
using RustPlusBot.Persistence.Alarms;
using RustPlusBot.Persistence.Connections;
using RustPlusBot.Persistence.Servers;
+using RustPlusBot.Persistence.StorageMonitors;
using RustPlusBot.Persistence.Switches;
namespace RustPlusBot.Features.Connections.Tests;
@@ -53,6 +54,7 @@ private static (ServiceProvider Provider, ConnectionSupervisor Supervisor, InMem
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
services.AddSingleton(source);
services.AddSingleton(Options.Create(new ConnectionOptions
{
diff --git a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
index 34d5560..46ddfbd 100644
--- a/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
+++ b/tests/RustPlusBot.Features.Connections.Tests/Fakes/FakeRustSocketSource.cs
@@ -21,6 +21,7 @@ internal sealed class FakeRustSocketSource : IRustSocketSource
private readonly ConcurrentQueue _connectOutcomes = new();
private readonly ConcurrentQueue _heartbeats = new();
private readonly ConcurrentQueue> _pendingMarkerScript = new();
+ private readonly Dictionary _pendingStorageContents = [];
private int _createCount;
private HeartbeatResult _lastHeartbeat = HeartbeatResult.Ok(0);
@@ -56,6 +57,14 @@ public IRustServerConnection Create(string ip, int port, ulong steamId, string p
connection.MonumentsResult = _pendingMonuments;
_pendingMonuments = [];
+ // Transfer any pre-staged storage contents so they are in place before the prime loop starts.
+ foreach (var (entityId, contents) in _pendingStorageContents)
+ {
+ connection.StorageContents[entityId] = contents;
+ }
+
+ _pendingStorageContents.Clear();
+
LastConnection = connection;
return connection;
}
@@ -83,6 +92,17 @@ public void EnqueueMarkers(IReadOnlyList markers) =>
/// The monument list to return from .
public void SetMonuments(IReadOnlyList monuments) => _pendingMonuments = monuments;
+ ///
+ /// Pre-stages storage contents for a given entity, to be transferred to the NEXT connection created by
+ /// . Eliminates the setup race when the prime loop reads contents before the test can
+ /// assign them on .
+ /// Call this before .
+ ///
+ /// The storage-monitor entity id to stage.
+ /// The contents snapshot to return from .
+ public void EnqueueStorageInfo(ulong entityId, StorageContentsSnapshot? contents) =>
+ _pendingStorageContents[entityId] = contents;
+
internal HeartbeatResult NextHeartbeat()
{
if (_heartbeats.TryDequeue(out var next))
@@ -121,6 +141,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
/// The state returned by per entity id; absent → null.
public Dictionary SwitchStates { get; } = [];
+ /// The contents returned by per entity id; absent → null.
+ public Dictionary StorageContents { get; } = [];
+
/// The result returned by . Defaults to true.
public bool SetSwitchResult { get; set; } = true;
@@ -155,6 +178,9 @@ internal sealed class FakeConnection(SocketConnectOutcome outcome, FakeRustSocke
/// Raised by .
public event EventHandler? SmartDeviceTriggered;
+ /// Raised by .
+ public event EventHandler? StorageMonitorTriggered;
+
public Task ConnectAsync(TimeSpan timeout, CancellationToken cancellationToken) =>
Task.FromResult(outcome);
@@ -191,6 +217,13 @@ public Task PromoteToLeaderAsync(ulong steamId, TimeSpan timeout, Cancella
Task.FromResult(SwitchStates.TryGetValue(entityId, out var s) ? s : null);
#pragma warning restore RCS1163
+#pragma warning disable RCS1163 // Unused parameters for fake implementation
+ public Task GetStorageMonitorInfoAsync(ulong entityId,
+ TimeSpan timeout,
+ CancellationToken cancellationToken) =>
+ Task.FromResult(StorageContents.TryGetValue(entityId, out var c) ? c : null);
+#pragma warning restore RCS1163
+
#pragma warning disable RCS1163 // Unused parameters for fake implementation
public Task