Chatfactor: Chat Repository, Admin Commands, Chat Created Events (#26113)

* Chatfactor: Chat Repository, Admin Commands, Chat Created Events

This addition-only PR covers a repository that stores chat messages.
This PR defines what chat messages can be stored, what can be done
with those stored messages, and what events occur when the repository
does things.

This PR also includes to admin commands that show how the repository
will be used for administration purposes at first. Because all chat
messages will be uniquely identified per round (and, as rounds are
uniquely identified, are essentially a GUID) we can delete, amend
or nuke messages.

Note there's no "amend" command right now. The original chatfactor PR
didn't include one, and I'm avoiding further feature bloat with these
chatfactor PRs...

* Remove an event that shouldn't be in this PR

* Resolve PR comments.

* Resolve peak goober moment

* Also make sure we tell the user why if their delete command fails

* Supply a reason if the nukeuserids command is malformed

* Tidy messages

* Some more docstring tidyup

* Imagine tests handling IOC correctly chat

* Imagine tests handling IOC correctly chat

* Resolve PR comments

* Fix goobering with needing to use ToolshedCommand

* In which we bikeshed toolshed

* loud metal pipe sound effect

* One must imagine funny boulder pushing man happy

* Move commands to new folder

* Mald, seethe, cope.

* I hate toolshed I hate toolshed I hate toolshed

* Fix command ftls

* Bit of tidy-up and a YAGNI removal of a get we don't need yet

* Whelp lmao

* UserIDs are in a weird fucky state I didn't anticipate, so I've removed the remove-by-userID command for the time being.

* Rename ChatRepository to ChatRepositorySystem.

* Resolve PR review comments

---------

Co-authored-by: Your Name <you@example.com>
This commit is contained in:
Hannah Giovanna Dawson
2024-04-18 05:59:10 +01:00
committed by GitHub
parent 126f64a914
commit b1136c98d7
7 changed files with 528 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
using System.Diagnostics;
using Content.Server.Administration;
using Content.Server.Chat.V2.Repository;
using Content.Shared.Administration;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.Errors;
using Robust.Shared.Utility;
namespace Content.Server.Chat.V2.Commands;
[ToolshedCommand, AdminCommand(AdminFlags.Admin)]
public sealed class DeleteChatMessageCommand : ToolshedCommand
{
[Dependency] private readonly IEntitySystemManager _manager = default!;
[CommandImplementation("id")]
public void DeleteChatMessage([CommandInvocationContext] IInvocationContext ctx, [CommandArgument] uint messageId)
{
if (!_manager.GetEntitySystem<ChatRepositorySystem>().Delete(messageId))
{
ctx.ReportError(new MessageIdDoesNotExist());
}
}
}
public record struct MessageIdDoesNotExist() : IConError
{
public FormattedMessage DescribeInner()
{
return FormattedMessage.FromUnformatted(Loc.GetString("command-error-deletechatmessage-id-notexist"));
}
public string? Expression { get; set; }
public Vector2i? IssueSpan { get; set; }
public StackTrace? Trace { get; set; }
}

View File

@@ -0,0 +1,41 @@
using System.Diagnostics;
using Content.Server.Administration;
using Content.Server.Chat.V2.Repository;
using Content.Shared.Administration;
using Robust.Shared.Toolshed;
using Robust.Shared.Toolshed.Errors;
using Robust.Shared.Utility;
namespace Content.Server.Chat.V2.Commands;
[ToolshedCommand, AdminCommand(AdminFlags.Admin)]
public sealed class NukeChatMessagesCommand : ToolshedCommand
{
[Dependency] private readonly IEntitySystemManager _manager = default!;
[CommandImplementation("usernames")]
public void Command([CommandInvocationContext] IInvocationContext ctx, [CommandArgument] string usernamesCsv)
{
var usernames = usernamesCsv.Split(',');
foreach (var username in usernames)
{
if (!_manager.GetEntitySystem<ChatRepositorySystem>().NukeForUsername(username, out var reason))
{
ctx.ReportError(new NukeMessagesForUsernameError(reason));
}
}
}
}
public record struct NukeMessagesForUsernameError(string Reason) : IConError
{
public FormattedMessage DescribeInner()
{
return FormattedMessage.FromUnformatted(Reason);
}
public string? Expression { get; set; }
public Vector2i? IssueSpan { get; set; }
public StackTrace? Trace { get; set; }
}

View File

@@ -0,0 +1,94 @@
using Content.Shared.Chat.Prototypes;
using Content.Shared.Chat.V2;
using Content.Shared.Radio;
namespace Content.Server.Chat.V2;
/// <summary>
/// Raised locally when a comms announcement is made.
/// </summary>
public sealed class CommsAnnouncementCreatedEvent(EntityUid sender, EntityUid console, string message) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = sender;
public string Message { get; set; } = message;
public MessageType Type => MessageType.Announcement;
public EntityUid Console = console;
}
/// <summary>
/// Raised locally when a character speaks in Dead Chat.
/// </summary>
public sealed class DeadChatCreatedEvent(EntityUid speaker, string message, bool isAdmin) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = speaker;
public string Message { get; set; } = message;
public MessageType Type => MessageType.DeadChat;
public bool IsAdmin = isAdmin;
}
/// <summary>
/// Raised locally when a character emotes.
/// </summary>
public sealed class EmoteCreatedEvent(EntityUid sender, string message, float range) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = sender;
public string Message { get; set; } = message;
public MessageType Type => MessageType.Emote;
public float Range = range;
}
/// <summary>
/// Raised locally when a character talks in local.
/// </summary>
public sealed class LocalChatCreatedEvent(EntityUid speaker, string message, float range) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = speaker;
public string Message { get; set; } = message;
public MessageType Type => MessageType.Local;
public float Range = range;
}
/// <summary>
/// Raised locally when a character speaks in LOOC.
/// </summary>
public sealed class LoocCreatedEvent(EntityUid speaker, string message) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = speaker;
public string Message { get; set; } = message;
public MessageType Type => MessageType.Looc;
}
/// <summary>
/// Raised locally when a character speaks on the radio.
/// </summary>
public sealed class RadioCreatedEvent(
EntityUid speaker,
string message,
RadioChannelPrototype channel)
: IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = speaker;
public string Message { get; set; } = message;
public RadioChannelPrototype Channel = channel;
public MessageType Type => MessageType.Radio;
}
/// <summary>
/// Raised locally when a character whispers.
/// </summary>
public sealed class WhisperCreatedEvent(EntityUid speaker, string message, float minRange, float maxRange) : IChatEvent
{
public uint Id { get; set; }
public EntityUid Sender { get; set; } = speaker;
public string Message { get; set; } = message;
public MessageType Type => MessageType.Whisper;
public float MinRange = minRange;
public float MaxRange = maxRange;
}

