--- /dev/null
+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; }
+}
--- /dev/null
+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; }
+}
--- /dev/null
+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;
+}
+
--- /dev/null
+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();
+ }
+}
--- /dev/null
+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();
+}
+
--- /dev/null
+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;
+ }
+}
--- /dev/null
+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