using System.Linq; using Content.Server.Administration.Logs; using Content.Server.CartridgeLoader; using Content.Server.Power.Components; using Content.Server.Radio; using Content.Server.Radio.Components; using Content.Server.Station.Systems; using Content.Shared.Access.Components; using Content.Shared.CartridgeLoader; using Content.Shared.Database; using Content.Shared.DeltaV.CartridgeLoader.Cartridges; using Content.Shared.DeltaV.NanoChat; using Content.Shared.PDA; using Content.Shared.Radio.Components; using Robust.Server.GameObjects; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Server.DeltaV.CartridgeLoader.Cartridges; public sealed class NanoChatCartridgeSystem : EntitySystem { [Dependency] private readonly CartridgeLoaderSystem _cartridge = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!; [Dependency] private readonly StationSystem _station = default!; [Dependency] private readonly UserInterfaceSystem _ui = default!; // Messages in notifications get cut off after this point // no point in storing it on the comp private const int NotificationMaxLength = 64; // The max length of the name and job title on the notification before being truncated. private const int NotificationTitleMaxLength = 32; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnUiReady); SubscribeLocalEvent(OnMessage); } private void UpdateClosed(Entity ent) { if (!TryComp(ent, out var cartridge) || cartridge.LoaderUid is not { } pda || !TryComp(pda, out var loader) || !GetCardEntity(pda, out var card)) { return; } } public override void Update(float frameTime) { base.Update(frameTime); // Update card references for any cartridges that need it var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var nanoChat)) { if (!TryComp(uid, out CartridgeComponent? cartridge) || cartridge.LoaderUid == null || !TryComp(cartridge.LoaderUid, out PdaComponent? pda)) continue; var newCard = pda.ContainedId; var currentCard = nanoChat.Card; // If the cards match, nothing to do if (newCard == currentCard) continue; // Update card reference nanoChat.Card = newCard; // Update UI state since card reference changed UpdateUI((uid, nanoChat), cartridge.LoaderUid.Value); } } /// /// Handles incoming UI messages from the NanoChat cartridge. /// private void OnMessage(Entity ent, ref CartridgeMessageEvent args) { if (args is not NanoChatUiMessageEvent msg) return; if (!GetCardEntity(GetEntity(args.LoaderUid), out var card)) return; switch (msg.Type) { case NanoChatUiMessageType.NewChat: HandleNewChat(card, msg); break; case NanoChatUiMessageType.SelectChat: HandleSelectChat(card, msg); break; case NanoChatUiMessageType.EditChat: HandleEditChat(card, msg); break; case NanoChatUiMessageType.CloseChat: HandleCloseChat(card); break; case NanoChatUiMessageType.ToggleMute: HandleToggleMute(card); break; case NanoChatUiMessageType.DeleteChat: HandleDeleteChat(card, msg); break; case NanoChatUiMessageType.SendMessage: HandleSendMessage(ent, card, msg); break; case NanoChatUiMessageType.ToggleListNumber: HandleToggleListNumber(card); break; } UpdateUI(ent, GetEntity(args.LoaderUid)); } /// /// Gets the ID card entity associated with a PDA. /// /// The PDA entity ID /// Output parameter containing the found card entity and component /// True if a valid NanoChat card was found private bool GetCardEntity( EntityUid loaderUid, out Entity card) { card = default; // Get the PDA and check if it has an ID card if (!TryComp(loaderUid, out var pda) || pda.ContainedId == null || !TryComp(pda.ContainedId, out var idCard)) return false; card = (pda.ContainedId.Value, idCard); return true; } /// /// Handles creation of a new chat conversation. /// private void HandleNewChat(Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber == null || msg.Content == null || msg.RecipientNumber == card.Comp.Number) return; var name = msg.Content; if (!string.IsNullOrWhiteSpace(name)) { name = name.Trim(); if (name.Length > IdCardConsoleComponent.MaxFullNameLength) name = name[..IdCardConsoleComponent.MaxFullNameLength]; } var jobTitle = msg.RecipientJob; if (!string.IsNullOrWhiteSpace(jobTitle)) { jobTitle = jobTitle.Trim(); if (jobTitle.Length > IdCardConsoleComponent.MaxJobTitleLength) jobTitle = jobTitle[..IdCardConsoleComponent.MaxJobTitleLength]; } // Add new recipient var recipient = new NanoChatRecipient(msg.RecipientNumber.Value, name, jobTitle); // Initialize or update recipient _nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient); _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(msg.Actor):user} created new NanoChat conversation with #{msg.RecipientNumber:D4} ({name})"); var recipientEv = new NanoChatRecipientUpdatedEvent(card); RaiseLocalEvent(ref recipientEv); UpdateUIForCard(card); } /// /// Handles selecting a chat conversation. /// private void HandleSelectChat(Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber == null) return; _nanoChat.SetCurrentChat((card, card.Comp), msg.RecipientNumber); // Clear unread flag when selecting chat if (_nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value) is { } recipient) { _nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient with { HasUnread = false }); } } /// /// Handles editing the current chat conversation. /// private void HandleEditChat(Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber == null || msg.Content == null || msg.RecipientNumber == card.Comp.Number || _nanoChat.GetRecipient((card, card.Comp), msg.RecipientNumber.Value) is not {} recipient) return; var name = msg.Content; if (!string.IsNullOrWhiteSpace(name)) { name = name.Trim(); if (name.Length > IdCardConsoleComponent.MaxFullNameLength) name = name[..IdCardConsoleComponent.MaxFullNameLength]; } var jobTitle = msg.RecipientJob; if (!string.IsNullOrWhiteSpace(jobTitle)) { jobTitle = jobTitle.Trim(); if (jobTitle.Length > IdCardConsoleComponent.MaxJobTitleLength) jobTitle = jobTitle[..IdCardConsoleComponent.MaxJobTitleLength]; } // Update recipient recipient.Name = name; recipient.JobTitle = jobTitle; _nanoChat.SetRecipient((card, card.Comp), msg.RecipientNumber.Value, recipient); var recipientEv = new NanoChatRecipientUpdatedEvent(card); RaiseLocalEvent(ref recipientEv); UpdateUIForCard(card); } /// /// Handles closing the current chat conversation. /// private void HandleCloseChat(Entity card) { _nanoChat.SetCurrentChat((card, card.Comp), null); } /// /// Handles deletion of a chat conversation. /// private void HandleDeleteChat(Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber == null || card.Comp.Number == null) return; // Delete chat but keep the messages var deleted = _nanoChat.TryDeleteChat((card, card.Comp), msg.RecipientNumber.Value, true); if (!deleted) return; _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(msg.Actor):user} deleted NanoChat conversation with #{msg.RecipientNumber:D4}"); UpdateUIForCard(card); } /// /// Handles toggling notification mute state. /// private void HandleToggleMute(Entity card) { _nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp))); UpdateUIForCard(card); } private void HandleToggleListNumber(Entity card) { _nanoChat.SetListNumber((card, card.Comp), !_nanoChat.GetListNumber((card, card.Comp))); UpdateUIForAllCards(); } /// /// Handles sending a new message in a chat conversation. /// private void HandleSendMessage(Entity cartridge, Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null) return; if (!EnsureRecipientExists(card, msg.RecipientNumber.Value)) return; var content = msg.Content; if (!string.IsNullOrWhiteSpace(content)) { content = content.Trim(); if (content.Length > NanoChatMessage.MaxContentLength) content = content[..NanoChatMessage.MaxContentLength]; } // Create and store message for sender var message = new NanoChatMessage( _timing.CurTime, content, (uint)card.Comp.Number ); // Attempt delivery var (deliveryFailed, recipients) = AttemptMessageDelivery(cartridge, msg.RecipientNumber.Value); // Update delivery status message = message with { DeliveryFailed = deliveryFailed }; // Store message in sender's outbox under recipient's number _nanoChat.AddMessage((card, card.Comp), msg.RecipientNumber.Value, message); // Log message attempt var recipientsText = recipients.Count > 0 ? string.Join(", ", recipients.Select(r => ToPrettyString(r))) : $"#{msg.RecipientNumber:D4}"; _adminLogger.Add(LogType.Chat, LogImpact.Low, $"{ToPrettyString(card):user} sent NanoChat message to {recipientsText}: {content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}"); var msgEv = new NanoChatMessageReceivedEvent(card); RaiseLocalEvent(ref msgEv); if (deliveryFailed) return; foreach (var recipient in recipients) { DeliverMessageToRecipient(card, recipient, message); } } /// /// Ensures a recipient exists in the sender's contacts. /// /// The card to check contacts for /// The recipient's number to check /// True if the recipient exists or was created successfully private bool EnsureRecipientExists(Entity card, uint recipientNumber) { return _nanoChat.EnsureRecipientExists((card, card.Comp), recipientNumber, GetCardInfo(recipientNumber)); } /// /// Attempts to deliver a message to recipients. /// /// The sending cartridge entity /// The recipient's number /// Tuple containing delivery status and recipients if found. private (bool failed, List> recipient) AttemptMessageDelivery( Entity sender, uint recipientNumber) { // First verify we can send from this device var channel = _prototype.Index(sender.Comp.RadioChannel); var sendAttemptEvent = new RadioSendAttemptEvent(channel, sender); RaiseLocalEvent(ref sendAttemptEvent); if (sendAttemptEvent.Cancelled) return (true, new List>()); var foundRecipients = new List>(); // Find all cards with matching number var cardQuery = EntityQueryEnumerator(); while (cardQuery.MoveNext(out var cardUid, out var card)) { if (card.Number != recipientNumber) continue; foundRecipients.Add((cardUid, card)); } if (foundRecipients.Count == 0) return (true, foundRecipients); // Now check if any of these cards can receive var deliverableRecipients = new List>(); foreach (var recipient in foundRecipients) { // Find any cartridges that have this card var cartridgeQuery = EntityQueryEnumerator(); while (cartridgeQuery.MoveNext(out var receiverUid, out var receiverCart, out _)) { if (receiverCart.Card != recipient.Owner) continue; // Check if devices are on same station/map var recipientStation = _station.GetOwningStation(receiverUid); var senderStation = _station.GetOwningStation(sender); // Both entities must be on a station if (recipientStation == null || senderStation == null) continue; // Must be on same map/station unless long range allowed if (!channel.LongRange && recipientStation != senderStation) continue; // Needs telecomms if (!HasActiveServer(senderStation.Value) || !HasActiveServer(recipientStation.Value)) continue; // Check if recipient can receive var receiveAttemptEv = new RadioReceiveAttemptEvent(channel, sender, receiverUid); RaiseLocalEvent(ref receiveAttemptEv); if (receiveAttemptEv.Cancelled) continue; // Found valid cartridge that can receive deliverableRecipients.Add(recipient); break; // Only need one valid cartridge per card } } return (deliverableRecipients.Count == 0, deliverableRecipients); } /// /// Checks if there are any active telecomms servers on the given station /// private bool HasActiveServer(EntityUid station) { // I have no idea why this isn't public in the RadioSystem var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out _, out _, out var power)) { if (_station.GetOwningStation(uid) == station && power.Powered) return true; } return false; } /// /// Delivers a message to the recipient and handles associated notifications. /// /// The sender's card entity /// The recipient's card entity /// The to deliver private void DeliverMessageToRecipient(Entity sender, Entity recipient, NanoChatMessage message) { var senderNumber = sender.Comp.Number; if (senderNumber == null) return; // Always try to get and add sender info to recipient's contacts if (!EnsureRecipientExists(recipient, senderNumber.Value)) return; _nanoChat.AddMessage((recipient, recipient.Comp), senderNumber.Value, message with { DeliveryFailed = false }); HandleUnreadNotification(recipient, message, (uint) senderNumber); var msgEv = new NanoChatMessageReceivedEvent(recipient); RaiseLocalEvent(ref msgEv); UpdateUIForCard(recipient); } /// /// Handles unread message notifications and updates unread status. /// private void HandleUnreadNotification(Entity recipient, NanoChatMessage message, uint senderNumber) { // Get sender name from contacts or fall back to number var recipients = _nanoChat.GetRecipients((recipient, recipient.Comp)); var senderName = recipients.TryGetValue(message.SenderId, out var senderRecipient) ? senderRecipient.Name : $"#{message.SenderId:D4}"; var hasSelectedCurrentChat = _nanoChat.GetCurrentChat((recipient, recipient.Comp)) == senderNumber; // Update unread status if (!hasSelectedCurrentChat) _nanoChat.SetRecipient((recipient, recipient.Comp), message.SenderId, senderRecipient with { HasUnread = true }); if (recipient.Comp.NotificationsMuted || recipient.Comp.PdaUid is not {} pdaUid || !TryComp(pdaUid, out var loader) || // Don't notify if the recipient has the NanoChat program open with this chat selected. (hasSelectedCurrentChat && _ui.IsUiOpen(pdaUid, PdaUiKey.Key) && HasComp(loader.ActiveProgram))) return; var title = ""; if (!String.IsNullOrEmpty(senderRecipient.JobTitle)) { var titleRecipient = Truncate(Loc.GetString("nano-chat-new-message-title-recipient", ("sender", senderName), ("jobTitle", senderRecipient.JobTitle)), NotificationTitleMaxLength, " \\[...\\]"); title = Loc.GetString("nano-chat-new-message-title", ("sender", titleRecipient)); } else title = Loc.GetString("nano-chat-new-message-title", ("sender", senderName)); _cartridge.SendNotification(pdaUid, title, Loc.GetString("nano-chat-new-message-body", ("message", Truncate(message.Content, NotificationMaxLength, " [...]"))), loader); } /// /// Updates the UI for any PDAs containing the specified card. /// private void UpdateUIForCard(EntityUid cardUid) { // Find any PDA containing this card and update its UI var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var comp, out var cartridge)) { if (comp.Card != cardUid || cartridge.LoaderUid == null) continue; UpdateUI((uid, comp), cartridge.LoaderUid.Value); } } /// /// Updates the UI for all PDAs containing a NanoChat cartridge. /// private void UpdateUIForAllCards() { // Find any PDA containing this card and update its UI var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var comp, out var cartridge)) { if (cartridge.LoaderUid is { } loader) UpdateUI((uid, comp), loader); } } /// /// Gets the for a given NanoChat number. /// private NanoChatRecipient? GetCardInfo(uint number) { // Find card with this number to get its info var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var card)) { if (card.Number != number) continue; // Try to get job title from ID card if possible string? jobTitle = null; var name = "Unknown"; if (TryComp(uid, out var idCard)) { jobTitle = idCard.LocalizedJobTitle; name = idCard.FullName ?? name; } return new NanoChatRecipient(number, name, jobTitle); } return null; } /// /// Truncates a string to a maximum length. /// private static string Truncate(string text, int maxLength, string overflowText = "...") => text.Length <= maxLength ? text : text[..(maxLength - overflowText.Length)] + overflowText; private void OnUiReady(Entity ent, ref CartridgeUiReadyEvent args) { _cartridge.RegisterBackgroundProgram(args.Loader, ent); UpdateUI(ent, args.Loader); } private void UpdateUI(Entity ent, EntityUid loader) { List? contacts; if (_station.GetOwningStation(loader) is { } station) { ent.Comp.Station = station; contacts = []; var query = AllEntityQuery(); while (query.MoveNext(out var entityId, out var nanoChatCard, out var idCardComponent)) { if (nanoChatCard.ListNumber && nanoChatCard.Number is uint nanoChatNumber && idCardComponent.FullName is string fullName && _station.GetOwningStation(entityId) == station) { contacts.Add(new NanoChatRecipient(nanoChatNumber, fullName)); } } contacts.Sort((contactA, contactB) => string.CompareOrdinal(contactA.Name, contactB.Name)); } else { contacts = null; } var recipients = new Dictionary(); var messages = new Dictionary>(); uint? currentChat = null; uint ownNumber = 0; var maxRecipients = 50; var notificationsMuted = false; var listNumber = false; if (ent.Comp.Card != null && TryComp(ent.Comp.Card, out var card)) { recipients = card.Recipients; messages = card.Messages; currentChat = card.CurrentChat; ownNumber = card.Number ?? 0; maxRecipients = card.MaxRecipients; notificationsMuted = card.NotificationsMuted; listNumber = card.ListNumber; } var state = new NanoChatUiState(recipients, messages, contacts, currentChat, ownNumber, maxRecipients, notificationsMuted, listNumber); _cartridge.UpdateCartridgeUiState(loader, state); } }