Delta-v/Content.Server/DeltaV/CartridgeLoader/Cartridges/NanoChatCartridgeSystem.cs

515 lines
19 KiB
C#

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.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!;
// Messages in notifications get cut off after this point
// no point in storing it on the comp
private const int NotificationMaxLength = 64;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeUiReadyEvent>(OnUiReady);
SubscribeLocalEvent<NanoChatCartridgeComponent, CartridgeMessageEvent>(OnMessage);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
// Update card references for any cartridges that need it
var query = EntityQueryEnumerator<NanoChatCartridgeComponent, CartridgeComponent>();
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 (!TryComp<PdaComponent>(cartridge.LoaderUid, out var 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);
}
}
/// <summary>
/// Handles incoming UI messages from the NanoChat cartridge.
/// </summary>
private void OnMessage(Entity<NanoChatCartridgeComponent> 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.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;
}
UpdateUI(ent, GetEntity(args.LoaderUid));
}
/// <summary>
/// Gets the ID card entity associated with a PDA.
/// </summary>
/// <param name="loaderUid">The PDA entity ID</param>
/// <param name="card">Output parameter containing the found card entity and component</param>
/// <returns>True if a valid NanoChat card was found</returns>
private bool GetCardEntity(
EntityUid loaderUid,
out Entity<NanoChatCardComponent> card)
{
card = default;
// Get the PDA and check if it has an ID card
if (!TryComp<PdaComponent>(loaderUid, out var pda) ||
pda.ContainedId == null ||
!TryComp<NanoChatCardComponent>(pda.ContainedId, out var idCard))
return false;
card = (pda.ContainedId.Value, idCard);
return true;
}
/// <summary>
/// Handles creation of a new chat conversation.
/// </summary>
private void HandleNewChat(Entity<NanoChatCardComponent> card, NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || msg.RecipientNumber == card.Comp.Number)
return;
// Add new recipient
var recipient = new NanoChatRecipient(msg.RecipientNumber.Value,
msg.Content,
msg.RecipientJob);
// 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} ({msg.Content})");
var recipientEv = new NanoChatRecipientUpdatedEvent(card);
RaiseLocalEvent(ref recipientEv);
UpdateUIForCard(card);
}
/// <summary>
/// Handles selecting a chat conversation.
/// </summary>
private void HandleSelectChat(Entity<NanoChatCardComponent> 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 });
}
}
/// <summary>
/// Handles closing the current chat conversation.
/// </summary>
private void HandleCloseChat(Entity<NanoChatCardComponent> card)
{
_nanoChat.SetCurrentChat((card, card.Comp), null);
}
/// <summary>
/// Handles deletion of a chat conversation.
/// </summary>
private void HandleDeleteChat(Entity<NanoChatCardComponent> 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);
}
/// <summary>
/// Handles toggling notification mute state.
/// </summary>
private void HandleToggleMute(Entity<NanoChatCardComponent> card)
{
_nanoChat.SetNotificationsMuted((card, card.Comp), !_nanoChat.GetNotificationsMuted((card, card.Comp)));
UpdateUIForCard(card);
}
/// <summary>
/// Handles sending a new message in a chat conversation.
/// </summary>
private void HandleSendMessage(Entity<NanoChatCartridgeComponent> cartridge,
Entity<NanoChatCardComponent> card,
NanoChatUiMessageEvent msg)
{
if (msg.RecipientNumber == null || msg.Content == null || card.Comp.Number == null)
return;
if (!EnsureRecipientExists(card, msg.RecipientNumber.Value))
return;
// Create and store message for sender
var message = new NanoChatMessage(
_timing.CurTime,
msg.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}: {msg.Content}{(deliveryFailed ? " [DELIVERY FAILED]" : "")}");
var msgEv = new NanoChatMessageReceivedEvent(card);
RaiseLocalEvent(ref msgEv);
if (deliveryFailed)
return;
foreach (var recipient in recipients)
{
DeliverMessageToRecipient(card, recipient, message);
}
}
/// <summary>
/// Ensures a recipient exists in the sender's contacts.
/// </summary>
/// <param name="card">The card to check contacts for</param>
/// <param name="recipientNumber">The recipient's number to check</param>
/// <returns>True if the recipient exists or was created successfully</returns>
private bool EnsureRecipientExists(Entity<NanoChatCardComponent> card, uint recipientNumber)
{
return _nanoChat.EnsureRecipientExists((card, card.Comp), recipientNumber, GetCardInfo(recipientNumber));
}
/// <summary>
/// Attempts to deliver a message to recipients.
/// </summary>
/// <param name="sender">The sending cartridge entity</param>
/// <param name="recipientNumber">The recipient's number</param>
/// <returns>Tuple containing delivery status and recipients if found.</returns>
private (bool failed, List<Entity<NanoChatCardComponent>> recipient) AttemptMessageDelivery(
Entity<NanoChatCartridgeComponent> 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<Entity<NanoChatCardComponent>>());
var foundRecipients = new List<Entity<NanoChatCardComponent>>();
// Find all cards with matching number
var cardQuery = EntityQueryEnumerator<NanoChatCardComponent>();
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<Entity<NanoChatCardComponent>>();
foreach (var recipient in foundRecipients)
{
// Find any cartridges that have this card
var cartridgeQuery = EntityQueryEnumerator<NanoChatCartridgeComponent, ActiveRadioComponent>();
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);
}
/// <summary>
/// Checks if there are any active telecomms servers on the given station
/// </summary>
private bool HasActiveServer(EntityUid station)
{
// I have no idea why this isn't public in the RadioSystem
var query =
EntityQueryEnumerator<TelecomServerComponent, EncryptionKeyHolderComponent, ApcPowerReceiverComponent>();
while (query.MoveNext(out var uid, out _, out _, out var power))
{
if (_station.GetOwningStation(uid) == station && power.Powered)
return true;
}
return false;
}
/// <summary>
/// Delivers a message to the recipient and handles associated notifications.
/// </summary>
/// <param name="sender">The sender's card entity</param>
/// <param name="recipient">The recipient's card entity</param>
/// <param name="message">The <see cref="NanoChatMessage" /> to deliver</param>
private void DeliverMessageToRecipient(Entity<NanoChatCardComponent> sender,
Entity<NanoChatCardComponent> 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 });
if (_nanoChat.GetCurrentChat((recipient, recipient.Comp)) != senderNumber)
HandleUnreadNotification(recipient, message);
var msgEv = new NanoChatMessageReceivedEvent(recipient);
RaiseLocalEvent(ref msgEv);
UpdateUIForCard(recipient);
}
/// <summary>
/// Handles unread message notifications and updates unread status.
/// </summary>
private void HandleUnreadNotification(Entity<NanoChatCardComponent> recipient, NanoChatMessage message)
{
// 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 existingRecipient)
? existingRecipient.Name
: $"#{message.SenderId:D4}";
if (!recipient.Comp.Recipients[message.SenderId].HasUnread && !recipient.Comp.NotificationsMuted)
{
var pdaQuery = EntityQueryEnumerator<PdaComponent>();
while (pdaQuery.MoveNext(out var pdaUid, out var pdaComp))
{
if (pdaComp.ContainedId != recipient)
continue;
_cartridge.SendNotification(pdaUid,
Loc.GetString("nano-chat-new-message-title", ("sender", senderName)),
Loc.GetString("nano-chat-new-message-body", ("message", TruncateMessage(message.Content))));
break;
}
}
// Update unread status
_nanoChat.SetRecipient((recipient, recipient.Comp),
message.SenderId,
existingRecipient with { HasUnread = true });
}
/// <summary>
/// Updates the UI for any PDAs containing the specified card.
/// </summary>
private void UpdateUIForCard(EntityUid cardUid)
{
// Find any PDA containing this card and update its UI
var query = EntityQueryEnumerator<NanoChatCartridgeComponent, CartridgeComponent>();
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);
}
}
/// <summary>
/// Gets the <see cref="NanoChatRecipient" /> for a given NanoChat number.
/// </summary>
private NanoChatRecipient? GetCardInfo(uint number)
{
// Find card with this number to get its info
var query = EntityQueryEnumerator<NanoChatCardComponent>();
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<IdCardComponent>(uid, out var idCard))
{
jobTitle = idCard.LocalizedJobTitle;
name = idCard.FullName ?? name;
}
return new NanoChatRecipient(number, name, jobTitle);
}
return null;
}
/// <summary>
/// Truncates a message to the notification maximum length.
/// </summary>
private static string TruncateMessage(string message)
{
return message.Length <= NotificationMaxLength
? message
: message[..(NotificationMaxLength - 4)] + " [...]";
}
private void OnUiReady(Entity<NanoChatCartridgeComponent> ent, ref CartridgeUiReadyEvent args)
{
_cartridge.RegisterBackgroundProgram(args.Loader, ent);
UpdateUI(ent, args.Loader);
}
private void UpdateUI(Entity<NanoChatCartridgeComponent> ent, EntityUid loader)
{
if (_station.GetOwningStation(loader) is { } station)
ent.Comp.Station = station;
var recipients = new Dictionary<uint, NanoChatRecipient>();
var messages = new Dictionary<uint, List<NanoChatMessage>>();
uint? currentChat = null;
uint ownNumber = 0;
var maxRecipients = 50;
var notificationsMuted = false;
if (ent.Comp.Card != null && TryComp<NanoChatCardComponent>(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;
}
var state = new NanoChatUiState(recipients,
messages,
currentChat,
ownNumber,
maxRecipients,
notificationsMuted);
_cartridge.UpdateCartridgeUiState(loader, state);
}
}