View File

@@ -0,0 +1,196 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.InteropServices;
using Content.Shared.Chat.V2;
using Content.Shared.Chat.V2.Repository;
using Robust.Server.Player;
using Robust.Shared.Network;
using Robust.Shared.Replays;
namespace Content.Server.Chat.V2.Repository;
/// <summary>
/// Stores <see cref="IChatEvent"/>, gives them UIDs, and issues <see cref="MessageCreatedEvent"/>.
/// Allows for deletion of messages.
/// </summary>
public sealed class ChatRepositorySystem : EntitySystem
{
[Dependency] private readonly IReplayRecordingManager _replay = default!;
[Dependency] private readonly IPlayerManager _player = default!;
// Clocks should start at 1, as 0 indicates "clock not set" or "clock forgotten to be set by bad programmer".
private uint _nextMessageId = 1;
private Dictionary<uint, ChatRecord> _messages = new();
private Dictionary<NetUserId, List<uint>> _playerMessages = new();
public override void Initialize()
{
Refresh();
_replay.RecordingFinished += _ =>
{
// TODO: resolve https://github.com/space-wizards/space-station-14/issues/25485 so we can dump the chat to disc.
Refresh();
};
}
/// <summary>
/// Adds an <see cref="IChatEvent"/> to the repo and raises it with a UID for consumption elsewhere.
/// </summary>
/// <param name="ev">The event to store and raise</param>
/// <returns>If storing and raising succeeded.</returns>
public bool Add(IChatEvent ev)
{
if (!_player.TryGetSessionByEntity(ev.Sender, out var session))
{
return false;
}
var messageId = _nextMessageId;
_nextMessageId++;
ev.Id = messageId;
var storedEv = new ChatRecord
{
UserName = session.Name,
UserId = session.UserId,
EntityName = Name(ev.Sender),
StoredEvent = ev
};
_messages[messageId] = storedEv;
CollectionsMarshal.GetValueRefOrAddDefault(_playerMessages, storedEv.UserId, out _)?.Add(messageId);
RaiseLocalEvent(ev.Sender, new MessageCreatedEvent(ev), true);
return true;
}
/// <summary>
/// Returns the event associated with a UID, if it exists.
/// </summary>
/// <param name="id">The UID of a event.</param>
/// <returns>The event, if it exists.</returns>
public IChatEvent? GetEventFor(uint id)
{
return _messages.TryGetValue(id, out var record) ? record.StoredEvent : null;
}
/// <summary>
/// Edits a specific message and issues a <see cref="MessagePatchedEvent"/> that says this happened both locally and
/// on the network. Note that this doesn't replay the message (yet), so translators and mutators won't act on it.
/// </summary>
/// <param name="id">The ID to edit</param>
/// <param name="message">The new message to send</param>
/// <returns>If patching did anything did anything</returns>
/// <remarks>Should be used for admining and admemeing only.</remarks>
public bool Patch(uint id, string message)
{
if (!_messages.TryGetValue(id, out var ev))
{
return false;
}
ev.StoredEvent.Message = message;
RaiseLocalEvent(new MessagePatchedEvent(id, message));
return true;
}
/// <summary>
/// Deletes a message from the repository and issues a <see cref="MessageDeletedEvent"/> that says this has happened
/// both locally and on the network.
/// </summary>
/// <param name="id">The ID to delete</param>
/// <returns>If deletion did anything</returns>
/// <remarks>Should only be used for adminning</remarks>
public bool Delete(uint id)
{
if (!_messages.TryGetValue(id, out var ev))
{
return false;
}
_messages.Remove(id);
if (_playerMessages.TryGetValue(ev.UserId, out var set))
{
set.Remove(id);
}
RaiseLocalEvent(new MessageDeletedEvent(id));
return true;
}
/// <summary>
/// Nukes a user's entire chat history from the repo and issues a <see cref="MessageDeletedEvent"/> saying this has
/// happened.
/// </summary>
/// <param name="userName">The user ID to nuke.</param>
/// <param name="reason">Why nuking failed, if it did.</param>
/// <returns>If nuking did anything.</returns>
/// <remarks>Note that this could be a <b>very large</b> event, as we send every single event ID over the wire.
/// By necessity we can't leak the player-source of chat messages (or if they even have the same origin) because of
/// client modders who could use that information to cheat/metagrudge/etc >:(</remarks>
public bool NukeForUsername(string userName, [NotNullWhen(false)] out string? reason)
{
if (!_player.TryGetUserId(userName, out var userId))
{
reason = Loc.GetString("command-error-nukechatmessages-usernames-usernamenotexist", ("username", userName));
return false;
}
return NukeForUserId(userId, out reason);
}
/// <summary>
/// Nukes a user's entire chat history from the repo and issues a <see cref="MessageDeletedEvent"/> saying this has
/// happened.
/// </summary>
/// <param name="userId">The user ID to nuke.</param>
/// <param name="reason">Why nuking failed, if it did.</param>
/// <returns>If nuking did anything.</returns>
/// <remarks>Note that this could be a <b>very large</b> event, as we send every single event ID over the wire.
/// By necessity we can't leak the player-source of chat messages (or if they even have the same origin) because of
/// client modders who could use that information to cheat/metagrudge/etc >:(</remarks>
public bool NukeForUserId(NetUserId userId, [NotNullWhen(false)] out string? reason)
{
if (!_playerMessages.TryGetValue(userId, out var dict))
{
reason = Loc.GetString("command-error-nukechatmessages-usernames-usernamenomessages", ("userId", userId.UserId.ToString()));
return false;
}
foreach (var id in dict)
{
_messages.Remove(id);
}
var ev = new MessagesNukedEvent(dict);
CollectionsMarshal.GetValueRefOrAddDefault(_playerMessages, userId, out _)?.Clear();
RaiseLocalEvent(ev);
reason = null;
return true;
}
/// <summary>
/// Dumps held chat storage data and refreshes the repo.
/// </summary>
public void Refresh()
{
_nextMessageId = 1;
_messages.Clear();
_playerMessages.Clear();
}
}

