diff --git a/Content.Server/Administration/Managers/IWatchlistWebhookManager.cs b/Content.Server/Administration/Managers/IWatchlistWebhookManager.cs
new file mode 100644
index 0000000000..6be4805365
--- /dev/null
+++ b/Content.Server/Administration/Managers/IWatchlistWebhookManager.cs
@@ -0,0 +1,23 @@
+using Content.Server.Administration.Notes;
+using Content.Server.Database;
+using Content.Server.Discord;
+using Content.Shared.CCVar;
+using Robust.Server;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Configuration;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+using System.Linq;
+
+namespace Content.Server.Administration.Managers;
+
+///
+/// This manager sends a webhook notification whenever a player with an active
+/// watchlist joins the server.
+///
+public interface IWatchlistWebhookManager
+{
+ void Initialize();
+ void Update();
+}
diff --git a/Content.Server/Administration/Managers/WatchlistWebhookManager.cs b/Content.Server/Administration/Managers/WatchlistWebhookManager.cs
new file mode 100644
index 0000000000..054d45bfd0
--- /dev/null
+++ b/Content.Server/Administration/Managers/WatchlistWebhookManager.cs
@@ -0,0 +1,143 @@
+using Content.Server.Administration.Notes;
+using Content.Server.Database;
+using Content.Server.Discord;
+using Content.Shared.CCVar;
+using Robust.Server;
+using Robust.Server.Player;
+using Robust.Shared.Enums;
+using Robust.Shared.Configuration;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+using Robust.Shared.Timing;
+using System.Linq;
+using System.Text;
+
+namespace Content.Server.Administration.Managers;
+
+///
+/// This manager sends a Discord webhook notification whenever a player with an active
+/// watchlist joins the server.
+///
+public sealed class WatchlistWebhookManager : IWatchlistWebhookManager
+{
+ [Dependency] private readonly IAdminNotesManager _adminNotes = default!;
+ [Dependency] private readonly IBaseServer _baseServer = default!;
+ [Dependency] private readonly IConfigurationManager _cfg = default!;
+ [Dependency] private readonly DiscordWebhook _discord = default!;
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+
+ private ISawmill _sawmill = default!;
+
+ private string _webhookUrl = default!;
+ private TimeSpan _bufferTime;
+
+ private List watchlistConnections = new();
+ private TimeSpan? _bufferStartTime;
+
+ public void Initialize()
+ {
+ _sawmill = Logger.GetSawmill("discord");
+ _cfg.OnValueChanged(CCVars.DiscordWatchlistConnectionBufferTime, SetBufferTime, true);
+ _cfg.OnValueChanged(CCVars.DiscordWatchlistConnectionWebhook, SetWebhookUrl, true);
+ _playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
+ }
+
+ private void SetBufferTime(float bufferTimeSeconds)
+ {
+ _bufferTime = TimeSpan.FromSeconds(bufferTimeSeconds);
+ }
+
+ private void SetWebhookUrl(string webhookUrl)
+ {
+ _webhookUrl = webhookUrl;
+ }
+
+ private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
+ {
+ if (e.NewStatus != SessionStatus.Connected)
+ return;
+
+ var watchlists = await _adminNotes.GetActiveWatchlists(e.Session.UserId);
+
+ if (watchlists.Count == 0)
+ return;
+
+ watchlistConnections.Add(new WatchlistConnection(e.Session.Name, watchlists));
+
+ if (_bufferTime > TimeSpan.Zero)
+ {
+ if (_bufferStartTime == null)
+ _bufferStartTime = _gameTiming.RealTime;
+ }
+ else
+ {
+ SendDiscordMessage();
+ }
+ }
+
+ public void Update()
+ {
+ if (_bufferStartTime != null && _gameTiming.RealTime > (_bufferStartTime + _bufferTime))
+ {
+ SendDiscordMessage();
+ _bufferStartTime = null;
+ }
+ }
+
+ private async void SendDiscordMessage()
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(_webhookUrl))
+ return;
+
+ var webhookData = await _discord.GetWebhook(_webhookUrl);
+ if (webhookData == null)
+ return;
+
+ var webhookIdentifier = webhookData.Value.ToIdentifier();
+
+ var messageBuilder = new StringBuilder(Loc.GetString("discord-watchlist-connection-header",
+ ("players", watchlistConnections.Count),
+ ("serverName", _baseServer.ServerName)));
+
+ foreach (var connection in watchlistConnections)
+ {
+ messageBuilder.Append('\n');
+
+ var watchlist = connection.Watchlists.First();
+ var expiry = watchlist.ExpirationTime?.ToUnixTimeSeconds();
+ messageBuilder.Append(Loc.GetString("discord-watchlist-connection-entry",
+ ("playerName", connection.PlayerName),
+ ("message", watchlist.Message),
+ ("expiry", expiry ?? 0),
+ ("otherWatchlists", connection.Watchlists.Count - 1)));
+ }
+
+ var payload = new WebhookPayload { Content = messageBuilder.ToString() };
+
+ await _discord.CreateMessage(webhookIdentifier, payload);
+ }
+ catch (Exception e)
+ {
+ _sawmill.Error($"Error while sending discord watchlist connection message:\n{e}");
+ }
+
+ // Clear the buffered list regardless of whether the message is sent successfully
+ // This prevents infinitely buffering connections if we fail to send a message
+ watchlistConnections.Clear();
+ }
+
+ private sealed class WatchlistConnection
+ {
+ public string PlayerName;
+ public List Watchlists;
+
+ public WatchlistConnection(string playerName, List watchlists)
+ {
+ PlayerName = playerName;
+ Watchlists = watchlists;
+ }
+ }
+}
diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs
index a02cf5dced..3d4ea922dc 100644
--- a/Content.Server/Entry/EntryPoint.cs
+++ b/Content.Server/Entry/EntryPoint.cs
@@ -47,6 +47,7 @@ namespace Content.Server.Entry
private PlayTimeTrackingManager? _playTimeTracking;
private IEntitySystemManager? _sysMan;
private IServerDbManager? _dbManager;
+ private IWatchlistWebhookManager _watchlistWebhookManager = default!;
private IConnectionManager? _connectionManager;
///
@@ -95,6 +96,7 @@ namespace Content.Server.Entry
_connectionManager = IoCManager.Resolve();
_sysMan = IoCManager.Resolve();
_dbManager = IoCManager.Resolve();
+ _watchlistWebhookManager = IoCManager.Resolve();
logManager.GetSawmill("Storage").Level = LogLevel.Info;
logManager.GetSawmill("db.ef").Level = LogLevel.Info;
@@ -112,6 +114,7 @@ namespace Content.Server.Entry
_voteManager.Initialize();
_updateManager.Initialize();
_playTimeTracking.Initialize();
+ _watchlistWebhookManager.Initialize();
IoCManager.Resolve().Initialize();
IoCManager.Resolve().Initialize();
}
@@ -168,6 +171,7 @@ namespace Content.Server.Entry
case ModUpdateLevel.FramePostEngine:
_updateManager.Update();
_playTimeTracking?.Update();
+ _watchlistWebhookManager.Update();
_connectionManager?.Update();
break;
}
diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs
index d91d59e741..777e134246 100644
--- a/Content.Server/IoC/ServerContentIoC.cs
+++ b/Content.Server/IoC/ServerContentIoC.cs
@@ -73,6 +73,7 @@ namespace Content.Server.IoC
IoCManager.Register();
IoCManager.Register();
IoCManager.Register();
+ IoCManager.Register();
IoCManager.Register();
}
}
diff --git a/Content.Shared/CCVar/CCVars.Discord.cs b/Content.Shared/CCVar/CCVars.Discord.cs
index a6c4ada745..6e4ef532cd 100644
--- a/Content.Shared/CCVar/CCVars.Discord.cs
+++ b/Content.Shared/CCVar/CCVars.Discord.cs
@@ -1,4 +1,4 @@
-using Robust.Shared.Configuration;
+using Robust.Shared.Configuration;
namespace Content.Shared.CCVar;
@@ -58,4 +58,18 @@ public sealed partial class CCVars
///
public static readonly CVarDef DiscordRoundEndRoleWebhook =
CVarDef.Create("discord.round_end_role", string.Empty, CVar.SERVERONLY);
+
+ ///
+ /// URL of the Discord webhook which will relay watchlist connection notifications. If left empty, disables the webhook.
+ ///
+ public static readonly CVarDef DiscordWatchlistConnectionWebhook =
+ CVarDef.Create("discord.watchlist_connection_webhook", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
+
+ ///
+ /// How long to buffer watchlist connections for, in seconds.
+ /// All connections within this amount of time from the first one will be batched and sent as a single
+ /// Discord notification. If zero, always sends a separate notification for each connection (not recommended).
+ ///
+ public static readonly CVarDef DiscordWatchlistConnectionBufferTime =
+ CVarDef.Create("discord.watchlist_connection_buffer_time", 5f, CVar.SERVERONLY);
}
diff --git a/Resources/Locale/en-US/discord/watchlist-connections.ftl b/Resources/Locale/en-US/discord/watchlist-connections.ftl
new file mode 100644
index 0000000000..72dc971c0a
--- /dev/null
+++ b/Resources/Locale/en-US/discord/watchlist-connections.ftl
@@ -0,0 +1,14 @@
+discord-watchlist-connection-header =
+ { $players ->
+ [one] {$players} player on a watchlist has
+ *[other] {$players} players on a watchlist have
+ } connected to {$serverName}
+
+discord-watchlist-connection-entry = - {$playerName} with message "{$message}"{ $expiry ->
+ [0] {""}
+ *[other] {" "}(expires )
+ }{ $otherWatchlists ->
+ [0] {""}
+ [one] {" "}and {$otherWatchlists} other watchlist
+ *[other] {" "}and {$otherWatchlists} other watchlists
+ }