Chat realtime calls

This commit is contained in:
LittleSheep 2025-05-07 00:47:57 +08:00
parent 02aee07116
commit fb07071603
13 changed files with 2839 additions and 22 deletions

View File

@ -25,6 +25,8 @@ public class AccountController(
{
var account = await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(a => a.Name == name)
.FirstOrDefaultAsync();
return account is null ? new NotFoundResult() : account;
@ -105,13 +107,15 @@ public class AccountController(
[Authorize]
[HttpGet("me")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
public async Task<ActionResult<Account>> GetMe()
public async Task<ActionResult<Account>> GetCurrentIdentity()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id;
var account = await db.Accounts
.Include(e => e.Profile)
.Include(e => e.Profile.Picture)
.Include(e => e.Profile.Background)
.Where(e => e.Id == userId)
.FirstOrDefaultAsync();

View File

@ -54,6 +54,7 @@ public class AppDatabase(
public DbSet<Chat.ChatRoom> ChatRooms { get; set; }
public DbSet<Chat.ChatMember> ChatMembers { get; set; }
public DbSet<Chat.Message> ChatMessages { get; set; }
public DbSet<Chat.RealtimeCall> ChatRealtimeCall { get; set; }
public DbSet<Chat.MessageStatus> ChatStatuses { get; set; }
public DbSet<Chat.MessageReaction> ChatReactions { get; set; }
@ -84,7 +85,9 @@ public class AppDatabase(
PermissionService.NewPermissionNode("group:default", "global", "posts.react", true),
PermissionService.NewPermissionNode("group:default", "global", "publishers.create", true),
PermissionService.NewPermissionNode("group:default", "global", "files.create", true),
PermissionService.NewPermissionNode("group:default", "global", "chat.create", true)
PermissionService.NewPermissionNode("group:default", "global", "chat.create", true),
PermissionService.NewPermissionNode("group:default", "global", "chat.messages.create", true),
PermissionService.NewPermissionNode("group:default", "global", "chat.realtime.create", true)
}
});
await context.SaveChangesAsync(cancellationToken);
@ -205,6 +208,16 @@ public class AppDatabase(
.WithMany()
.HasForeignKey(m => m.RepliedMessageId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Chat.RealtimeCall>()
.HasOne(m => m.Room)
.WithMany()
.HasForeignKey(m => m.RoomId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Chat.RealtimeCall>()
.HasOne(m => m.Sender)
.WithMany()
.HasForeignKey(m => m.SenderId)
.OnDelete(DeleteBehavior.Cascade);
// Automatically apply soft-delete filter to all entities inheriting BaseModel
foreach (var entityType in modelBuilder.Model.GetEntityTypes())

View File

@ -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(),

View File

@ -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);

View File

@ -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!;

View 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!;
}

View 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);
}
}
}

View File

@ -44,6 +44,7 @@
<PackageReference Include="SixLabors.ImageSharp.Web" Version="3.1.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="8.1.0" />
<PackageReference Include="tls-sig-api-v2" Version="1.0.1" />
<PackageReference Include="tusdotnet" Version="2.8.1" />
</ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddChatRealtimeCall : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "content",
table: "chat_messages",
type: "character varying(4096)",
maxLength: 4096,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096);
migrationBuilder.AddColumn<string>(
name: "type",
table: "chat_messages",
type: "text",
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "chat_realtime_call",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "text", nullable: true),
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
sender_id = table.Column<Guid>(type: "uuid", nullable: false),
room_id = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_chat_realtime_call", x => x.id);
table.ForeignKey(
name: "fk_chat_realtime_call_chat_members_sender_id",
column: x => x.sender_id,
principalTable: "chat_members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_chat_realtime_call_chat_rooms_room_id",
column: x => x.room_id,
principalTable: "chat_rooms",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_chat_realtime_call_room_id",
table: "chat_realtime_call",
column: "room_id");
migrationBuilder.CreateIndex(
name: "ix_chat_realtime_call_sender_id",
table: "chat_realtime_call",
column: "sender_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "chat_realtime_call");
migrationBuilder.DropColumn(
name: "type",
table: "chat_messages");
migrationBuilder.AlterColumn<string>(
name: "content",
table: "chat_messages",
type: "character varying(4096)",
maxLength: 4096,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(4096)",
oldMaxLength: 4096,
oldNullable: true);
}
}
}

View File

@ -785,7 +785,6 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnName("chat_room_id");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("content");
@ -828,6 +827,11 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@ -934,6 +938,53 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("chat_statuses", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.RealtimeCall", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<long>("RoomId")
.HasColumnType("bigint")
.HasColumnName("room_id");
b.Property<Guid>("SenderId")
.HasColumnType("uuid")
.HasColumnName("sender_id");
b.Property<string>("Title")
.HasColumnType("text")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_chat_realtime_call");
b.HasIndex("RoomId")
.HasDatabaseName("ix_chat_realtime_call_room_id");
b.HasIndex("SenderId")
.HasDatabaseName("ix_chat_realtime_call_sender_id");
b.ToTable("chat_realtime_call", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Property<Guid>("Id")
@ -2038,6 +2089,27 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Chat.RealtimeCall", b =>
{
b.HasOne("DysonNetwork.Sphere.Chat.ChatRoom", "Room")
.WithMany()
.HasForeignKey("RoomId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_rooms_room_id");
b.HasOne("DysonNetwork.Sphere.Chat.ChatMember", "Sender")
.WithMany()
.HasForeignKey("SenderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_chat_realtime_call_chat_members_sender_id");
b.Navigation("Room");
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", "Group")

View File

@ -141,9 +141,9 @@ public class FileService(
List<Task> tasks = [];
var ogFilePath = Path.Join(configuration.GetValue<string>("Tus:StorePath"), file.Id);
var vipsImage = NetVips.Image.NewFromFile(ogFilePath);
using var vipsImage = NetVips.Image.NewFromFile(ogFilePath);
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
tasks.Add(Task.Run(() => vipsImage.WriteToFile(imagePath + ".webp")));
vipsImage.WriteToFile(imagePath + ".webp");
result.Add((imagePath + ".webp", string.Empty));
if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
@ -153,19 +153,12 @@ public class FileService(
Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}-compressed");
// Create and save image within the same synchronous block to avoid disposal issues
tasks.Add(Task.Run(() => {
using var compressedImage = vipsImage.Resize(scale);
compressedImage.WriteToFile(imageCompressedPath + ".webp");
vipsImage.Dispose();
}));
result.Add((imageCompressedPath + ".webp", ".compressed"));
file.HasCompression = true;
}
else
{
vipsImage.Dispose();
}
await Task.WhenAll(tasks);
}

View File

@ -30,6 +30,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AImage_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F9f_003Fc5bde8be_003FImage_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIndexAttribute_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe38f14ac86274ebb9b366729231d1c1a8838_003F8b_003F2890293d_003FIndexAttribute_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIntentType_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fbf_003Ffcb84131_003FIntentType_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AInternalServerError_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003Fb5_003Fc55acdd2_003FInternalServerError_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AIServiceCollectionQuartzConfigurator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1edbd6e24d7b430fabce72177269baa19200_003F67_003Faee36f5b_003FIServiceCollectionQuartzConfigurator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AITusStore_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003Fb1_003F7e861de5_003FITusStore_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJsonSerializerOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5703920a18f94462b4354fab05326e6519a200_003F35_003F8536fc49_003FJsonSerializerOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>