View File

@@ -0,0 +1,60 @@
using System.Linq;
using System.Runtime.InteropServices;
using Robust.Shared.Network;
using Robust.Shared.Serialization;
namespace Content.Shared.Chat.V2.Repository;
/// <summary>
/// The record associated with a specific chat event.
/// </summary>
public struct ChatRecord(string userName, NetUserId userId, IChatEvent storedEvent, string entityName)
{
public string UserName = userName;
public NetUserId UserId = userId;
public string EntityName = entityName;
public IChatEvent StoredEvent = storedEvent;
}
/// <summary>
/// Notifies that a chat message has been created.
/// </summary>
/// <param name="ev"></param>
[Serializable, NetSerializable]
public sealed class MessageCreatedEvent(IChatEvent ev) : EntityEventArgs
{
public IChatEvent Event = ev;
}
/// <summary>
/// Notifies that a chat message has been changed.
/// </summary>
/// <param name="id"></param>
/// <param name="newMessage"></param>
[Serializable, NetSerializable]
public sealed class MessagePatchedEvent(uint id, string newMessage) : EntityEventArgs
{
public uint MessageId = id;
public string NewMessage = newMessage;
}
/// <summary>
/// Notifies that a chat message has been deleted.
/// </summary>
/// <param name="id"></param>
[Serializable, NetSerializable]
public sealed class MessageDeletedEvent(uint id) : EntityEventArgs
{
public uint MessageId = id;
}
/// <summary>
/// Notifies that a player's messages have been nuked.
/// </summary>
/// <param name="set"></param>
[Serializable, NetSerializable]
public sealed class MessagesNukedEvent(List<uint> set) : EntityEventArgs
{
public uint[] MessageIds = CollectionsMarshal.AsSpan(set).ToArray();
}

