Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Last Message Before Death Webhook. #30235

Open
wants to merge 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2c3852e
Added a new webhook to track player's last IC message.
VelonacepsCalyxEggs Jul 21, 2024
1817c59
Code cleanup.
VelonacepsCalyxEggs Jul 21, 2024
18f7be4
Bugfixes and Improvements
VelonacepsCalyxEggs Jul 22, 2024
1f1fce8
Update Content.Server/GameTicking/GameTicker.CVars.cs
VelonacepsCalyxEggs Jul 22, 2024
bbba697
Improvements
VelonacepsCalyxEggs Jul 22, 2024
73b607f
Merge branch 'master' of https://github.com/space-wizards/space-stati…
VelonacepsCalyxEggs Jul 22, 2024
96aeb27
Merge branch 'deathwebhook' of https://github.com/VelonacepsCalyxEggs…
VelonacepsCalyxEggs Jul 22, 2024
fcc8a9c
Use GameTicker instead of IGameTiming.
VelonacepsCalyxEggs Jul 22, 2024
88bdb6a
Convention check
VelonacepsCalyxEggs Jul 26, 2024
6658722
Remove message on ban.
VelonacepsCalyxEggs Jul 28, 2024
d59f327
Merge branch 'master' of https://github.com/space-wizards/space-stati…
VelonacepsCalyxEggs Jul 28, 2024
760dc71
Merge branch 'deathwebhook' of https://github.com/VelonacepsCalyxEggs…
VelonacepsCalyxEggs Jul 28, 2024
749d0cb
Refactor
VelonacepsCalyxEggs Aug 5, 2024
6af4ca1
Code cleanup
VelonacepsCalyxEggs Aug 5, 2024
53edf71
Merge branch 'master' of https://github.com/space-wizards/space-stati…
VelonacepsCalyxEggs Aug 5, 2024
00da285
Merge branch 'space-wizards:master' into deathwebhook
VelonacepsCalyxEggs Aug 5, 2024
faadbdf
Merge branch 'deathwebhook' of https://github.com/VelonacepsCalyxEggs…
VelonacepsCalyxEggs Aug 5, 2024
c55f636
Made webhook persistent.
VelonacepsCalyxEggs Aug 10, 2024
e601912
Code refactor (big)
VelonacepsCalyxEggs Aug 29, 2024
1120c61
Fix merge conflict.
VelonacepsCalyxEggs Oct 29, 2024
fabac59
Fix a foolish mistake...
VelonacepsCalyxEggs Oct 29, 2024
53f2c99
Merge remote-tracking branch 'upstream/master' into deathwebhook
VelonacepsCalyxEggs Nov 25, 2024
00ff25c
Merge branch 'space-wizards:master' into deathwebhook
VelonacepsCalyxEggs Dec 15, 2024
1ef259a
Now keying the user by NetUserId and their character by Mind.
VelonacepsCalyxEggs Dec 15, 2024
57181e4
Merge branch 'master' into deathwebhook
VelonacepsCalyxEggs Jan 18, 2025
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
16 changes: 16 additions & 0 deletions Content.Server/Chat/Systems/ChatSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public sealed partial class ChatSystem : SharedChatSystem
[Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
[Dependency] private readonly LastMessageBeforeDeathSystem _lastMessageBeforeDeathSystem = default!;

public const int VoiceRange = 10; // how far voice goes in world units
public const int WhisperClearRange = 2; // how far whisper goes while still being understandable, in world units
Expand All @@ -69,6 +70,7 @@ public sealed partial class ChatSystem : SharedChatSystem
private bool _loocEnabled = true;
private bool _deadLoocEnabled;
private bool _critLoocEnabled;

private readonly bool _adminLoocEnabled = true;

public override void Initialize()
Expand Down Expand Up @@ -233,6 +235,11 @@ public void TrySendInGameICMessage(
if (string.IsNullOrEmpty(message))
return;

if (player != null) // Last Message Before Death System
{
HandleLastMessageBeforeDeath(source, player, message);
}

// This message may have a radio prefix, and should then be whispered to the resolved radio channel
if (checkRadioPrefix)
{
Expand Down Expand Up @@ -743,6 +750,15 @@ private bool CanSendInGame(string message, IConsoleShell? shell = null, ICommonS
return !_chatManager.MessageCharacterLimit(player, message);
}

public void HandleLastMessageBeforeDeath(EntityUid source, ICommonSession player, string message)
{

if (player != null && source != null)
{
_lastMessageBeforeDeathSystem.AddMessage(source, player, message);
}
}

// ReSharper disable once InconsistentNaming
private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true)
{
Expand Down
200 changes: 200 additions & 0 deletions Content.Server/Chat/Systems/LastMessageBeforeDeathSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
using System.Text;
using Content.Server.Discord;
using Content.Shared.Mobs.Systems;
using System.Collections.Generic;
using Content.Server.GameTicking;
using Content.Server.Database;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Content.Server.Discord.Managers;
using Robust.Shared.Configuration;
using System.Threading.Tasks;
using Content.Shared.CCVar;
using Robust.Shared.Enums;
using Content.Shared.Weapons.Reflect;
using Content.Server.Administration.Logs.Converters;
using Content.Shared.Mind;
using Content.Shared.Mind.Components;

namespace Content.Server.Chat.Systems
{
internal class LastMessageBeforeDeathSystem : EntitySystem
{
[Dependency] private readonly IConfigurationManager _configManager = default!;
[Dependency] private readonly DiscordWebhook _discord = default!;
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly ILogManager _logManager = default!;

private bool _lastMessageWebhookEnabled = false;
private int _maxICLengthCVar;
private int _maxMessageSize;
private int _maxMessagesPerBatch;
private int _messageDelayMs;
private int _rateLimitDelayMs;
private WebhookIdentifier? _webhookIdentifierLastMessage;

private readonly LastMessageWebhookManager _webhookManager = new LastMessageWebhookManager();

private Dictionary<NetUserId, PlayerData> _playerData = new Dictionary<NetUserId, PlayerData>();

private readonly Random _random = new Random();



private class PlayerData
{
public Dictionary<MindComponent, CharacterData> Characters { get; } = new Dictionary<MindComponent, CharacterData>();
public ICommonSession? PlayerSession { get; set; }
}

private class CharacterData
{
public string? LastMessage { get; set; } // Store only the last message
public EntityUid EntityUid { get; set; }
public TimeSpan MessageTime { get; set; } // Store the round time of the last message
}

public override void Initialize()
{
base.Initialize();
Subs.CVar(_configManager, CCVars.DiscordLastMessageBeforeDeathWebhook, value =>
{
if (!string.IsNullOrWhiteSpace(value))
{
_lastMessageWebhookEnabled = true;
_discord.GetWebhook(value, data => _webhookIdentifierLastMessage = data.ToIdentifier());
}
}, true);
_maxICLengthCVar = _configManager.GetCVar(CCVars.DiscordLastMessageSystemMaxICLength);
_maxMessageSize = _configManager.GetCVar(CCVars.DiscordLastMessageSystemMaxMessageLength);
_maxMessagesPerBatch = _configManager.GetCVar(CCVars.DiscordLastMessageSystemMaxMessageBatch);
_messageDelayMs = _configManager.GetCVar(CCVars.DiscordLastMessageSystemMessageDelay);
_rateLimitDelayMs = _configManager.GetCVar(CCVars.DiscordLastMessageSystemMaxMessageBatchOverflowDelay);
}
/// <summary>
/// Adds a message to the character data for a given player session.
/// </summary>
/// <param name="source">The entity UID of the source.</param>
/// <param name="playerSession">The player's current session.</param>
/// <param name="message">The message to be added.</param>
public async void AddMessage(EntityUid source, ICommonSession playerSession, string message)
{
if (!_lastMessageWebhookEnabled)
{
return;
}

if (!_playerData.ContainsKey(playerSession.UserId))
{
_playerData[playerSession.UserId] = new PlayerData();
_playerData[playerSession.UserId].PlayerSession = playerSession;
}
var playerData = _playerData[playerSession.UserId];

var mindContainerComponent = EntityManager.GetComponentOrNull<MindContainerComponent>(source);
if (mindContainerComponent != null && mindContainerComponent.Mind != null)
{
var mindComponent = EntityManager.GetComponentOrNull<MindComponent>(mindContainerComponent.Mind.Value); // Get mind by EntityUID, well, I hope this is the correct way to do it.

if (mindComponent != null && !playerData.Characters.ContainsKey(mindComponent))
{
playerData.Characters[mindComponent] = new CharacterData();
}
if (mindComponent != null)
{
var characterData = playerData.Characters[mindComponent];
characterData.LastMessage = message;
characterData.EntityUid = source;
characterData.MessageTime = _gameTicker.RoundDuration();
}
}
}

/// <summary>
/// Processes messages at the end of a round and sends them via webhook.
/// </summary>
public async void OnRoundEnd()
{
if (!_lastMessageWebhookEnabled)
return;
_webhookManager.Initialize();

var allMessages = new List<string>();

foreach (var player in _playerData)
{
var singlePlayerData = player.Value;
if (player.Key != null && singlePlayerData.PlayerSession != null && singlePlayerData.PlayerSession.Status != SessionStatus.Disconnected)
{
foreach (var character in singlePlayerData.Characters)
{
var characterData = character.Value;
// I am sure if there is a better way to go about checking if an EntityUID is no longer linked to an active entity...
// I don't know how tho...
if (_mobStateSystem.IsDead(characterData.EntityUid) || !EntityManager.TryGetComponent<MetaDataComponent>(characterData.EntityUid, out var metadata)) // Check if an entity is dead or doesn't exist
{
if (character.Key.CharacterName != null)
{
var message = await FormatMessage(characterData, character.Key.CharacterName);
allMessages.Add(message);
}
}
}
}
}

await SendMessagesInBatches(allMessages);

// Clear all stored data upon round restart
_playerData.Clear();
}

/// <summary>
/// Formats a message for the "last message before death" system.
/// </summary>
/// <param name="characterData">The data of the character whose message is being formatted.</param>
/// <param name="characterName">The name of the character whose message is being formatted.</param>
/// <returns>A formatted message string.</returns>
private async Task<string> FormatMessage(CharacterData characterData, string characterName)
{
var message = characterData.LastMessage;
if (message != null && message.Length > _maxICLengthCVar)
{
var randomLength = _random.Next(1, _maxICLengthCVar);
message = message[..randomLength] + "-";
}
var messageTime = characterData.MessageTime;
var truncatedTime = $"{messageTime.Hours:D2}:{messageTime.Minutes:D2}:{messageTime.Seconds:D2}";

return $"[{truncatedTime}] {characterName}: {message}";
}

/// <summary>
/// Sends messages in batches via webhook.
/// </summary>
/// <param name="messages">The list of messages to be sent.</param>
private async Task SendMessagesInBatches(List<string> messages)
{
var concatenatedMessages = new StringBuilder();
var messagesToSend = new List<string>();

foreach (var message in messages)
{
if (concatenatedMessages.Length + message.Length + 1 > _maxMessageSize)
{
messagesToSend.Add(concatenatedMessages.ToString());
concatenatedMessages.Clear();
}
concatenatedMessages.AppendLine(message);
}

if (concatenatedMessages.Length > 0)
{
messagesToSend.Add(concatenatedMessages.ToString());
}

await _webhookManager.SendMessagesAsync(_webhookIdentifierLastMessage, messagesToSend, _maxMessageSize, _maxMessagesPerBatch, _messageDelayMs, _rateLimitDelayMs);
}
}
}
65 changes: 65 additions & 0 deletions Content.Server/Discord/Managers/LastMessageWebhookManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Content.Server.Discord.Managers
{
public class LastMessageWebhookManager
{
[Dependency] private readonly DiscordWebhook _discordWebhook = default!;

public void Initialize()
{
IoCManager.InjectDependencies(this);
}

/// <summary>
/// Sends a list of messages to a specified webhook, handling rate limiting.
/// </summary>
/// <param name="webhookId">The identifier of the webhook to send messages to.</param>
/// <param name="messages">The list of messages to be sent.</param>
/// <param name="MaxMessageSize">The maximum message size in characters.</param>
/// <param name="MaxMessagesPerBatch">The maximum amount of messages the webhook can send before the RateLimitDelayMs is used.</param>
/// <param name="MessageDelayMs">Delay between each message in ms.</param>
/// <param name="RateLimitDelayMs">Delay (in ms) after MaxMessagesPerBatch is exceeded.</param>
/// <returns>A task representing the asynchronous operation. :nerd:</returns>
public async Task SendMessagesAsync(WebhookIdentifier? webhookId, List<string> messages, int MaxMessageSize, int MaxMessagesPerBatch, int MessageDelayMs, int RateLimitDelayMs)
{
if (_discordWebhook == null)
return;

if (MaxMessageSize > 2000)
{
throw new ArgumentOutOfRangeException("A discord webhook message can't contain more than 2000 characters.");
}

if (webhookId == null || !webhookId.HasValue)
return;

var id = webhookId.Value;

int messageCount = 0;

foreach (var message in messages)
{
if (messageCount >= MaxMessagesPerBatch)
{
await Task.Delay(RateLimitDelayMs); // Wait to avoid rate limiting
messageCount = 0;
}

var payload = new WebhookPayload { Content = message };
var response = await _discordWebhook.CreateMessage(id, payload);
if (!response.IsSuccessStatusCode)
{
return; // Not sure if logging is needed here.
}
messageCount++;

await Task.Delay(MessageDelayMs); // Small delay between messages to mitigate rate limiting.
}
}
}
}
11 changes: 10 additions & 1 deletion Content.Server/GameTicking/GameTicker.RoundFlow.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Linq;
using Content.Server.Announcements;
using Content.Server.Chat.Systems;
using Content.Server.Discord;
using Content.Server.GameTicking.Events;
using Content.Server.Ghost;
Expand Down Expand Up @@ -29,6 +31,7 @@ public sealed partial class GameTicker
[Dependency] private readonly DiscordWebhook _discord = default!;
[Dependency] private readonly RoleSystem _role = default!;
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly IEntityManager _entityManager = default!;

private static readonly Counter RoundNumberMetric = Metrics.CreateCounter(
"ss14_round_number",
Expand Down Expand Up @@ -333,7 +336,6 @@ public void EndRound(string text = "")
_sawmill.Info("Ending round!");

RunLevel = GameRunLevel.PostRound;

try
{
ShowRoundEndScoreboard(text);
Expand All @@ -351,6 +353,7 @@ public void EndRound(string text = "")
{
Log.Error($"Error while sending round end Discord message: {e}");
}
SendLastMessagesBeforeDeath();
}

public void ShowRoundEndScoreboard(string text = "")
Expand Down Expand Up @@ -485,6 +488,12 @@ private async void SendRoundEndDiscordMessage()
}
}

public void SendLastMessagesBeforeDeath()
{
var lastMessageSystem = _entityManager.System<LastMessageBeforeDeathSystem>();
lastMessageSystem.OnRoundEnd();
}

public void RestartRound()
{
// If this game ticker is a dummy, do nothing!
Expand Down
Loading
Loading