using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Helpers.Dialogue; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Dialog; using SPTarkov.Server.Core.Models.Eft.Profile; using SPTarkov.Server.Core.Models.Eft.Ws; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; namespace SPTarkov.Server.Core.Controllers; [Injectable] public class DialogueController( ISptLogger logger, TimeUtil timeUtil, DialogueHelper dialogueHelper, NotificationSendHelper notificationSendHelper, ProfileHelper profileHelper, ConfigServer configServer, SaveServer saveServer, ServerLocalisationService serverLocalisationService, MailSendService mailSendService, IEnumerable dialogueChatBots ) { protected readonly CoreConfig _coreConfig = configServer.GetConfig(); protected readonly List _dialogueChatBots = dialogueChatBots.ToList(); /// /// /// public virtual void RegisterChatBot(IDialogueChatBot chatBot) // TODO: this is in with the helper types { if (_dialogueChatBots.Any(cb => cb.GetChatBot().Id == chatBot.GetChatBot().Id)) { logger.Error(serverLocalisationService.GetText("dialog-chatbot_id_already_exists", chatBot.GetChatBot().Id)); } _dialogueChatBots.Add(chatBot); } /// /// Handle onUpdate spt event /// public void Update() { var profiles = saveServer.GetProfiles(); foreach (var (sessionId, _) in profiles) { if (saveServer.IsProfileInvalidOrUnloadable(sessionId)) { continue; } RemoveExpiredItemsFromMessages(sessionId); } } /// /// Handle client/friend/list /// /// session id /// GetFriendListDataResponse public virtual GetFriendListDataResponse GetFriendList(MongoId sessionId) { // Add all chatbots to the friends list var friends = GetActiveChatBots(); // Add any friends the user has after the chatbots var profile = profileHelper.GetFullProfile(sessionId); if (profile.FriendProfileIds is null) { return new GetFriendListDataResponse { Friends = friends, Ignore = [], InIgnoreList = [], }; } foreach (var friendId in profile.FriendProfileIds) { var friendProfile = profileHelper.GetChatRoomMemberFromSessionId(friendId); if (friendProfile is not null) { friends.Add( new UserDialogInfo { Id = friendProfile.Id, Aid = friendProfile.Aid, Info = friendProfile.Info, } ); } } return new GetFriendListDataResponse { Friends = friends, Ignore = [], InIgnoreList = [], }; } /// /// Get all active chatbots /// /// Active chatbots public List GetActiveChatBots() { var activeBots = new List(); var chatBotConfig = _coreConfig.Features.ChatbotFeatures; foreach (var bot in _dialogueChatBots) { var botData = bot.GetChatBot(); if (chatBotConfig.EnabledBots.ContainsKey(botData.Id)) { activeBots.Add(botData); } } return activeBots; } /// /// Handle client/mail/dialog/list /// Create array holding trader dialogs and mail interactions with player /// Set the content of the dialogue on the list tab. /// /// Session Id /// list of dialogs public virtual List GenerateDialogueList(MongoId sessionId) { var data = new List(); foreach (var (_, dialog) in dialogueHelper.GetDialogsForProfile(sessionId)) { var dialogueInfo = GetDialogueInfo(dialog, sessionId); if (dialogueInfo is null) { continue; } data.Add(dialogueInfo); } return data; } /// /// Get the content of a dialogue /// /// Dialog id /// Session Id /// DialogueInfo public virtual DialogueInfo? GetDialogueInfo(MongoId dialogueId, MongoId sessionId) { var dialogs = dialogueHelper.GetDialogsForProfile(sessionId); var dialogue = dialogs.GetValueOrDefault(dialogueId); return GetDialogueInfo(dialogue, sessionId); } /// /// Get the content of a dialogue /// /// Dialog /// Session Id /// DialogueInfo public virtual DialogueInfo? GetDialogueInfo(Dialogue? dialogue, MongoId sessionId) { if (dialogue is null || dialogue.Messages?.Count == 0) { return null; } var result = new DialogueInfo { Id = dialogue.Id, Type = dialogue.Type ?? MessageType.NpcTraderMessage, Message = dialogueHelper.GetMessagePreview(dialogue), New = dialogue?.New, AttachmentsNew = dialogue?.AttachmentsNew, Pinned = dialogue?.Pinned, Users = GetDialogueUsers(dialogue, dialogue?.Type, sessionId), }; return result; } /// /// Get the users involved in a dialog (player + other party) /// /// The dialog to check for users /// What type of message is being sent /// Player id /// UserDialogInfo list public virtual List GetDialogueUsers(Dialogue? dialog, MessageType? messageType, MongoId sessionId) { var profile = saveServer.GetProfile(sessionId); // User to user messages are special in that they need the player to exist in them, add if they don't if ( messageType == MessageType.UserMessage && dialog?.Users is not null && dialog.Users.All(userDialog => userDialog.Id != profile.CharacterData?.PmcData?.SessionId) ) { dialog.Users.Add( new UserDialogInfo { Id = profile.CharacterData.PmcData.SessionId.Value, Aid = profile.CharacterData?.PmcData?.Aid, Info = new UserDialogDetails { Level = profile.CharacterData?.PmcData?.Info?.Level, Nickname = profile.CharacterData?.PmcData?.Info?.Nickname, Side = profile.CharacterData?.PmcData?.Info?.Side, MemberCategory = profile.CharacterData?.PmcData?.Info?.MemberCategory, SelectedMemberCategory = profile.CharacterData?.PmcData?.Info?.SelectedMemberCategory, }, } ); } return dialog?.Users!; } /// /// Handle client/mail/dialog/view /// Handle player clicking 'messenger' and seeing all the messages they've received /// Set the content of the dialogue on the details panel, showing all the messages /// for the specified dialogue. /// /// Get dialog request /// Session id /// GetMailDialogViewResponseData object public virtual GetMailDialogViewResponseData GenerateDialogueView(GetMailDialogViewRequestData request, MongoId sessionId) { var dialogueId = request.DialogId; var fullProfile = saveServer.GetProfile(sessionId); var dialogue = GetDialogByIdFromProfile(fullProfile, request); if (dialogue.Messages?.Count == 0) { return new GetMailDialogViewResponseData { Messages = [], Profiles = [], HasMessagesWithRewards = false, }; } // Dialog was opened, remove the little [1] on screen dialogue.New = 0; // Set number of new attachments, but ignore those that have expired. dialogue.AttachmentsNew = GetUnreadMessagesWithAttachmentsCount(sessionId, dialogueId); return new GetMailDialogViewResponseData { Messages = dialogue.Messages, Profiles = GetProfilesForMail(fullProfile, dialogue.Users), HasMessagesWithRewards = MessagesHaveUncollectedRewards(dialogue.Messages!), }; } /// /// Get dialog from player profile, create if doesn't exist /// /// Player profile /// get dialog request /// Dialogue protected Dialogue GetDialogByIdFromProfile(SptProfile profile, GetMailDialogViewRequestData request) { if (profile.DialogueRecords is null || profile.DialogueRecords.ContainsKey(request.DialogId)) { return profile.DialogueRecords?[request.DialogId] ?? throw new NullReferenceException(); } profile.DialogueRecords[request.DialogId] = new Dialogue { Id = request.DialogId, AttachmentsNew = 0, Pinned = false, Messages = [], New = 0, Type = request.Type, }; if (request.Type != MessageType.UserMessage) { return profile.DialogueRecords[request.DialogId]; } var dialogue = profile.DialogueRecords[request.DialogId]; dialogue.Users = []; var chatBot = _dialogueChatBots.FirstOrDefault(cb => cb.GetChatBot().Id == request.DialogId); if (chatBot is null) { return profile.DialogueRecords[request.DialogId]; } dialogue.Users ??= []; dialogue.Users.Add(chatBot.GetChatBot()); return profile.DialogueRecords[request.DialogId]; } /// /// Get the users involved in a mail between two entities /// /// Player profile /// The participants of the mail /// UserDialogInfo list protected List GetProfilesForMail(SptProfile fullProfile, List? userDialogs) { List result = []; if (userDialogs is null) // Nothing to add { return result; } result.AddRange(userDialogs); if (result.Any(userDialog => userDialog.Id == fullProfile.ProfileInfo?.ProfileId)) { return result; } // Player doesn't exist, add them in before returning var pmcProfile = fullProfile.CharacterData?.PmcData; result.Add( new UserDialogInfo { Id = fullProfile.ProfileInfo.ProfileId.Value, Aid = fullProfile.ProfileInfo?.Aid, Info = new UserDialogDetails { Nickname = pmcProfile?.Info?.Nickname, Side = pmcProfile?.Info?.Side, Level = pmcProfile?.Info?.Level, MemberCategory = pmcProfile?.Info?.MemberCategory, SelectedMemberCategory = pmcProfile?.Info?.SelectedMemberCategory, }, } ); return result; } /// /// Get a count of messages with attachments from a particular dialog /// /// Session id /// Dialog id /// Count of messages with attachments protected int GetUnreadMessagesWithAttachmentsCount(MongoId sessionId, MongoId dialogueId) { var newAttachmentCount = 0; var activeMessages = GetActiveMessagesFromDialog(sessionId, dialogueId); foreach (var message in activeMessages) { if (message.HasRewards.GetValueOrDefault(false) && !message.RewardCollected.GetValueOrDefault(false)) { newAttachmentCount++; } } return newAttachmentCount; } /// /// Get messages from a specific dialog that have items not expired /// /// Session/Player id /// Dialog to get mail attachments from /// Message array protected List GetActiveMessagesFromDialog(MongoId sessionId, MongoId dialogueId) { var timeNow = timeUtil.GetTimeStamp(); var dialogs = dialogueHelper.GetDialogsForProfile(sessionId); return dialogs[dialogueId] .Messages?.Where(message => { var checkTime = message.DateTime + (message.MaxStorageTime ?? 0); return timeNow < checkTime; }) .ToList() ?? []; } /// /// Does list have messages with uncollected rewards (includes expired rewards) /// /// Messages to check /// true if uncollected rewards found protected bool MessagesHaveUncollectedRewards(List messages) { return messages.Any(message => (message.Items?.Data?.Count ?? 0) > 0); } /// /// Handle client/mail/dialog/remove /// Remove an entire dialog with an entity (trader/user) /// /// id of the dialog to remove /// Player id public virtual void RemoveDialogue(MongoId dialogueId, MongoId sessionId) { var profile = saveServer.GetProfile(sessionId); if (!profile.DialogueRecords?.Remove(dialogueId) ?? false) { logger.Error(serverLocalisationService.GetText("dialogue-unable_to_find_in_profile", new { sessionId, dialogueId })); } } /// /// Handle client/mail/dialog/pin && Handle client/mail/dialog/unpin /// /// /// /// Session/Player id public virtual void SetDialoguePin(MongoId dialogueId, bool shouldPin, MongoId sessionId) { var dialog = dialogueHelper.GetDialogsForProfile(sessionId).GetValueOrDefault(dialogueId); if (dialog is null) { logger.Error(serverLocalisationService.GetText("dialogue-unable_to_find_in_profile", new { sessionId, dialogueId })); return; } dialog.Pinned = shouldPin; } /// /// Handle client/mail/dialog/read /// Set a dialog to be read (no number alert/attachment alert) /// /// Dialog ids to set as read /// Player profile id public virtual void SetRead(List? dialogueIds, MongoId sessionId) { if (dialogueIds is null) { logger.Error(serverLocalisationService.GetText("dialogue-list_from_client_empty", new { sessionId })); return; } var dialogs = dialogueHelper.GetDialogsForProfile(sessionId); if (dialogs.Count == 0) { logger.Error(serverLocalisationService.GetText("dialogue-unable_to_find_dialogs_in_profile", new { sessionId })); return; } foreach (var dialogId in dialogueIds) { dialogs[dialogId].New = 0; dialogs[dialogId].AttachmentsNew = 0; } } /// /// Handle client/mail/dialog/getAllAttachments /// Get all uncollected items attached to mail in a particular dialog /// /// Dialog to get mail attachments from /// Session id /// GetAllAttachmentsResponse or null if dialogue doesn't exist public virtual GetAllAttachmentsResponse? GetAllAttachments(string dialogueId, MongoId sessionId) { var dialogs = dialogueHelper.GetDialogsForProfile(sessionId); var dialog = dialogs.TryGetValue(dialogueId, out var dialogInfo); if (!dialog) { logger.Error(serverLocalisationService.GetText("dialogue-unable_to_find_in_profile")); return null; } // Removes corner 'new messages' tag dialogInfo!.AttachmentsNew = 0; var activeMessages = GetActiveMessagesFromDialog(sessionId, dialogueId); var messagesWithAttachments = GetMessageWithAttachments(activeMessages); return new GetAllAttachmentsResponse { Messages = messagesWithAttachments, Profiles = [], HasMessagesWithRewards = MessagesHaveUncollectedRewards(messagesWithAttachments), }; } /// /// handle client/mail/msg/send /// /// Session/Player id /// /// public virtual async ValueTask SendMessage(MongoId sessionId, SendMessageRequest request) { mailSendService.SendPlayerMessageToNpc(sessionId, request.DialogId, request.Text); var chatBot = _dialogueChatBots.FirstOrDefault(cb => cb.GetChatBot().Id == request.DialogId); if (chatBot is not null) { return await chatBot.HandleMessage(sessionId, request); } else { return string.Empty; } } /// /// Return list of messages with uncollected items (includes expired) /// /// Messages to parse /// messages with items to collect protected List GetMessageWithAttachments(List messages) { return messages.Where(message => (message.Items?.Data?.Count ?? 0) > 0).ToList(); } /// /// Delete expired items from all messages in player profile. triggers when updating traders. /// /// Session id protected void RemoveExpiredItemsFromMessages(MongoId sessionId) { foreach (var (dialogId, _) in dialogueHelper.GetDialogsForProfile(sessionId)) { RemoveExpiredItemsFromMessage(sessionId, dialogId); } } /// /// Removes expired items from a message in player profile /// /// Session id /// Dialog id protected void RemoveExpiredItemsFromMessage(MongoId sessionId, MongoId dialogueId) { var dialogs = dialogueHelper.GetDialogsForProfile(sessionId); if (!dialogs.TryGetValue(dialogueId, out var dialog)) { return; } if (dialog.Messages is null) { return; } foreach (var message in dialog.Messages.Where(MessageHasExpired)) { // Reset expired message items data message.Items = new(); } } /// /// Has a dialog message expired /// /// Message to check expiry of /// True = expired protected bool MessageHasExpired(Message message) { return timeUtil.GetTimeStamp() > message.DateTime + (message.MaxStorageTime ?? 0); } /// /// Handle client/friend/request/send /// /// Session/player id /// Sent friend request /// public virtual FriendRequestSendResponse SendFriendRequest(MongoId sessionID, FriendRequestData request) { // To avoid needing to jump between profiles, auto-accept all friend requests var friendProfile = profileHelper.GetFullProfile(request.To.Value); if (friendProfile?.CharacterData?.PmcData is null) { return new FriendRequestSendResponse { Status = BackendErrorCodes.PlayerProfileNotFound, RequestId = "", // Unused in an error state RetryAfter = 600, }; } // Only add the profile to the friends list if it doesn't already exist var profile = saveServer.GetProfile(sessionID); profile.FriendProfileIds.Add(request.To.Value); // We need to delay this so that the friend request gets properly added to the clientside list before we accept it _ = new Timer( _ => { var notification = new WsFriendsListAccept { EventType = NotificationEventType.friendListRequestAccept, Profile = profileHelper.GetChatRoomMemberFromPmcProfile(friendProfile.CharacterData.PmcData), }; notificationSendHelper.SendMessage(sessionID, notification); }, null, TimeSpan.FromMicroseconds(1000), Timeout.InfiniteTimeSpan // This should mean it does this callback once after 1 second and then stops ); return new FriendRequestSendResponse { Status = BackendErrorCodes.None, RequestId = friendProfile.ProfileInfo.Aid.ToString(), RetryAfter = 600, }; } /// /// Handle client/friend/delete /// /// Session/player id /// Sent delete friend request public virtual void DeleteFriend(MongoId sessionID, DeleteFriendRequest request) { var profile = saveServer.GetProfile(sessionID); profile?.FriendProfileIds?.Remove(request.FriendId); } /// /// Clear messages from a specified dialogue /// /// Session/Player id /// Client request to clear messages public void ClearMessages(MongoId sessionId, ClearMailMessageRequest request) { var profile = saveServer.GetProfile(sessionId); if (profile.DialogueRecords is null || !profile.DialogueRecords.TryGetValue(request.DialogId, out var dialogToClear)) { logger.Warning($"unable to clear messages from dialog: {request.DialogId} as it cannot be found in profile: {sessionId}"); return; } dialogToClear.Messages?.Clear(); } }