View File

@@ -0,0 +1,94 @@
namespace Content.Shared.Chat.V2;
/// <summary>
/// The types of messages that can be sent, validated and processed via user input that are covered by Chat V2.
/// </summary>
public enum MessageType : byte
{
#region Player-sendable types
/// <summary>
/// Chat for announcements like CentCom telling you to stop sending them memes.
/// </summary>
Announcement,
/// <summary>
/// Chat that ghosts use to complain about being gibbed.
/// </summary>
DeadChat,
/// <summary>
/// Chat that mimes use to evade their vow.
/// </summary>
Emote,
/// <summary>
/// Chat that players use to make lame jokes to people nearby.
/// </summary>
Local,
/// <summary>
/// Chat that players use to complain about shitsec/admins/antags/balance/etc.
/// </summary>
Looc,
/// <summary>
/// Chat that players use to say "HELP MAINT", or plead to call the shuttle because a beaker spilled.
/// </summary>
/// <remarks>This does not tell you what radio channel has been chatted on!</remarks>
Radio,
/// <summary>
/// Chat that is used exclusively by syndie tots to collaborate on whatever tots do.
/// </summary>
Whisper,
#endregion
#region Non-player-sendable types
/// <summary>
/// Chat that is sent to exactly one player; almost exclusively used for admemes and prayer responses.
/// </summary>
Subtle,
/// <summary>
/// Chat that is sent by automata, like when a vending machine thanks you for your unwise purchases.
/// </summary>
Background,
#endregion
}
/// <summary>
/// Defines a chat event that can be stored in a chat repository.
/// </summary>
public interface IChatEvent
{
/// <summary>
/// The sender of the chat message.
/// </summary>
public EntityUid Sender
{
get;
}
/// <summary>
/// The ID of the message. This is overwritten when saved into a repository.
/// </summary>
public uint Id
{
get;
set;
}
/// <summary>
/// The sent message.
/// </summary>
public string Message
{
get;
set;
}
/// <summary>
/// The type of sent message.
/// </summary>
public MessageType Type
{
get;
}
}

View File

@@ -0,0 +1,7 @@
command-description-deletechatmessage-id = Delete a specific chat message by message ID
command-description-nukechatmessages-usernames = Delete all of the supplied usernames' chat messages posted during this round
command-description-nukechatmessages-userids = Delete all of the supplied userIds' chat messages posted during this round
command-error-deletechatmessage-id-notexist = The message with the supplied ID does not exist
command-error-nukechatmessages-usernames-usernamenotexist = Username {$username} does not exist
command-error-nukechatmessages-usernames-usernamenomessages = UserID {$userId} has no messages to nuke