Информация о звонках в чате
This commit is contained in:
33
backend/src/Contracts/Messaging/Domain/CallMessage.cs
Normal file
33
backend/src/Contracts/Messaging/Domain/CallMessage.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
|
||||
namespace Knot.Contracts.Messaging.Domain;
|
||||
|
||||
public class CallMessage : Message
|
||||
{
|
||||
public override string Type => "call";
|
||||
public override string? Content { get; protected set; }
|
||||
public string CallType { get; protected set; }
|
||||
public string CallStatus { get; protected set; }
|
||||
public int? Duration { get; protected set; }
|
||||
|
||||
public CallMessage() : base() { }
|
||||
|
||||
public CallMessage(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
string callType,
|
||||
string callStatus,
|
||||
int? duration,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported = false)
|
||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||
{
|
||||
CallType = callType;
|
||||
CallStatus = callStatus;
|
||||
Duration = duration;
|
||||
Content = $"Call {callStatus}";
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
||||
senderObj.Avatar
|
||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
||||
reactionsWithUser,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList()
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||
(latestMessage as CallMessage)?.CallType,
|
||||
(latestMessage as CallMessage)?.CallStatus,
|
||||
(latestMessage as CallMessage)?.Duration
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ public record ChatMessageDto(
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto Sender,
|
||||
List<ReactionDto> Reactions,
|
||||
List<ReadByDto> ReadBy
|
||||
List<ReadByDto> ReadBy,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null
|
||||
);
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ public record MessageDetailDto(
|
||||
List<MediaDto> Media,
|
||||
MessageSenderDto? Sender,
|
||||
List<ReadByDto> ReadBy,
|
||||
List<MessageReactionDto> Reactions
|
||||
List<MessageReactionDto> Reactions,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
@@ -142,7 +142,10 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
|
||||
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
||||
reactionsWithUser
|
||||
reactionsWithUser,
|
||||
(message as CallMessage)?.CallType,
|
||||
(message as CallMessage)?.CallStatus,
|
||||
(message as CallMessage)?.Duration
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,10 @@ public sealed record SendMessageCommand(
|
||||
List<string>? PollOptions = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollAllowMultipleAnswers = null,
|
||||
DateTime? PollExpiresAt = null) : ICommand<Guid>;
|
||||
DateTime? PollExpiresAt = null,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null) : ICommand<Guid>;
|
||||
|
||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||
{
|
||||
@@ -147,6 +150,20 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
}
|
||||
else if (request.Type == "call")
|
||||
{
|
||||
message = new CallMessage(
|
||||
Guid.NewGuid(),
|
||||
request.ChatId,
|
||||
request.SenderId,
|
||||
request.CallType ?? "voice",
|
||||
request.CallStatus ?? "completed",
|
||||
request.Duration,
|
||||
request.ReplyToId,
|
||||
request.ForwardedFromId,
|
||||
DateTime.UtcNow,
|
||||
false);
|
||||
}
|
||||
else
|
||||
{
|
||||
message = new TextMessage(
|
||||
|
||||
@@ -29,6 +29,11 @@ public sealed class ChatHub : Hub
|
||||
|
||||
public static int OnlineUsersCount => _userConnections.Count;
|
||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||
|
||||
// userId → CallSession (one user can be in only one call at a time)
|
||||
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
||||
// chatId → (startTime, callType)
|
||||
private static readonly ConcurrentDictionary<string, (DateTime StartTime, string CallType)> _activeGroupCalls = new();
|
||||
|
||||
private readonly ISender _sender;
|
||||
private readonly IUserContext _userContext;
|
||||
@@ -309,11 +314,35 @@ public sealed class ChatHub : Hub
|
||||
username = username
|
||||
}
|
||||
});
|
||||
|
||||
// Track session for history
|
||||
Guid? chatId = null;
|
||||
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
||||
|
||||
if (!chatId.HasValue)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||
{
|
||||
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||
if (personalChat != null) chatId = personalChat.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
||||
_activeSessionsByUser[_userContext.UserId.ToString()] = session;
|
||||
_activeSessionsByUser[request.TargetUserId] = session;
|
||||
}
|
||||
|
||||
[HubMethodName("call_answer")]
|
||||
public async Task CallAnswer(CallAnswerRequest request)
|
||||
{
|
||||
if (_activeSessionsByUser.TryGetValue(_userContext.UserId.ToString(), out var session))
|
||||
{
|
||||
session.IsAnswered = true;
|
||||
session.AnswerTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
@@ -324,6 +353,19 @@ public sealed class ChatHub : Hub
|
||||
[HubMethodName("call_decline")]
|
||||
public async Task CallDecline(TargetUserRequest request)
|
||||
{
|
||||
var currentUserIdStr = _userContext.UserId.ToString();
|
||||
if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session))
|
||||
{
|
||||
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||
if (session.ChatId.HasValue)
|
||||
{
|
||||
// If declined by recipient, it's a "declined" call
|
||||
// If current user is recipient (not the one who started), status is declined
|
||||
string status = _userContext.UserId == session.FromUserId ? "cancelled" : "declined";
|
||||
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, 0);
|
||||
}
|
||||
}
|
||||
|
||||
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
@@ -333,12 +375,53 @@ public sealed class ChatHub : Hub
|
||||
[HubMethodName("call_end")]
|
||||
public async Task CallEnd(TargetUserRequest request)
|
||||
{
|
||||
var currentUserIdStr = _userContext.UserId.ToString();
|
||||
if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session))
|
||||
{
|
||||
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||
if (session.ChatId.HasValue)
|
||||
{
|
||||
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||
: 0;
|
||||
|
||||
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
||||
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
||||
}
|
||||
}
|
||||
|
||||
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
||||
{
|
||||
from = _userContext.UserId.ToString(),
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CreateCallMessage(Guid chatId, Guid senderId, string callType, string status, int duration)
|
||||
{
|
||||
var command = new SendMessageCommand(
|
||||
chatId,
|
||||
senderId,
|
||||
null,
|
||||
"call",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
callType,
|
||||
status,
|
||||
duration
|
||||
);
|
||||
|
||||
await _sender.Send(command);
|
||||
}
|
||||
|
||||
[HubMethodName("ice_candidate")]
|
||||
public async Task IceCandidate(IceCandidateRequest request)
|
||||
{
|
||||
@@ -410,7 +493,11 @@ public sealed class ChatHub : Hub
|
||||
|
||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||
{
|
||||
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
||||
return new ConcurrentDictionary<string, ParticipantInfo>();
|
||||
});
|
||||
var isFirst = participants.IsEmpty;
|
||||
participants.TryAdd(userId, userInfo);
|
||||
|
||||
@@ -468,6 +555,11 @@ public sealed class ChatHub : Hub
|
||||
if (participants.IsEmpty)
|
||||
{
|
||||
_groupCallParticipants.TryRemove(chatId, out _);
|
||||
if (_activeGroupCalls.TryRemove(chatId, out var info))
|
||||
{
|
||||
var duration = (int)(DateTime.UtcNow - info.StartTime).TotalSeconds;
|
||||
await CreateCallMessage(Guid.Parse(chatId), _userContext.UserId, info.CallType, "completed", duration);
|
||||
}
|
||||
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
||||
}
|
||||
}
|
||||
@@ -691,5 +783,25 @@ public sealed class ChatHub : Hub
|
||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record FriendSignalRequest(string FriendId);
|
||||
|
||||
public class CallSession
|
||||
{
|
||||
public Guid? ChatId { get; }
|
||||
public Guid FromUserId { get; }
|
||||
public Guid ToUserId { get; }
|
||||
public string CallType { get; }
|
||||
public DateTime StartTime { get; }
|
||||
public bool IsAnswered { get; set; }
|
||||
public DateTime? AnswerTime { get; set; }
|
||||
|
||||
public CallSession(Guid? chatId, Guid fromUserId, Guid toUserId, string callType, DateTime startTime)
|
||||
{
|
||||
ChatId = chatId;
|
||||
FromUserId = fromUserId;
|
||||
ToUserId = toUserId;
|
||||
CallType = callType;
|
||||
StartTime = startTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
||||
readBy = new List<object>(),
|
||||
storyId = (message as StoryMessage)?.StoryId,
|
||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||
storyMediaType = (message as StoryMessage)?.StoryMediaType
|
||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||
callType = (message as CallMessage)?.CallType,
|
||||
callStatus = (message as CallMessage)?.CallStatus,
|
||||
duration = (message as CallMessage)?.Duration
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ public static class MongoDbMapConfigurator
|
||||
cm.SetDiscriminator("PollMessage");
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<CallMessage>(cm =>
|
||||
{
|
||||
cm.AutoMap();
|
||||
cm.SetIgnoreExtraElements(true);
|
||||
cm.SetDiscriminator("CallMessage");
|
||||
});
|
||||
|
||||
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
||||
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user