using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Server.Administration.Logs; using Content.Server.CartridgeLoader; using Content.Server.Power.Components; using Content.Server.Radio; using Content.Server.Station.Systems; using Content.Shared.Access.Components; using Content.Shared.CartridgeLoader; using Content.Shared.CCVar; using Content.Shared.Database; using Content.Shared._DV.CartridgeLoader.Cartridges; using Content.Shared._DV.NanoChat; using Content.Shared.PDA; using Content.Shared.Radio.Components; using Content.Shared.Silicons.Borgs.Components; using Content.Shared.Silicons.StationAi; using Robust.Shared.Configuration; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Server._DV.CartridgeLoader.Cartridges; public sealed class NanoChatCartridgeSystem : EntitySystem { [Dependency] private readonly CartridgeLoaderSystem _cartridge = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly SharedNanoChatSystem _nanoChat = default!; [Dependency] private readonly SharedUserInterfaceSystem _ui = default!; [Dependency] private readonly StationSystem _station = default!; private EntityQuery _pdaQuery; private EntityQuery _cardQuery; // 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; private int _maxNameLength; private int _maxIdJobLength; public override void Initialize() { base.Initialize(); _pdaQuery = GetEntityQuery(); _cardQuery = GetEntityQuery(); SubscribeLocalEvent(OnActiveProgramChanged); SubscribeLocalEvent(OnUiOpened); SubscribeLocalEvent(OnUiClosed); SubscribeLocalEvent(OnUiReady); SubscribeLocalEvent(OnMessage); Subs.CVar(_cfg, CCVars.MaxNameLength, x => _maxNameLength = x, true); Subs.CVar(_cfg, CCVars.MaxIdJobLength, x => _maxIdJobLength = x, true); } private void OnActiveProgramChanged(Entity ent, ref ActiveProgramChangedEvent args) { if (!GetCardEntity(ent, out var nanoChatCard)) return; _nanoChat.SetClosed(nanoChatCard.Value.AsNullable(), !HasComp(args.NewActiveProgram)); } private void OnUiOpened(Entity ent, ref BoundUIOpenedEvent args) { if (!PdaUiKey.Key.Equals(args.UiKey)) return; if (!GetCardEntity(ent, out var nanoChatCard)) return; if (nanoChatCard.Value.Comp.IsClosed) _nanoChat.SetClosed(nanoChatCard.Value.AsNullable(), !HasComp(ent.Comp.ActiveProgram)); } private void OnUiClosed(Entity ent, ref BoundUIClosedEvent args) { if (!PdaUiKey.Key.Equals(args.UiKey)) return; if (!GetCardEntity(ent, out var nanoChatCard)) return; // Since the UI got closed we always set it to be closed if (!nanoChatCard.Value.Comp.IsClosed) _nanoChat.SetClosed(nanoChatCard.Value.AsNullable(), true); } 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, out var cartridge)) { if (cartridge.LoaderUid == null) continue; // Check if we need to update our card reference if (!_pdaQuery.TryComp(cartridge.LoaderUid, out var pda)) continue; // TODO Perf: this could be faster if we get rid of the trycomp in the update loop altogether // There was a reason I did this in the Update loop but I can't remember. GetCardEntity(cartridge.LoaderUid.Value, out var newCardEnt); var newCard = newCardEnt?.Owner; 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.Value, msg); break; case NanoChatUiMessageType.SelectChat: HandleSelectChat(card.Value, msg); break; case NanoChatUiMessageType.EditChat: HandleEditChat(card.Value, msg); break; case NanoChatUiMessageType.CloseChat: HandleCloseChat(card.Value); break; case NanoChatUiMessageType.ToggleMute: HandleToggleMute(card.Value); break; case NanoChatUiMessageType.ToggleMuteChat: HandleToggleMuteChat(card.Value, msg); break; case NanoChatUiMessageType.DeleteChat: HandleDeleteChat(card.Value, msg); break; case NanoChatUiMessageType.SendMessage: HandleSendMessage(ent, card.Value, msg); break; case NanoChatUiMessageType.ToggleListNumber: HandleToggleListNumber(card.Value); 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, [NotNullWhen(true)] out Entity? card) { card = null; if (TryComp(loaderUid, out var selfCard)) { card = (loaderUid, selfCard); return true; } // Get the PDA and check if it has an ID card if (!_pdaQuery.TryComp(loaderUid, out var pda) || pda.ContainedId == null || !_cardQuery.TryComp(pda.ContainedId, out var idCard)) return false; card = (pda.ContainedId.Value, idCard); return true; } /// /// Gets the cartridge loader associated with a card. /// private bool GetCartridgeLoader( Entity card, [NotNullWhen(true)] out Entity? loader) { loader = null; if (TryComp(card, out var selfLoader)) { loader = (card, selfLoader); return true; } if (card.Comp.PdaUid is { } pdaUid && TryComp(pdaUid, out var pdaLoader)) { loader = (pdaUid, pdaLoader); return true; } return false; } /// /// 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 > _maxNameLength) name = name[.._maxNameLength]; } var jobTitle = msg.RecipientJob; if (!string.IsNullOrWhiteSpace(jobTitle)) { jobTitle = jobTitle.Trim(); if (jobTitle.Length > _maxIdJobLength) jobTitle = jobTitle[.._maxIdJobLength]; } // 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 > _maxNameLength) name = name[.._maxNameLength]; } var jobTitle = msg.RecipientJob; if (!string.IsNullOrWhiteSpace(jobTitle)) { jobTitle = jobTitle.Trim(); if (jobTitle.Length > _maxIdJobLength) jobTitle = jobTitle[.._maxIdJobLength]; } // 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 HandleToggleMuteChat(Entity card, NanoChatUiMessageEvent msg) { if (msg.RecipientNumber is not uint chat) return; _nanoChat.ToggleChatMuted((card, card.Comp), chat); 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) { if (sender.Comp.Number is not uint senderNumber) return; // Always try to get and add sender info to recipient's contacts if (!EnsureRecipientExists(recipient, senderNumber)) return; _nanoChat.AddMessage((recipient, recipient.Comp), senderNumber, message with { DeliveryFailed = false }); if (recipient.Comp.IsClosed || _nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber) HandleUnreadNotification(recipient, message, 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 }); // Temporary local to avoid trouble with read-only access; Contains doesn't modify the collection HashSet mutedChats = recipient.Comp.MutedChats; if (recipient.Comp.NotificationsMuted || mutedChats.Contains(message.SenderId) || !GetCartridgeLoader(recipient, out var loader) || // Don't notify if the recipient has the NanoChat program open with this chat selected. (hasSelectedCurrentChat && _ui.IsUiOpen(loader.Value.Owner, PdaUiKey.Key) && HasComp(loader.Value.Comp.ActiveProgram))) return; var title = ""; if (!string.IsNullOrEmpty(senderRecipient.JobTitle)) { var titleRecipient = SharedNanoChatSystem.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(loader.Value, title, Loc.GetString("nano-chat-new-message-body", ("message", SharedNanoChatSystem.Truncate(message.Content, NotificationMaxLength, " [...]"))), loader.Value); } /// /// 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; if (HasComp(uid)) { if (!TryComp(uid, out var switchable) || switchable.SelectedBorgType is not { } borgType) return new NanoChatRecipient(number, MetaData(uid).EntityName, null); return new NanoChatRecipient(number, MetaData(uid).EntityName, Loc.GetString($"borg-type-{borgType}-transponder")); } if (HasComp(uid)) { return new NanoChatRecipient(number, MetaData(uid).EntityName, Loc.GetString($"station-ai-transponder")); } // 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; } 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)); } } var borgQuery = AllEntityQuery(); while (borgQuery.MoveNext(out var borgId, out var borgChatCard, out var _)) { if (borgChatCard.ListNumber && borgChatCard.Number is uint nanoChatNumber && _station.GetOwningStation(borgId) == station) { contacts.Add(new NanoChatRecipient(nanoChatNumber, MetaData(borgId).EntityName)); } } var aiQuery = AllEntityQuery(); while (aiQuery.MoveNext(out var aiId, out var aiChatCard, out var _)) { if (aiChatCard.ListNumber && aiChatCard.Number is uint nanoChatNumber && _station.GetOwningStation(aiId) == station) { contacts.Add(new NanoChatRecipient(nanoChatNumber, MetaData(aiId).EntityName)); } } contacts.Sort((contactA, contactB) => string.CompareOrdinal(contactA.Name, contactB.Name)); } else { contacts = null; } var recipients = new Dictionary(); var messages = new Dictionary>(); var mutedChats = new HashSet(); uint? currentChat = null; uint ownNumber = 0; var maxRecipients = 50; var notificationsMuted = false; var listNumber = false; if (ent.Comp.Card != null && _cardQuery.TryComp(ent.Comp.Card, out var card)) { recipients = card.Recipients; messages = card.Messages; mutedChats = card.MutedChats; currentChat = card.CurrentChat; ownNumber = card.Number ?? 0; maxRecipients = card.MaxRecipients; notificationsMuted = card.NotificationsMuted; listNumber = card.ListNumber; } var state = new NanoChatUiState(recipients, messages, mutedChats, contacts, currentChat, ownNumber, maxRecipients, notificationsMuted, listNumber); _cartridge.UpdateCartridgeUiState(loader, state); } }