was added firebase nitifying

This commit is contained in:
Artemy
2026-03-01 16:48:06 +07:00
parent 76d7280c71
commit eab0d746b8
60 changed files with 2335 additions and 1149 deletions
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Hosting" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" />
@@ -0,0 +1,8 @@
namespace Govor.Application.Interfaces;
public interface IConnectionStore
{
void AddConnection(Guid userId, string connectionId);
void RemoveConnection(Guid userId, string connectionId);
IEnumerable<string> GetConnections(Guid userId);
}
@@ -0,0 +1,9 @@
using Govor.Core.Models;
namespace Govor.Application.Interfaces;
public interface IPrivateChatGroupManager
{
Task AddUsersToPrivateChatGroupAsync(PrivateChat privateChat);
Task RemoveUsersFromPrivateChatGroupAsync(PrivateChat privateChat);
}
@@ -0,0 +1,8 @@
using Govor.Core.Models;
namespace Govor.Application.Interfaces;
public interface IUserPrivateChatsCreator
{
Task<PrivateChat> CreateAsync(Guid userIdA, Guid userIdB);
}
@@ -0,0 +1,14 @@
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Interfaces.PushNotifications;
public interface IPushNotificationProvider
{
string Name { get; }
Task<SendPushResult> SendToTokenAsync(string token, PushMessage message);
Task<SendPushResult> SendMulticastAsync(
IReadOnlyList<string> tokens,
PushMessage message);
}
@@ -0,0 +1,10 @@
namespace Govor.Application.Interfaces.PushNotifications;
public interface IPushNotificationService
{
Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary<string, string>? data = null);
Task SendToUsersAsync(IEnumerable<Guid> userIds, string title, string body, string channelId, Dictionary<string, string>? data = null);
Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary<string, string>? data = null);
}
@@ -0,0 +1,9 @@
namespace Govor.Application.Interfaces.PushNotifications.Models;
public class PushMessage
{
public string Title { get; set; } = string.Empty;
public string Body { get; set; } = string.Empty;
public Dictionary<string, string> Data { get; set; } = new();
public string? ChannelId { get; set; } = "chat_messages"; //for Android
}
@@ -0,0 +1,3 @@
namespace Govor.Application.Interfaces.PushNotifications.Models;
public record SendPushResult(int SuccessCount, int FailureCount, IEnumerable<string> FailedTokens);
@@ -1,4 +1,5 @@
using Govor.Application.Exceptions.FriendsService;
using Govor.Application.Interfaces;
using Govor.Application.Interfaces.Friends;
using Govor.Core.Models;
using Govor.Core.Repositories.Friendships;
@@ -9,10 +10,13 @@ namespace Govor.Application.Services.Friends;
public class FriendRequestCommandService : IFriendRequestCommandService
{
private readonly IFriendshipsRepository _friendshipsRepository;
public FriendRequestCommandService(IFriendshipsRepository friendshipsRepository)
private readonly IUserPrivateChatsCreator _privateChatsCreator;
public FriendRequestCommandService(
IFriendshipsRepository friendshipsRepository,
IUserPrivateChatsCreator privateChatsCreator)
{
_friendshipsRepository = friendshipsRepository;
_privateChatsCreator = privateChatsCreator;
}
public async Task<Friendship> SendAsync(Guid fromUserId, Guid toUserId)
@@ -56,7 +60,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService
try
{
var friendship = await _friendshipsRepository.GetByIdAsync(requestId);
if (friendship.AddresseeId != currentUserId)
throw new UnauthorizedAccessException("You cannot accept this request");
@@ -66,6 +71,8 @@ public class FriendRequestCommandService : IFriendRequestCommandService
friendship.Status = FriendshipStatus.Accepted;
await _friendshipsRepository.UpdateAsync(friendship);
await _privateChatsCreator.CreateAsync(friendship.AddresseeId, friendship.RequesterId);
return friendship;
}
catch (NotFoundByKeyException<Guid> ex)
@@ -16,9 +16,10 @@ namespace Govor.Application.Services.Messages;
public class MessageCommandService : IMessageCommandService
{
private readonly IMessagesRepository _messagesRepository;
private readonly IUsersRepository _usersRepository;
private readonly IGroupsRepository _groupsRepository;
private readonly IPrivateChatsRepository _privateChats;
private readonly IPrivateChatsRepository _privateChatsRepository;
private readonly IUsersRepository _usersRepository;
private readonly IGroupsRepository _groupsRepository;
private readonly IUserPrivateChatsCreator _privateChatsCreator;
private readonly IVerifyFriendship _verifyFriendship;
private readonly IMediaService _mediaService;
private readonly ILogger<MessageCommandService> _logger;
@@ -27,77 +28,39 @@ public class MessageCommandService : IMessageCommandService
IMessagesRepository messagesRepository,
IUsersRepository usersRepository,
IGroupsRepository groupsRepository,
IPrivateChatsRepository privateChatsRepository,
IUserPrivateChatsCreator privateChatsCreator,
IVerifyFriendship verifyFriendship,
IPrivateChatsRepository privateChats,
IMediaService mediaService,
ILogger<MessageCommandService> logger)
{
_messagesRepository = messagesRepository;
_usersRepository = usersRepository;
_groupsRepository = groupsRepository;
_privateChatsRepository = privateChatsRepository;
_privateChatsCreator = privateChatsCreator;
_verifyFriendship = verifyFriendship;
_privateChats = privateChats;
_mediaService = mediaService;
_logger = logger;
}
public async Task<SendMessageResult> SendMessageAsync(SendMessage sendParams)
{
Guid recipientId;
try
{
// Validate recipient
if (sendParams.RecipientType == RecipientType.User)
var recipientId = sendParams.RecipientType switch
{
if (!await _usersRepository.ExistsByIdAsync(sendParams.RecipientId))
{
if (!_privateChats.Exist(sendParams.RecipientId))
{
_logger.LogWarning("Attempt to send message to non-existent user {RecipientId}", sendParams.RecipientId);
return new SendMessageResult(false, new KeyNotFoundException($"Recipient user {sendParams.RecipientId} not found."), default);
}
else
{
recipientId = sendParams.RecipientId;
}
}
else
{
// Verify friendship for private messages
await _verifyFriendship.VerifyAsync(sendParams.FromUserId, sendParams.RecipientId);
recipientId = await GetPrivateChatsIdAsync(sendParams.FromUserId, sendParams.RecipientId);
}
}
else if (sendParams.RecipientType == RecipientType.Group)
{
if (!_groupsRepository.Exist(sendParams.RecipientId))
{
_logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId);
return new SendMessageResult(false, new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found."), default);
}
// TODO: Optionally, verify if sender is a member of the group
bool isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId);
if (!isMember)
{
_logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member", sendParams.FromUserId, sendParams.RecipientId);
return new SendMessageResult(false, new UnauthorizedAccessException("Sender is not a member of the group."), default);
}
recipientId = sendParams.RecipientId;
}
else
{
_logger.LogError("Invalid recipient type specified: {RecipientType}", sendParams.RecipientType);
return new SendMessageResult(false, new ArgumentException("Invalid recipient type."), default);
}
RecipientType.User => await HandleUserRecipientAsync(sendParams),
RecipientType.Group => await HandleGroupRecipientAsync(sendParams),
_ => throw new ArgumentException("Invalid recipient type.")
};
var messageId = Guid.NewGuid();
var message = new Message
{
Id = messageId,
SenderId = sendParams.FromUserId,
RecipientId = recipientId,
RecipientId = recipientId,
RecipientType = sendParams.RecipientType,
EncryptedContent = sendParams.EncryptContent,
SentAt = sendParams.SendAt,
@@ -111,44 +74,61 @@ public class MessageCommandService : IMessageCommandService
}).ToList() ?? new List<MediaAttachments>()
};
// If there is media, we link them.
if (sendParams.Media != null && sendParams.Media.Any())
{
// Attach media
if (sendParams.Media?.Any() == true)
foreach (var media in sendParams.Media)
{
await _mediaService.AttachToMessageAsync(media.MediaId, messageId);
}
}
await _messagesRepository.AddAsync(message);
_logger.LogInformation("Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.", messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
_logger.LogInformation(
"Message {MessageId} from {SenderId} to {RecipientId} ({RecipientType}) saved successfully.",
messageId, sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
return new SendMessageResult(true, null, message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error sending message from {SenderId} to {RecipientId} ({RecipientType})", sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
_logger.LogError(
ex,
"Error sending message from {SenderId} to {RecipientId} ({RecipientType})",
sendParams.FromUserId, sendParams.RecipientId, sendParams.RecipientType);
return new SendMessageResult(false, ex, default);
}
}
private async Task<Guid> GetPrivateChatsIdAsync(Guid userA, Guid userB)
private async Task<Guid> HandleUserRecipientAsync(SendMessage sendParams)
{
Guid recipientId;
// Makes new PrivateChat if not exist
if (!_privateChats.Exist(userA, userB))
var privateChat = await _privateChatsRepository.GetByIdAsync(sendParams.RecipientId);
if (privateChat == null)
{
recipientId = Guid.NewGuid();
await _privateChats.AddAsync(new PrivateChat()
{ Id = recipientId, UserAId = userA, UserBId = userB });
}
else
{
recipientId = (await _privateChats.GetByMembersAsync(userA, userB)).Id;
_logger.LogWarning("Private chat {ChatId} not found", sendParams.RecipientId);
throw new KeyNotFoundException($"Private chat {sendParams.RecipientId} not found.");
}
return recipientId;
return privateChat.Id;
}
private async Task<Guid> HandleGroupRecipientAsync(SendMessage sendParams)
{
if (!_groupsRepository.Exist(sendParams.RecipientId))
{
_logger.LogWarning("Attempt to send message to non-existent group {GroupId}", sendParams.RecipientId);
throw new KeyNotFoundException($"Recipient group {sendParams.RecipientId} not found.");
}
var isMember = await _groupsRepository.IsUserMemberOfGroupAsync(sendParams.FromUserId, sendParams.RecipientId);
if (!isMember)
{
_logger.LogWarning("User {UserId} attempted to send message to group {GroupId} but is not a member",
sendParams.FromUserId, sendParams.RecipientId);
throw new UnauthorizedAccessException("Sender is not a member of the group.");
}
return sendParams.RecipientId;
}
//TODO: Full Cleanup all them:
public async Task<EditMessageResult> EditMessageAsync(EditMessage editParams)
{
try
@@ -157,13 +137,16 @@ public class MessageCommandService : IMessageCommandService
if (message.SenderId != editParams.EditorId)
{
_logger.LogWarning("User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})", editParams.EditorId, editParams.MessageId, message.SenderId);
return new EditMessageResult(false, new UnauthorizedAccessException("User is not authorized to edit this message."), null);
_logger.LogWarning(
"User {EditorId} attempted to edit message {MessageId} not sent by them (sender was {SenderId})",
editParams.EditorId, editParams.MessageId, message.SenderId);
return new EditMessageResult(false,
new UnauthorizedAccessException("User is not authorized to edit this message."), null);
}
/*if (message.SentAt < DateTime.UtcNow.AddMinutes(-15))
throw new Exception("Edit time limit exceeded");*/
var originalMessageForNotification = new Message
{
Id = message.Id,
@@ -174,7 +157,7 @@ public class MessageCommandService : IMessageCommandService
ReplyToMessageId = message.ReplyToMessageId,
Reactions = message.Reactions,
MediaAttachments = message.MediaAttachments,
MessageViews = message.MessageViews,
MessageViews = message.MessageViews
};
message.EncryptedContent = editParams.NewContent;
@@ -182,12 +165,14 @@ public class MessageCommandService : IMessageCommandService
message.EditedAt = editParams.EditedAt;
await _messagesRepository.UpdateAsync(message);
_logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId, editParams.EditorId);
_logger.LogInformation("Message {MessageId} edited successfully by user {EditorId}", editParams.MessageId,
editParams.EditorId);
return new EditMessageResult(true, default, originalMessageForNotification);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId, editParams.EditorId);
_logger.LogError(ex, "Error editing message {MessageId} by user {EditorId}", editParams.MessageId,
editParams.EditorId);
return new EditMessageResult(false, ex, default);
}
}
@@ -221,7 +206,7 @@ public class MessageCommandService : IMessageCommandService
Id = message.Id,
SenderId = message.SenderId,
RecipientId = message.RecipientId,
RecipientType = message.RecipientType,
RecipientType = message.RecipientType
};
await _messagesRepository.RemoveAsync(deleteParams.MessageId);
@@ -236,7 +221,8 @@ public class MessageCommandService : IMessageCommandService
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId, deleteParams.DeleterId);
_logger.LogError(ex, "Error deleting message {MessageId} by user {DeleterId}", deleteParams.MessageId,
deleteParams.DeleterId);
return new DeleteMessageResult(false, ex, default);
}
}
@@ -36,7 +36,6 @@ public class ProfileService : IProfileService
{
throw new NotFoundException("User's profile cant be found with given id", ex);
}
}
public async Task SetDescription(string description, Guid userId)
@@ -0,0 +1,107 @@
using FirebaseAdmin.Messaging;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Services.PushNotifications.Providers;
public class FirebasePushProvider : IPushNotificationProvider
{
public string Name => "FCM";
public async Task<SendPushResult> SendToTokenAsync(string token, PushMessage message)
{
var msg = BuildFcmMessage(message, token: token);
try
{
string response = await FirebaseMessaging.DefaultInstance.SendAsync(msg);
return new SendPushResult(1, 0, []);
}
catch (FirebaseMessagingException ex)
{
if (IsInvalidTokenError(ex))
{
return new SendPushResult(0, 1, [token]);
}
throw; // return failed
}
}
public async Task<SendPushResult> SendMulticastAsync(IReadOnlyList<string> tokens, PushMessage message)
{
if (tokens.Count == 0)
return new SendPushResult(0, 0, []);
var multicastMsg = new MulticastMessage
{
Tokens = tokens.ToList(),
Notification = new Notification
{
Title = message.Title,
Body = message.Body
},
Data = message.Data,
Android = message.ChannelId != null ? new AndroidConfig
{
Notification = new AndroidNotification
{
ChannelId = message.ChannelId,
Priority = NotificationPriority.HIGH
}
} : null
};
try
{
var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(multicastMsg);
var failedTokens = new List<string>();
for (int i = 0; i < response.Responses.Count; i++)
{
if (!response.Responses[i].IsSuccess)
{
failedTokens.Add(tokens[i]);
}
}
return new SendPushResult(
response.SuccessCount,
response.FailureCount,
failedTokens
);
}
catch (Exception ex)
{
return new SendPushResult(0, tokens.Count, tokens.ToList());
}
}
private Message BuildFcmMessage(PushMessage pm, string? token = null)
{
var msg = new Message
{
Notification = new Notification { Title = pm.Title, Body = pm.Body },
Data = pm.Data,
Token = token
};
if (pm.ChannelId is not null)
{
msg.Android = new AndroidConfig
{
Notification = new AndroidNotification { ChannelId = pm.ChannelId }
};
}
return msg;
}
private static bool IsInvalidTokenError(FirebaseMessagingException ex)
{
return ex.MessagingErrorCode is MessagingErrorCode.Unregistered
or MessagingErrorCode.InvalidArgument
or MessagingErrorCode.SenderIdMismatch;
}
}
@@ -0,0 +1,19 @@
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
namespace Govor.Application.Services.PushNotifications.Providers;
public class NullPushProvider : IPushNotificationProvider
{
public string Name => "NULL";
public Task<SendPushResult> SendToTokenAsync(string token, PushMessage message)
{
throw new NotImplementedException();
}
public Task<SendPushResult> SendMulticastAsync(IReadOnlyList<string> tokens, PushMessage message)
{
throw new NotImplementedException();
}
}
@@ -0,0 +1,95 @@
using FirebaseAdmin.Messaging;
using Govor.Application.Interfaces.PushNotifications;
using Govor.Application.Interfaces.PushNotifications.Models;
using Govor.Core.Repositories.PushTokens;
using Microsoft.Extensions.Logging;
namespace Govor.Application.Services.PushNotifications;
public class PushNotificationService : IPushNotificationService
{
private readonly IPushNotificationProvider _provider;
private readonly IPushTokenRepository _tokenRepo;
private readonly ILogger _logger;
public PushNotificationService(
IPushNotificationProvider provider,
IPushTokenRepository tokenRepo,
ILogger<PushNotificationService> logger)
{
_provider = provider;
_tokenRepo = tokenRepo;
_logger = logger;
}
public async Task SendToUserAsync(Guid userId, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
var tokens = await _tokenRepo.GetActiveTokensAsync(userId);
if (tokens.Count == 0)
{
_logger.LogInformation("No active push tokens for user {UserId}", userId);
return;
}
var message = new PushMessage
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
};
var result = await _provider.SendMulticastAsync(tokens, message);
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for user {UserId}", result.FailureCount, userId);
}
}
public async Task SendToUsersAsync(IEnumerable<Guid> userIds, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
if (userIds is null)
throw new ArgumentNullException(nameof(userIds));
var tokens = (await _tokenRepo.GetActiveTokensUsersAsync(userIds)).AsReadOnly();
if (tokens.Count == 0)
return;
var result = await _provider.SendMulticastAsync(tokens, new PushMessage()
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
});
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for users {Users}...", result.FailureCount, userIds);
}
}
public async Task SendToSessionAsync(Guid sessionId, string title, string body, string channelId, Dictionary<string, string>? data = null)
{
var token = await _tokenRepo.GetActiveTokenBySessionAsync(sessionId);
if(string.IsNullOrEmpty(token))
return;
var result = await _provider.SendToTokenAsync(token, new PushMessage()
{
Title = title,
Body = body,
Data = data ?? new(),
ChannelId = channelId
});
if (result.FailureCount > 0)
{
await _tokenRepo.RemoveTokensAsync(result.FailedTokens);
_logger.LogWarning("Removed {Count} invalid FCM tokens for session {Session}...", result.FailureCount, sessionId);
}
}
}
@@ -0,0 +1,46 @@
using Govor.Application.Interfaces;
using Govor.Core.Models;
using Govor.Core.Repositories.PrivateChats;
using Microsoft.Extensions.Logging;
namespace Govor.Application.Services;
public class UserPrivateChatsCreator : IUserPrivateChatsCreator
{
private readonly IPrivateChatsRepository _privateChats;
private readonly IPrivateChatGroupManager _privateChatGroupManager;
private readonly ILogger<UserPrivateChatsCreator> _logger;
public UserPrivateChatsCreator(
IPrivateChatsRepository privateChats,
IPrivateChatGroupManager privateChatGroupManager,
ILogger<UserPrivateChatsCreator> logger)
{
_privateChats = privateChats;
_privateChatGroupManager = privateChatGroupManager;
_logger = logger;
}
public async Task<PrivateChat> CreateAsync(Guid userIdA, Guid userIdB)
{
if (!_privateChats.Exist(userIdA, userIdB))
{
var recipientId = Guid.NewGuid();
var privateChat = new PrivateChat()
{
Id = recipientId,
UserAId = userIdA,
UserBId = userIdB
};
await _privateChats.AddAsync(privateChat);
await _privateChatGroupManager.AddUsersToPrivateChatGroupAsync(privateChat); // Notifying
_logger.LogInformation($"User for {userIdA} and {userIdB} was created private chat with Id: {recipientId}");
return privateChat;
}
_logger.LogInformation($"Private chat already exists for {userIdA} and {userIdB}; Returning already created chat...");
return await _privateChats.GetByMembersAsync(userIdA, userIdB);
}
}
@@ -1,4 +1,5 @@
using Govor.Application.Interfaces.UserSession;
using Govor.Core.Repositories.PushTokens;
using Govor.Core.Repositories.UserSessionsRepository;
using Govor.Data.Repositories.Exceptions;
using Microsoft.Extensions.Logging;
@@ -8,11 +9,16 @@ namespace Govor.Application.Services.UserSessions;
public class UserSessionRevoker : IUserSessionRevoker
{
private readonly IUserSessionsRepository _sessionsRepository;
private readonly IPushTokenRepository _pushTokenRepository;
private readonly ILogger<UserSessionRevoker> _logger;
public UserSessionRevoker(IUserSessionsRepository sessionsRepository, ILogger<UserSessionRevoker> logger)
public UserSessionRevoker(
IUserSessionsRepository sessionsRepository,
IPushTokenRepository pushTokenRepository,
ILogger<UserSessionRevoker> logger)
{
_sessionsRepository = sessionsRepository;
_pushTokenRepository = pushTokenRepository;
_logger = logger;
}
@@ -32,6 +38,8 @@ public class UserSessionRevoker : IUserSessionRevoker
session.IsRevoked = true;
await _sessionsRepository.UpdateAsync(session);
await _pushTokenRepository.DeactivateTokenBySessionAsync(sessionId); // pushes deactivate
}
catch (NotFoundByKeyException<Guid> ex)
{
@@ -52,6 +60,8 @@ public class UserSessionRevoker : IUserSessionRevoker
session.IsRevoked = true;
await _sessionsRepository.UpdateAsync(session);
await _pushTokenRepository.DeactivateTokenBySessionAsync(session.Id); // pushes deactivate
}
}
catch (NotFoundByKeyException<Guid> ex)