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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Changelog — Team chat commands & presence alerts

This PR adds a few team-chat commands and presence announcements, and fixes a couple of chat-reliability issues. Everything is built to be **API-friendly** (single message per command, throttled, no extra polling) — directly addressing the concern that shop-style chat commands tend to flood the API and get players kicked.

## Added
- **`!shop <item>`** — searches the map's vending machines and replies with grid, price and stock.
- Reads the **already-cached** shop list (`GetLastShopsList()`) — **no extra map/marker polling**.
- Replies with the **top 3 matches** (in-stock first, then cheapest) as up to 3 separate messages, spaced by the chat-response delay so the API is never flooded; the last line shows a `(+N more)` overflow indicator.
- **Filters out junk barter trades**: when real Scrap prices exist, odd trades priced in another item (e.g. "Bone Fragments for 1 LR-300") are dropped — they previously ranked as "cheapest" because `1 < 25` across different currencies. Barter is shown only when nothing is priced in Scrap.
- **`!small` / `!large`** — individual Small/Large Oil Rig crate timers (the existing `!oilrig` was refactored into a shared `BuildSingleRigStatus` helper).
- **`!pos <name>`** — reports a teammate's current grid (exact-then-partial name match).
- **Team presence announcements** (gated by the existing "Chat Announce" master toggle):
- Teammate **joined** / **left** the team.
- Teammate went **AFK** — e.g. "X is now AFK for more than 5m". Announced for **anyone in the team, including yourself**.
- Teammate **back from AFK** — e.g. "X came back from AFK, was AFK for 12m".
- Teammate **came online** — message now appends how long they were offline (e.g. "X came online @ G12 (was offline 2h 5m)").
- Teammate **died** — message now appends how long they were alive (e.g. ":skull: X is dead @ G12, was alive for 1h 25m"). The clock starts on respawn, survives going offline/online (sleeping ≠ dying), and is tracked regardless of the announce toggle.
- Smart handling of **your own** team switches:
- You join someone else's team → announces **only you** once (not every existing member).
- People join **your** team (you're leader) → announces each.
- You leave / the team dissolves (roster collapses to just you) → **suppressed** (no spam).
- Multiple announcements are **spaced 2.5s apart** so they never burst the API.
- **Configurable AFK threshold** — new per-server setting (default 5 min, range 1–60) replacing the hardcoded value, with a selector in the Chat Commands panel.
- Config rows for the new commands in the Chat Commands panel; join/leave/AFK (and back-from-AFK) templates are editable in the Custom Alerts window.
- In-game emoji shortcodes on alerts: `:skull:` (death), `:wave:` (join/leave), `:vending.machine:` (shop alerts).
- **Persistent event logs** (under `%LOCALAPPDATA%\RustPlusDesk\logs\`, written regardless of the Chat Alerts toggle so the record survives an app crash):
- `timeline.log` — append-only, crash-safe history of every event (offline/online, AFK/back, death/respawn, oil-rig crate, cargo, heli, vendor, deep sea), one timestamped line each.
- `events.json` — latest state per event type (keyed per server, overwritten as new events arrive) and **loaded on startup**. On reconnect it rehydrates the "X ago" timers for cargo/heli/vendor/deep sea so those `!` commands still answer correctly after a restart.
- **Map-wipe & staleness guards on restore**: persisted states from before the server's last wipe (`RustMapsWipeTime`) are ignored, and anything older than ~15h is dropped. Past that age the `!cargo`/`!heli`/`!vendor`/`!deepsea` replies say "Haven't seen X in a while" instead of a misleading "28h ago".
- **Deep Sea auto-detection** — a gentle background poll (every 60s, started on connect) tracks the Deep Sea event without needing the Shops layer enabled. It skips when full shop polling is already active and backs off under API pressure, and it keeps the shop cache fresh so `!shop` works too. The Deep Sea direction is now reported reliably as **West** (it always spawns off the west edge; the precise direction can't be read from the API) and is shown in the alert, e.g. "Deep Sea event is up (West)".
- **Global outgoing-send throttle** — a minimum 1.5s gap between any two team-chat sends so bursts of alerts/commands never hammer the Rust+ API (isolated messages still send instantly; only back-to-back ones wait).
- **Alarm spam cooldown** — if a Smart Alarm fires more than 3 times in a row (each within 2 min of the last), its team-chat **and** Discord raid alerts are muted for 10 minutes (per-alarm). Popup/audio still fire; only the broadcasts are suppressed, so a raid/decay loop can't flood chat.

## Fixed
- **AFK announcements never fired for your own account** — the AFK alert was hard-skipped for the local player (`SteamId == _mySteamId`), so going AFK yourself produced no message. Now any team member (you included) is announced when Chat Alerts is on, matching how death/online alerts already behave.
- **`!afk` command** — now respects the name-abbreviation setting (was printing raw names) and caps its reply at Rust's 128-char team-chat limit, so a long AFK list no longer silently fails to send.
- **Offline-duration tracking** — a member's `OfflineSince` timestamp was only recorded when Chat Alerts happened to be enabled, so the "(was offline X)" suffix could be missing if alerts were turned on only after they went offline. The timing is now tracked independently of the announce toggle.
- **Discord webhook flooding / rate-limits** — webhook forwarding previously created a fresh `HttpClient` per message and ignored HTTP 429s. It now routes through a serialized, rate-limited queue: one shared client, one in-flight POST at a time, a minimum gap between posts (~40/min ceiling), and it honors Discord's `Retry-After` (header and JSON body) with a single retry.
- **Chat delivery confirmation** — Rust rewrites `:shortcode:` emoji into glyphs in its echo, which broke the exact-text match used to confirm a sent message, causing wait-timeouts and **duplicate re-sends**. Both sides are now normalized before matching.
- **Instant command responses** — removed an artificial pre-send delay so single replies go out immediately (the delay setting still spaces multi-line replies).

## Notes
- New strings added to `Resources.resx` and `Resources.en-US.resx`.
- No changes to polling cadence or existing features; all additions are opt-in via the chat-commands toggle.
57 changes: 51 additions & 6 deletions RustPlusDesktop/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ public MainWindow()
var baseDir = AppDomain.CurrentDomain.BaseDirectory;

_vm.IsInitializing = true;
Services.EventStateService.Load(); // restore persisted event states from a previous run/crash
InitializeComponent();

PlayersTab?.SetMainWindow(this);
Expand Down Expand Up @@ -715,11 +716,13 @@ await Dispatcher.InvokeAsync(() =>

_monumentWatcher.OnOilRigTriggered += (s, data) =>
{
if (!TrackingService.AnnounceSpawnsMaster || !TrackingService.AnnounceOilRig) return;
string timeStr = data.Duration >= 800 ? "~15m" : "~12:30m";
string rigName = data.Name == "Small Oil Rig" ? Properties.Resources.SmallOilRig :
data.Name == "Large Oil Rig" ? Properties.Resources.LargeOilRig :
data.Name;
// crash-safe log regardless of the announce toggle
RecordEvent(data.Name, "CRATE-CALLED", $"crate in {timeStr}");
if (!TrackingService.AnnounceSpawnsMaster || !TrackingService.AnnounceOilRig) return;
Dispatcher.InvokeAsync(async () =>
{
var msg = AlertTemplateService.GetFormattedAlert("AlertOilRigTriggered", rigName, timeStr);
Expand Down Expand Up @@ -2059,6 +2062,38 @@ private sealed record MarkerRef(System.Windows.Shapes.Ellipse Dot, double U_DIP,
private readonly ObservableCollection<AlarmNotification> _alarmFeed = new();
private readonly Dictionary<string, DateTime> _lastAlarmProcessed = new();
private DateTime _lastAnyAlarmTime = DateTime.MinValue; // Globaler Marker für Fuzzy-Dedup

// Per-alarm spam guard: if an alarm fires more than 3 times in a row (each within the window
// of the last), its alerts are muted for 10 minutes so a raid/decay loop can't flood chat.
private sealed class AlarmSpamState { public DateTime LastTrigger; public int Streak; public DateTime CooldownUntil; }
private readonly Dictionary<string, AlarmSpamState> _alarmSpam = new();
private const int AlarmSpamThreshold = 3;
private static readonly TimeSpan AlarmSpamWindow = TimeSpan.FromMinutes(2);
private static readonly TimeSpan AlarmSpamCooldown = TimeSpan.FromMinutes(10);

private bool IsAlarmSpamSuppressed(string key, DateTime now)
{
if (!_alarmSpam.TryGetValue(key, out var st)) { st = new AlarmSpamState(); _alarmSpam[key] = st; }

// already cooling down -> stay muted
if (now < st.CooldownUntil) return true;

// count consecutive triggers that arrive within the window of the previous one
if (st.LastTrigger != default && (now - st.LastTrigger) <= AlarmSpamWindow)
st.Streak++;
else
st.Streak = 1;
st.LastTrigger = now;

if (st.Streak > AlarmSpamThreshold)
{
st.CooldownUntil = now + AlarmSpamCooldown;
st.Streak = 0;
AppendLog($"[alarm] '{key}' fired >{AlarmSpamThreshold}x in a row — muting its alerts for {AlarmSpamCooldown.TotalMinutes:0} min");
return true; // mute the one that tripped the cooldown too
}
return false;
}
private readonly Dictionary<uint, (string Title, string Message)> _alarmMetadataCache = new();
private readonly Dictionary<string, (uint Id, DateTime Time)> _lastSeenIdPerServer = new();
private readonly List<string> _alarmHistoryDedup = new();
Expand Down Expand Up @@ -2233,15 +2268,25 @@ private void ShowAlarmPopup(AlarmNotification n, string source = "FCM")
var raidOwnerSteamId = !string.IsNullOrWhiteSpace(_vm.SteamId64)
? _vm.SteamId64
: alarmProfile?.SteamId64 ?? "";
_ = DiscordBotListenerService.Instance.SendRaidNotificationAsync(
raidServerKey,
raidOwnerSteamId,
$"\uD83D\uDEA8 **{dev?.PureName ?? n.DeviceName ?? "Smart Alarm"}**: {n.Message}");

// Spam guard: if this alarm fires >3 times in a row it gets muted for 10 min so a
// raid/decay loop can't flood team chat + Discord (and burn API tokens).
string alarmSpamKey = n.EntityId.HasValue ? $"ID:{n.EntityId.Value}" : (n.DeviceName ?? "alarm");
bool alarmSpamMuted = IsAlarmSpamSuppressed(alarmSpamKey, now);

if (!alarmSpamMuted)
{
_ = DiscordBotListenerService.Instance.SendRaidNotificationAsync(
raidServerKey,
raidOwnerSteamId,
$"\uD83D\uDEA8 **{dev?.PureName ?? n.DeviceName ?? "Smart Alarm"}**: {n.Message}");
}

// Send smart alert to team chat if setting and master switch are enabled
if (_vm.Selected?.IsFullConnected == true
&& TrackingService.AnnounceSmartAlerts
&& _announceSpawns)
&& _announceSpawns
&& !alarmSpamMuted)
{
string alarmName = dev?.PureName ?? (!string.IsNullOrEmpty(n.DeviceName) ? n.DeviceName : "Smart Alarm");
_ = SendTeamChatSafeAsync(AlertTemplateService.GetFormattedAlert("AlertAlarmTriggered", alarmName), false, true);
Expand Down
35 changes: 35 additions & 0 deletions RustPlusDesktop/Models/ServerProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,34 @@ public string CmdUpkeepDetail
set { _cmdUpkeepDetail = ValidateCommand(value, "upkeepdetail"); OnProp(); }
}

private string _cmdShop = "shop";
public string CmdShop
{
get => _cmdShop;
set { _cmdShop = ValidateCommand(value, "shop"); OnProp(); }
}

private string _cmdSmall = "small";
public string CmdSmall
{
get => _cmdSmall;
set { _cmdSmall = ValidateCommand(value, "small"); OnProp(); }
}

private string _cmdLarge = "large";
public string CmdLarge
{
get => _cmdLarge;
set { _cmdLarge = ValidateCommand(value, "large"); OnProp(); }
}

private string _cmdPos = "pos";
public string CmdPos
{
get => _cmdPos;
set { _cmdPos = ValidateCommand(value, "pos"); OnProp(); }
}

private int _chatCommandDelaySeconds = 2;
public int ChatCommandDelaySeconds
{
Expand All @@ -273,6 +301,13 @@ public double ChatResponseDelaySeconds
set { if (value >= 0.0 && value <= 5.0) { _chatResponseDelaySeconds = value; OnProp(); } }
}

private int _afkThresholdMinutes = 5;
public int AfkThresholdMinutes
{
get => _afkThresholdMinutes;
set { if (value >= 1 && value <= 60) { _afkThresholdMinutes = value; OnProp(); } }
}

private ObservableCollection<ChatCommandMapping> _switchCommandMappings = new();
public ObservableCollection<ChatCommandMapping> SwitchCommandMappings
{
Expand Down
2 changes: 2 additions & 0 deletions RustPlusDesktop/Properties/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading