Files
SPT-Server-Build/Libraries/SPTarkov.Server.Core/Services/MailSendService.cs
T
Chomp 040be2feaa More strings to MongoIds
Convert constructors into primary constructors

Simplified logic with use of ??, ??= and method groups

Cleaned up redundant conditional access qualifiers
2025-07-14 22:29:41 +01:00

710 lines
25 KiB
C#

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<MailSendService> 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<MessageType> _messageTypes =
[
MessageType.NpcTraderMessage,
MessageType.FleamarketMessage,
];
protected readonly FrozenSet<string> _slotNames = ["hideout", "main"];
/// <summary>
/// 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
/// </summary>
/// <param name="sessionId"> The session ID to send the message to </param>
/// <param name="trader"> The trader sending the message </param>
/// <param name="messageType"> What type the message will assume (e.g. QUEST_SUCCESS) </param>
/// <param name="message"> Text to send to the player </param>
/// <param name="items"> Optional items to send to player </param>
/// <param name="maxStorageTimeSeconds"> Optional time to collect items before they expire </param>
/// <param name="systemData"> </param>
/// <param name="ragfair"> </param>
public void SendDirectNpcMessageToPlayer(
MongoId sessionId,
string? trader,
MessageType messageType,
string message,
List<Item>? 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);
}
/// <summary>
/// Send a message from an NPC (e.g. prapor) to the player with or without items
/// </summary>
/// <param name="sessionId"> The session ID to send the message to </param>
/// <param name="trader"> The trader sending the message </param>
/// <param name="messageType"> What type the message will assume (e.g. QUEST_SUCCESS) </param>
/// <param name="messageLocaleId"> The localised text to send to player </param>
/// <param name="items"> Optional items to send to player </param>
/// <param name="maxStorageTimeSeconds"> Optional time to collect items before they expire </param>
/// <param name="systemData"></param>
/// <param name="ragfair"></param>
public void SendLocalisedNpcMessageToPlayer(
MongoId sessionId,
MongoId? trader,
MessageType messageType,
string messageLocaleId,
List<Item>? 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?.Count > 0)
{
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);
}
/// <summary>
/// Send a message from SYSTEM to the player with or without items
/// </summary>
/// <param name="sessionId"> The session ID to send the message to </param>
/// <param name="message"> The text to send to player </param>
/// <param name="items"> Optional items to send to player </param>
/// <param name="maxStorageTimeSeconds"> Optional time to collect items before they expire </param>
/// <param name="profileChangeEvents"></param>
public void SendSystemMessageToPlayer(
MongoId sessionId,
string message,
List<Item>? items,
long? maxStorageTimeSeconds = 172800,
List<ProfileChangeEvent>? 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);
}
/// <summary>
/// Send a message from SYSTEM to the player with or without items with localised text
/// </summary>
/// <param name="sessionId"> The session ID to send the message to </param>
/// <param name="messageLocaleId"> Id of key from locale file to send to player </param>
/// <param name="items"> Optional items to send to player </param>
/// <param name="profileChangeEvents"></param>
/// <param name="maxStorageTimeSeconds"> Optional time to collect items before they expire </param>
public void SendLocalisedSystemMessageToPlayer(
MongoId sessionId,
string messageLocaleId,
List<Item>? items,
List<ProfileChangeEvent>? 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);
}
/// <summary>
/// Send a USER message to a player with or without items
/// </summary>
/// <param name="sessionId"> The session ID to send the message to </param>
/// <param name="senderDetails"> Who is sending the message </param>
/// <param name="message"> The text to send to player </param>
/// <param name="items"> Optional items to send to player </param>
/// <param name="maxStorageTimeSeconds"> Optional time to collect items before they expire </param>
public void SendUserMessageToPlayer(
MongoId sessionId,
UserDialogInfo senderDetails,
string message,
List<Item>? 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);
}
/// <summary>
/// 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
/// </summary>
/// <param name="messageDetails"> Details needed to send a message to the player </param>
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);
}
/// <summary>
/// Send a message from the player to an NPC
/// </summary>
/// <param name="sessionId"> Session ID </param>
/// <param name="targetNpcId"> NPC message is sent to </param>
/// <param name="message"> Text to send to NPC </param>
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,
}
);
}
/// <summary>
/// Create a message for storage inside a dialog in the player profile
/// </summary>
/// <param name="dialogId"> ID of dialog that will hold the message </param>
/// <param name="messageDetails"> Various details on what the message must contain/do </param>
/// <returns> Message </returns>
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;
}
/// <summary>
/// Finds the Message to reply to using the ID of the recipient, message and the dialogue.
/// </summary>
/// <param name="recipientId"> The ID of the recipient </param>
/// <param name="replyToId"> The ID of the message to reply to </param>
/// <param name="dialogueId"> The ID of the dialogue (traderId or profileId) </param>
/// <returns> A new instance with data from the found message, otherwise undefined </returns>
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,
};
}
/// <summary>
/// Add items to message and adjust various properties to reflect the items being added
/// </summary>
/// <param name="message"> Message to add items to </param>
/// <param name="itemsToSendToPlayer"> Items to add to message </param>
/// <param name="maxStorageTimeSeconds"> Total time the items are stored in mail before being deleted </param>
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;
}
}
/// <summary>
/// Perform various sanitising actions on the items before they're considered ready for insertion into message
/// </summary>
/// <param name="dialogType"> The type of the dialog that will hold the reward items being processed </param>
/// <param name="messageDetails"> Details fo the message e.g. Text, items it has etc. </param>
/// <returns> Sanitised items </returns>
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<Item> { 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;
}
/// <summary>
/// Try to find the most correct item to be the 'primary' item in a reward mail
/// </summary>
/// <param name="items"> Possible items to choose from </param>
/// <returns> Chosen 'primary' item </returns>
protected Item? GetBaseItemFromRewards(List<Item> 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();
}
/// <summary>
/// Get a dialog with a specified entity (user/trader).
/// Create and store empty dialog if none exists in profile.
/// </summary>
/// <param name="messageDetails"> Data on what message should do </param>
/// <returns> Relevant Dialogue object </returns>
/// <exception cref="Exception"> Thrown when message not found </exception>
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];
}
/// <summary>
/// Get the appropriate sender id by the sender enum type
/// </summary>
/// <param name="messageDetails"> Data of the message </param>
/// <returns> Gets an id of the individual sending it </returns>
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;
}
}