using System.Collections.Frozen; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Dialog; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Services; [Injectable] public class MailSendService( ISptLogger logger, TimeUtil timeUtil, SaveServer saveServer, DatabaseService databaseService, NotifierHelper notifierHelper, DialogueHelper dialogueHelper, NotificationSendHelper notificationSendHelper, ServerLocalisationService serverLocalisationService, ItemHelper itemHelper, ICloner cloner ) { private const string _systemSenderId = "59e7125688a45068a6249071"; protected readonly FrozenSet _messageTypes = [ MessageType.NpcTraderMessage, MessageType.FleamarketMessage, ]; protected readonly FrozenSet _slotNames = ["hideout", "main"]; /// /// Send a message from an NPC (e.g. prapor) to the player with or without items using direct message text, do not look up any locale /// /// The session ID to send the message to /// The trader sending the message /// What type the message will assume (e.g. QUEST_SUCCESS) /// Text to send to the player /// Optional items to send to player /// Optional time to collect items before they expire /// /// public void SendDirectNpcMessageToPlayer( MongoId sessionId, string? trader, MessageType messageType, string message, List? items, long? maxStorageTimeSeconds = 172800, SystemData? systemData = null, MessageContentRagfair? ragfair = null ) { if (trader is null) { logger.Error( serverLocalisationService.GetText( "mailsend-missing_trader", new { messageType, sessionId } ) ); return; } SendMessageDetails details = new() { RecipientId = sessionId, Sender = messageType, DialogType = MessageType.NpcTraderMessage, Trader = trader, MessageText = message, Items = [], }; // Add items to message if (items?.Count > 0) { details.Items.AddRange(items); details.ItemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds; } if (systemData is not null) { details.SystemData = systemData; } if (ragfair is not null) { details.RagfairDetails = ragfair; } SendMessageToPlayer(details); } /// /// Send a message from an NPC (e.g. prapor) to the player with or without items /// /// The session ID to send the message to /// The trader sending the message /// What type the message will assume (e.g. QUEST_SUCCESS) /// The localised text to send to player /// Optional items to send to player /// Optional time to collect items before they expire /// /// public void SendLocalisedNpcMessageToPlayer( MongoId sessionId, MongoId? trader, MessageType messageType, string messageLocaleId, IEnumerable? items, long? maxStorageTimeSeconds = 172800, SystemData? systemData = null, MessageContentRagfair? ragfair = null ) { if (trader is null) { logger.Error( serverLocalisationService.GetText( "mailsend-missing_trader", new { messageType, sessionId } ) ); return; } SendMessageDetails details = new() { RecipientId = sessionId, Sender = messageType, DialogType = MessageType.NpcTraderMessage, Trader = trader, TemplateId = messageLocaleId, Items = [], }; // Add items to message if (items is not null && items.Any()) { details.Items.AddRange(items); details.ItemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds > 0 ? maxStorageTimeSeconds : 172800; } if (systemData is not null) { details.SystemData = systemData; } if (ragfair is not null) { details.RagfairDetails = ragfair; } SendMessageToPlayer(details); } /// /// Send a message from SYSTEM to the player with or without items /// /// The session ID to send the message to /// The text to send to player /// Optional items to send to player /// Optional time to collect items before they expire /// public void SendSystemMessageToPlayer( MongoId sessionId, string message, List? items, long? maxStorageTimeSeconds = 172800, List? profileChangeEvents = null ) { SendMessageDetails details = new() { RecipientId = sessionId, Sender = MessageType.SystemMessage, MessageText = message, Items = [], }; // add items to message if (items?.Count > 0) { var rootItemParentId = new MongoId(); details.Items.AddRange(items.AdoptOrphanedItems(rootItemParentId)); details.ItemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds; } if ((profileChangeEvents?.Count ?? 0) > 0) { details.ProfileChangeEvents = profileChangeEvents; } SendMessageToPlayer(details); } /// /// Send a message from SYSTEM to the player with or without items with localised text /// /// The session ID to send the message to /// Id of key from locale file to send to player /// Optional items to send to player /// /// Optional time to collect items before they expire public void SendLocalisedSystemMessageToPlayer( MongoId sessionId, string messageLocaleId, List? items, List? profileChangeEvents, long? maxStorageTimeSeconds = 172800 ) { SendMessageDetails details = new() { RecipientId = sessionId, Sender = MessageType.SystemMessage, TemplateId = messageLocaleId, Items = [], }; // add items to message if (items?.Count > 0) { details.Items.AddRange(items); details.ItemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds; } if ((profileChangeEvents?.Count ?? 0) > 0) { details.ProfileChangeEvents = profileChangeEvents; } SendMessageToPlayer(details); } /// /// Send a USER message to a player with or without items /// /// The session ID to send the message to /// Who is sending the message /// The text to send to player /// Optional items to send to player /// Optional time to collect items before they expire public void SendUserMessageToPlayer( MongoId sessionId, UserDialogInfo senderDetails, string message, List? items = null, long? maxStorageTimeSeconds = 172800 ) { SendMessageDetails details = new() { RecipientId = sessionId, Sender = MessageType.UserMessage, SenderDetails = senderDetails, MessageText = message, Items = [], }; // add items to message if (items?.Count > 0) { details.Items.AddRange(items); details.ItemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds; } SendMessageToPlayer(details); } /// /// Large function to send messages to players from a variety of sources (SYSTEM/NPC/USER). /// Helper functions in this class are available to simplify common actions /// /// Details needed to send a message to the player public void SendMessageToPlayer(SendMessageDetails messageDetails) { // Get dialog, create if doesn't exist var senderDialog = GetDialog(messageDetails); // Flag dialog as containing a new message to player senderDialog.New++; // Craft message var message = CreateDialogMessage(senderDialog.Id, messageDetails); // Create items array // Generate item stash if we have rewards. var itemsToSendToPlayer = ProcessItemsBeforeAddingToMail(senderDialog.Type, messageDetails); // If there's items to send to player, flag dialog as containing attachments if ((itemsToSendToPlayer.Data?.Count ?? 0) > 0) { senderDialog.AttachmentsNew += 1; } // Store reward items inside message and set appropriate flags inside message AddRewardItemsToMessage( message, itemsToSendToPlayer, messageDetails.ItemsMaxStorageLifetimeSeconds ); if (messageDetails.ProfileChangeEvents is not null) { message.ProfileChangeEvents = messageDetails.ProfileChangeEvents; } // Add message to dialog senderDialog.Messages.Add(message); // TODO: clean up old code here // Offer Sold notifications are now separate from the main notification if ( _messageTypes.Contains(senderDialog.Type ?? MessageType.SystemMessage) && messageDetails?.RagfairDetails is not null ) { var offerSoldMessage = notifierHelper.CreateRagfairOfferSoldNotification( message, messageDetails.RagfairDetails ); notificationSendHelper.SendMessage(messageDetails.RecipientId, offerSoldMessage); message.MessageType = MessageType.MessageWithItems; // Should prevent getting the same notification popup twice } // Send notification to player informing them of mail delivery var notificationMessage = notifierHelper.CreateNewMessageNotification(message); notificationSendHelper.SendMessage(messageDetails.RecipientId, notificationMessage); } /// /// Send a message from the player to an NPC /// /// Session ID /// NPC message is sent to /// Text to send to NPC public void SendPlayerMessageToNpc(MongoId sessionId, string targetNpcId, string message) { var playerProfile = saveServer.GetProfile(sessionId); if ( playerProfile.DialogueRecords is null || !playerProfile.DialogueRecords.TryGetValue(targetNpcId, out var dialogWithNpc) ) { logger.Error( serverLocalisationService.GetText("mailsend-missing_npc_dialog", targetNpcId) ); return; } dialogWithNpc.Messages.Add( new Message { Id = new MongoId(), DateTime = timeUtil.GetTimeStamp(), HasRewards = false, UserId = playerProfile.CharacterData.PmcData.Id.Value, MessageType = MessageType.UserMessage, RewardCollected = false, Text = message, } ); } /// /// Create a message for storage inside a dialog in the player profile /// /// ID of dialog that will hold the message /// Various details on what the message must contain/do /// Message private Message CreateDialogMessage(MongoId dialogId, SendMessageDetails messageDetails) { Message message = new() { Id = new MongoId(), UserId = dialogId, MessageType = messageDetails.Sender, DateTime = timeUtil.GetTimeStamp(), Text = messageDetails.TemplateId is not null ? "" : messageDetails.MessageText, TemplateId = messageDetails.TemplateId, HasRewards = false, RewardCollected = false, SystemData = messageDetails.SystemData, ProfileChangeEvents = messageDetails.ProfileChangeEvents?.Count == 0 ? messageDetails.ProfileChangeEvents : null, }; // Handle replyTo if (messageDetails.ReplyTo is not null) { var replyMessage = GetMessageToReplyTo( messageDetails.RecipientId, messageDetails.ReplyTo, dialogId ); if (replyMessage is not null) { message.ReplyTo = replyMessage; } } return message; } /// /// Finds the Message to reply to using the ID of the recipient, message and the dialogue. /// /// The ID of the recipient /// The ID of the message to reply to /// The ID of the dialogue (traderId or profileId) /// A new instance with data from the found message, otherwise undefined protected ReplyTo? GetMessageToReplyTo(MongoId recipientId, string replyToId, string dialogueId) { var currentDialogue = dialogueHelper.GetDialogueFromProfile(recipientId, dialogueId); if (currentDialogue is null) { logger.Warning($"Unable to find dialogue: {dialogueId} from sender"); return null; } var messageToReplyTo = currentDialogue.Messages?.FirstOrDefault(message => message.Id == replyToId ); if (messageToReplyTo is null) { return null; } return new ReplyTo { Id = messageToReplyTo.Id, DateTime = messageToReplyTo.DateTime, MessageType = messageToReplyTo.MessageType, UserId = messageToReplyTo.UserId, Text = messageToReplyTo.Text, }; } /// /// Add items to message and adjust various properties to reflect the items being added /// /// Message to add items to /// Items to add to message /// Total time the items are stored in mail before being deleted private void AddRewardItemsToMessage( Message message, MessageItems? itemsToSendToPlayer, long? maxStorageTimeSeconds ) { if ((itemsToSendToPlayer?.Data?.Count ?? 0) > 0) { message.Items = itemsToSendToPlayer; message.HasRewards = true; message.MaxStorageTime = maxStorageTimeSeconds ?? 172800; message.RewardCollected = false; } } /// /// Perform various sanitising actions on the items before they're considered ready for insertion into message /// /// The type of the dialog that will hold the reward items being processed /// Details fo the message e.g. Text, items it has etc. /// Sanitised items private MessageItems ProcessItemsBeforeAddingToMail( MessageType? dialogType, SendMessageDetails messageDetails ) { var items = databaseService.GetItems(); MessageItems itemsToSendToPlayer = new(); if ((messageDetails.Items?.Count ?? 0) > 0) { // Find base item that should be the 'primary' + have its parent id be used as the dialogs 'stash' value var parentItem = GetBaseItemFromRewards(messageDetails.Items); if (parentItem is null) { serverLocalisationService.GetText( "mailsend-missing_parent", new { traderId = messageDetails.Trader, sender = messageDetails.Sender } ); return itemsToSendToPlayer; } // No parent id, generate random id and add (doesn't need to be actual parentId from db, only unique) if (parentItem?.ParentId is null) { parentItem.ParentId = new MongoId(); } // Prep return object itemsToSendToPlayer = new MessageItems { Stash = new MongoId(parentItem.ParentId), Data = [], }; // Ensure Ids are unique and cont collide with items in player inventory later messageDetails.Items = cloner.Clone(messageDetails.Items).ReplaceIDs().ToList(); // Ensure item exits in items db foreach (var reward in messageDetails.Items) { if (!items.TryGetValue(reward.Template, out var itemTemplate)) { logger.Error( serverLocalisationService.GetText( "dialog-missing_item_template", new { tpl = reward.Template, type = dialogType } ) ); continue; } // Ensure every 'base/root' item has the same parentId + has a slotId of 'main' if ( reward.SlotId is null || reward.SlotId == "hideout" || reward.ParentId == parentItem.ParentId ) { // Reward items NEED a parent id + slotId reward.ParentId = parentItem.ParentId; reward.SlotId = "main"; } // Boxes can contain sub-items if (itemHelper.IsOfBaseclass(itemTemplate.Id, BaseClasses.AMMO_BOX)) { // look for child cartridge objects var childItems = messageDetails.Items?.Where(x => x.ParentId == reward.Id); if (childItems is null || !childItems.Any()) { // No cartridges found, generate and add to rewards var boxAndCartridges = new List { reward }; itemHelper.AddCartridgesToAmmoBox(boxAndCartridges, itemTemplate); // Push box + cartridge children into array itemsToSendToPlayer.Data.AddRange(boxAndCartridges); continue; } // Ammo box reward already has ammo, don't do anything extra itemsToSendToPlayer.Data.Add(reward); } else { if (itemTemplate.Properties.StackSlots is not null) { logger.Error( serverLocalisationService.GetText( "mail-unable_to_give_gift_not_handled", itemTemplate.Id ) ); } // Item is sanitised and ready to be pushed into holding array itemsToSendToPlayer.Data.Add(reward); } } // Remove empty data property if no rewards // if (itemsToSendToPlayer.Data.Count == 0) // delete itemsToSendToPlayer.data; } return itemsToSendToPlayer; } /// /// Try to find the most correct item to be the 'primary' item in a reward mail /// /// Possible items to choose from /// Chosen 'primary' item protected Item? GetBaseItemFromRewards(List items) { // Only one item in reward, return it if (items?.Count == 1) { return items[0]; } // Find first item with slotId that indicates it's a 'base' item var item = items.FirstOrDefault(x => _slotNames.Contains(x.SlotId ?? "")); if (item is not null) { return item; } // Not a singular item + no items have a hideout/main slotId // Look for first item without parent id item = items.FirstOrDefault(x => x.ParentId is null); if (item is not null) { return item; } // Just return first item in array return items.FirstOrDefault(); } /// /// Get a dialog with a specified entity (user/trader). /// Create and store empty dialog if none exists in profile. /// /// Data on what message should do /// Relevant Dialogue object /// Thrown when message not found private Dialogue GetDialog(SendMessageDetails messageDetails) { var senderId = GetMessageSenderIdByType(messageDetails); if (senderId is null) { throw new Exception( serverLocalisationService.GetText( "mail-unable_to_find_message_sender_by_id", messageDetails.Sender ) ); } var dialogsInProfile = dialogueHelper.GetDialogsForProfile(messageDetails.RecipientId); // Does dialog exist if (!dialogsInProfile.ContainsKey(senderId)) // Doesn't exist, create { dialogsInProfile[senderId] = new Dialogue { Id = senderId, Type = messageDetails.DialogType ?? messageDetails.Sender, Messages = [], Pinned = false, New = 0, AttachmentsNew = 0, }; } return dialogsInProfile[senderId]; } /// /// Get the appropriate sender id by the sender enum type /// /// Data of the message /// Gets an id of the individual sending it private string? GetMessageSenderIdByType(SendMessageDetails messageDetails) { if (messageDetails.Sender == MessageType.SystemMessage) { return _systemSenderId; } if ( messageDetails.Sender == MessageType.NpcTraderMessage || messageDetails.DialogType == MessageType.NpcTraderMessage ) { if (messageDetails.Trader == null && logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Trader was null for {messageDetails.TemplateId}"); } return messageDetails.Trader; } if (messageDetails.Sender == MessageType.UserMessage) { return messageDetails.SenderDetails?.Id; } if (messageDetails.SenderDetails?.Id is not null) { return messageDetails.SenderDetails.Id; } if (messageDetails.Trader is not null) { return messageDetails.Trader; } logger.Warning($"Unable to handle message of type: {messageDetails.Sender}"); return null; } }