diff --git a/Content.Server/Chat/V2/Commands/DeleteChatMessageCommand.cs b/Content.Server/Chat/V2/Commands/DeleteChatMessageCommand.cs new file mode 100644 index 0000000000..1f9203d299 --- /dev/null +++ b/Content.Server/Chat/V2/Commands/DeleteChatMessageCommand.cs @@ -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().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; } +} diff --git a/Content.Server/Chat/V2/Commands/NukeChatMessagesCommand.cs b/Content.Server/Chat/V2/Commands/NukeChatMessagesCommand.cs new file mode 100644 index 0000000000..3d8b69dd76 --- /dev/null +++ b/Content.Server/Chat/V2/Commands/NukeChatMessagesCommand.cs @@ -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().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; } +} diff --git a/Content.Server/Chat/V2/Messages.cs b/Content.Server/Chat/V2/Messages.cs new file mode 100644 index 0000000000..31a563cbeb --- /dev/null +++ b/Content.Server/Chat/V2/Messages.cs @@ -0,0 +1,94 @@ +using Content.Shared.Chat.Prototypes; +using Content.Shared.Chat.V2; +using Content.Shared.Radio; + +namespace Content.Server.Chat.V2; + +/// +/// Raised locally when a comms announcement is made. +/// +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; +} + +/// +/// Raised locally when a character speaks in Dead Chat. +/// +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; +} + +/// +/// Raised locally when a character emotes. +/// +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; +} + +/// +/// Raised locally when a character talks in local. +/// +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; +} + +/// +/// Raised locally when a character speaks in LOOC. +/// +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; +} + +/// +/// Raised locally when a character speaks on the radio. +/// +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; +} + +/// +/// Raised locally when a character whispers. +/// +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; +} + diff --git a/Content.Server/Chat/V2/Repository/ChatRepository.cs b/Content.Server/Chat/V2/Repository/ChatRepository.cs new file mode 100644 index 0000000000..06de37128f --- /dev/null +++ b/Content.Server/Chat/V2/Repository/ChatRepository.cs @@ -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; + +/// +/// Stores , gives them UIDs, and issues . +/// Allows for deletion of messages. +/// +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 _messages = new(); + private Dictionary> _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(); + }; + } + + /// + /// Adds an to the repo and raises it with a UID for consumption elsewhere. + /// + /// The event to store and raise + /// If storing and raising succeeded. + 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; + } + + /// + /// Returns the event associated with a UID, if it exists. + /// + /// The UID of a event. + /// The event, if it exists. + public IChatEvent? GetEventFor(uint id) + { + return _messages.TryGetValue(id, out var record) ? record.StoredEvent : null; + } + + /// + /// Edits a specific message and issues a 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. + /// + /// The ID to edit + /// The new message to send + /// If patching did anything did anything + /// Should be used for admining and admemeing only. + 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; + } + + /// + /// Deletes a message from the repository and issues a that says this has happened + /// both locally and on the network. + /// + /// The ID to delete + /// If deletion did anything + /// Should only be used for adminning + 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; + } + + /// + /// Nukes a user's entire chat history from the repo and issues a saying this has + /// happened. + /// + /// The user ID to nuke. + /// Why nuking failed, if it did. + /// If nuking did anything. + /// Note that this could be a very large 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 >:( + 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); + } + + /// + /// Nukes a user's entire chat history from the repo and issues a saying this has + /// happened. + /// + /// The user ID to nuke. + /// Why nuking failed, if it did. + /// If nuking did anything. + /// Note that this could be a very large 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 >:( + 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; + } + + /// + /// Dumps held chat storage data and refreshes the repo. + /// + public void Refresh() + { + _nextMessageId = 1; + _messages.Clear(); + _playerMessages.Clear(); + } +} diff --git a/Content.Shared/Chat/V2/Repository/Types.cs b/Content.Shared/Chat/V2/Repository/Types.cs new file mode 100644 index 0000000000..59acb849d4 --- /dev/null +++ b/Content.Shared/Chat/V2/Repository/Types.cs @@ -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; + +/// +/// The record associated with a specific chat event. +/// +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; +} + +/// +/// Notifies that a chat message has been created. +/// +/// +[Serializable, NetSerializable] +public sealed class MessageCreatedEvent(IChatEvent ev) : EntityEventArgs +{ + public IChatEvent Event = ev; +} + +/// +/// Notifies that a chat message has been changed. +/// +/// +/// +[Serializable, NetSerializable] +public sealed class MessagePatchedEvent(uint id, string newMessage) : EntityEventArgs +{ + public uint MessageId = id; + public string NewMessage = newMessage; +} + +/// +/// Notifies that a chat message has been deleted. +/// +/// +[Serializable, NetSerializable] +public sealed class MessageDeletedEvent(uint id) : EntityEventArgs +{ + public uint MessageId = id; +} + +/// +/// Notifies that a player's messages have been nuked. +/// +/// +[Serializable, NetSerializable] +public sealed class MessagesNukedEvent(List set) : EntityEventArgs +{ + public uint[] MessageIds = CollectionsMarshal.AsSpan(set).ToArray(); +} + diff --git a/Content.Shared/Chat/V2/Types.cs b/Content.Shared/Chat/V2/Types.cs new file mode 100644 index 0000000000..50e5a53ab5 --- /dev/null +++ b/Content.Shared/Chat/V2/Types.cs @@ -0,0 +1,94 @@ +namespace Content.Shared.Chat.V2; + +/// +/// The types of messages that can be sent, validated and processed via user input that are covered by Chat V2. +/// +public enum MessageType : byte +{ + #region Player-sendable types + + /// + /// Chat for announcements like CentCom telling you to stop sending them memes. + /// + Announcement, + /// + /// Chat that ghosts use to complain about being gibbed. + /// + DeadChat, + /// + /// Chat that mimes use to evade their vow. + /// + Emote, + /// + /// Chat that players use to make lame jokes to people nearby. + /// + Local, + /// + /// Chat that players use to complain about shitsec/admins/antags/balance/etc. + /// + Looc, + /// + /// Chat that players use to say "HELP MAINT", or plead to call the shuttle because a beaker spilled. + /// + /// This does not tell you what radio channel has been chatted on! + Radio, + /// + /// Chat that is used exclusively by syndie tots to collaborate on whatever tots do. + /// + Whisper, + + #endregion + + #region Non-player-sendable types + + /// + /// Chat that is sent to exactly one player; almost exclusively used for admemes and prayer responses. + /// + Subtle, + /// + /// Chat that is sent by automata, like when a vending machine thanks you for your unwise purchases. + /// + Background, + + #endregion +} + +/// +/// Defines a chat event that can be stored in a chat repository. +/// +public interface IChatEvent +{ + /// + /// The sender of the chat message. + /// + public EntityUid Sender + { + get; + } + + /// + /// The ID of the message. This is overwritten when saved into a repository. + /// + public uint Id + { + get; + set; + } + + /// + /// The sent message. + /// + public string Message + { + get; + set; + } + + /// + /// The type of sent message. + /// + public MessageType Type + { + get; + } +} diff --git a/Resources/Locale/en-US/chat/chat-repo.ftl b/Resources/Locale/en-US/chat/chat-repo.ftl new file mode 100644 index 0000000000..a53380260b --- /dev/null +++ b/Resources/Locale/en-US/chat/chat-repo.ftl @@ -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