✨ Chat realtime calls
This commit is contained in:
@ -1,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.RegularExpressions;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -105,6 +106,7 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
|
||||
|
||||
[HttpPost("{roomId:long}/messages")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "chat.messages.create")]
|
||||
public async Task<ActionResult> SendMessage([FromBody] SendMessageRequest request, long roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
@ -122,6 +124,7 @@ public partial class ChatController(AppDatabase db, ChatService cs) : Controller
|
||||
|
||||
var message = new Message
|
||||
{
|
||||
Type = "text",
|
||||
SenderId = member.Id,
|
||||
ChatRoomId = roomId,
|
||||
Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
|
||||
|
@ -11,16 +11,16 @@ public class ChatService(AppDatabase db, IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
message.UpdatedAt = message.CreatedAt;
|
||||
|
||||
|
||||
// First complete the save operation
|
||||
db.ChatMessages.Add(message);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
// Then start the delivery process
|
||||
// Using ConfigureAwait(false) is correct here since we don't need context to flow
|
||||
_ = Task.Run(() => DeliverMessageAsync(message, sender, room))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@ -37,8 +37,8 @@ public class ChatService(AppDatabase db, IServiceScopeFactory scopeFactory)
|
||||
var members = await scopedDb.ChatMembers
|
||||
.Where(m => m.ChatRoomId == message.ChatRoomId && m.AccountId != sender.AccountId)
|
||||
.Where(m => m.Notify != ChatMemberNotify.None)
|
||||
.Where(m => m.Notify != ChatMemberNotify.Mentions ||
|
||||
(message.MembersMentioned != null && message.MembersMentioned.Contains(m.Id)))
|
||||
.Where(m => m.Notify != ChatMemberNotify.Mentions ||
|
||||
(message.MembersMentioned != null && message.MembersMentioned.Contains(m.Id)))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var member in members)
|
||||
@ -101,6 +101,85 @@ public class ChatService(AppDatabase db, IServiceScopeFactory scopeFactory)
|
||||
return messages.Count(m => !m.IsRead);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<long, int>> CountUnreadMessagesForJoinedRoomsAsync(long userId)
|
||||
{
|
||||
var userRooms = await db.ChatMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Select(m => m.ChatRoomId)
|
||||
.ToListAsync();
|
||||
|
||||
var messages = await db.ChatMessages
|
||||
.Where(m => userRooms.Contains(m.ChatRoomId))
|
||||
.Select(m => new
|
||||
{
|
||||
m.ChatRoomId,
|
||||
IsRead = m.Statuses.Any(rs => rs.Sender.AccountId == userId)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return messages
|
||||
.GroupBy(m => m.ChatRoomId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Count(m => !m.IsRead)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<RealtimeCall> CreateCallAsync(ChatRoom room, ChatMember sender)
|
||||
{
|
||||
var call = new RealtimeCall
|
||||
{
|
||||
RoomId = room.Id,
|
||||
SenderId = sender.Id,
|
||||
};
|
||||
db.ChatRealtimeCall.Add(call);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendMessageAsync(new Message
|
||||
{
|
||||
Type = "realtime.start",
|
||||
ChatRoomId = room.Id,
|
||||
SenderId = sender.Id,
|
||||
Meta = new Dictionary<string, object>
|
||||
{
|
||||
{ "call", call.Id }
|
||||
}
|
||||
}, sender, room);
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
public async Task EndCallAsync(long roomId)
|
||||
{
|
||||
var call = await GetCallOngoingAsync(roomId);
|
||||
if (call is null) throw new InvalidOperationException("No ongoing call was not found.");
|
||||
|
||||
call.EndedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.ChatRealtimeCall.Update(call);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SendMessageAsync(new Message
|
||||
{
|
||||
Type = "realtime.ended",
|
||||
ChatRoomId = call.RoomId,
|
||||
SenderId = call.SenderId,
|
||||
Meta = new Dictionary<string, object>
|
||||
{
|
||||
{ "call", call.Id }
|
||||
}
|
||||
}, call.Sender, call.Room);
|
||||
}
|
||||
|
||||
public async Task<RealtimeCall?> GetCallOngoingAsync(long roomId)
|
||||
{
|
||||
return await db.ChatRealtimeCall
|
||||
.Where(c => c.RoomId == roomId)
|
||||
.Where(c => c.EndedAt == null)
|
||||
.Include(c => c.Room)
|
||||
.Include(c => c.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<SyncResponse> GetSyncDataAsync(long roomId, long lastSyncTimestamp)
|
||||
{
|
||||
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
|
||||
|
@ -9,7 +9,8 @@ namespace DysonNetwork.Sphere.Chat;
|
||||
public class Message : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[MaxLength(4096)] public string Content { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Content { get; set; }
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; }
|
||||
[Column(TypeName = "jsonb")] public List<Guid>? MembersMentioned { get; set; }
|
||||
[MaxLength(36)] public string Nonce { get; set; } = null!;
|
||||
|
15
DysonNetwork.Sphere/Chat/RealtimeCall.cs
Normal file
15
DysonNetwork.Sphere/Chat/RealtimeCall.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
public class RealtimeCall : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string? Title { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
|
||||
public Guid SenderId { get; set; }
|
||||
public ChatMember Sender { get; set; } = null!;
|
||||
public long RoomId { get; set; }
|
||||
public ChatRoom Room { get; set; } = null!;
|
||||
}
|
104
DysonNetwork.Sphere/Chat/RealtimeCallController.cs
Normal file
104
DysonNetwork.Sphere/Chat/RealtimeCallController.cs
Normal file
@ -0,0 +1,104 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using tencentyun;
|
||||
|
||||
namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
public class RealtimeChatConfiguration
|
||||
{
|
||||
public string Provider { get; set; } = null!;
|
||||
public int AppId { get; set; }
|
||||
[JsonIgnore] public string SecretKey { get; set; } = null!;
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("/chat/realtime")]
|
||||
public class RealtimeCallController(IConfiguration configuration, AppDatabase db, ChatService cs) : ControllerBase
|
||||
{
|
||||
private readonly RealtimeChatConfiguration _config =
|
||||
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
|
||||
|
||||
[HttpGet]
|
||||
public ActionResult<RealtimeChatConfiguration> GetConfiguration()
|
||||
{
|
||||
return _config;
|
||||
}
|
||||
|
||||
public class RealtimeChatToken
|
||||
{
|
||||
public RealtimeChatConfiguration Config { get; set; } = null!;
|
||||
public string Token { get; set; } = null!;
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<RealtimeChatToken>> GetToken(long roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403,
|
||||
"You need to be a normal member to get the token for joining the realtime chatroom."
|
||||
);
|
||||
|
||||
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
|
||||
if (ongoingCall is null) return BadRequest("No ongoing call.");
|
||||
|
||||
var api = new TLSSigAPIv2(_config.AppId, _config.SecretKey);
|
||||
var sig = api.GenSig(currentUser.Name);
|
||||
if (sig is null) return StatusCode(500, "Failed to generate the token.");
|
||||
|
||||
return Ok(new RealtimeChatToken
|
||||
{
|
||||
Config = _config,
|
||||
Token = sig
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> StartCall(long roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
|
||||
.Include(m => m.ChatRoom)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You need to be a normal member to start a call.");
|
||||
|
||||
var ongoingCall = await cs.GetCallOngoingAsync(roomId);
|
||||
if (ongoingCall is not null) return StatusCode(423, "There is already an ongoing call inside the chatroom.");
|
||||
var call = await cs.CreateCallAsync(member.ChatRoom, member);
|
||||
return Ok(call);
|
||||
}
|
||||
|
||||
[HttpDelete("{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> EndCall(long roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == currentUser.Id && m.ChatRoomId == roomId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You need to be a normal member to end a call.");
|
||||
|
||||
try
|
||||
{
|
||||
await cs.EndCallAsync(roomId);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user