Compare commits
12 Commits
15687a0c32
...
refactor/w
Author | SHA1 | Date | |
---|---|---|---|
28d60c722a
|
|||
4626529eb5
|
|||
46ebd92dc1
|
|||
7f8521bb40
|
|||
f01226d91a
|
|||
6cb6dee6be
|
|||
0e9caf67ff
|
|||
ca70bb5487
|
|||
59ed135f20
|
|||
6077f91529
|
|||
5c485bb1c3
|
|||
27d979d77b
|
@@ -80,7 +80,7 @@ public class AccountCurrentController(
|
||||
[MaxLength(1024)] public string? TimeZone { get; set; }
|
||||
[MaxLength(1024)] public string? Location { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
public UsernameColor? UsernameColor { get; set; }
|
||||
public Shared.Models.UsernameColor? UsernameColor { get; set; }
|
||||
public Instant? Birthday { get; set; }
|
||||
public List<ProfileLink>? Links { get; set; }
|
||||
|
||||
|
@@ -197,7 +197,8 @@ public class SubscriptionGiftController(
|
||||
|
||||
if (currentUser.Profile.Level < MinimumAccountLevel)
|
||||
{
|
||||
return StatusCode(403, "Account level must be at least 60 to purchase a gift.");
|
||||
if (currentUser.PerkSubscription is null)
|
||||
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
|
||||
}
|
||||
|
||||
Duration? giftDuration = null;
|
||||
|
@@ -250,6 +250,14 @@ public class SubscriptionService(
|
||||
: null;
|
||||
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
|
||||
|
||||
if (subscriptionInfo.RequiredLevel > 0)
|
||||
{
|
||||
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == subscription.AccountId);
|
||||
if (profile is null) throw new InvalidOperationException("Account must have a profile");
|
||||
if (profile.Level < subscriptionInfo.RequiredLevel)
|
||||
throw new InvalidOperationException("Account level must be at least 60 to purchase a gift.");
|
||||
}
|
||||
|
||||
return await payment.CreateOrderAsync(
|
||||
null,
|
||||
subscriptionInfo.Currency,
|
||||
@@ -684,6 +692,9 @@ public class SubscriptionService(
|
||||
if (now > gift.ExpiresAt)
|
||||
throw new InvalidOperationException("Gift has expired.");
|
||||
|
||||
if (gift.GifterId == redeemer.Id)
|
||||
throw new InvalidOperationException("You cannot redeem your own gift.");
|
||||
|
||||
// Validate redeemer permissions
|
||||
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
|
||||
throw new InvalidOperationException("This gift is not intended for you.");
|
||||
|
@@ -148,6 +148,32 @@ public class UsernameColor
|
||||
public string? Value { get; set; } // e.g. "red" or "#ff6600"
|
||||
public string? Direction { get; set; } // e.g. "to right"
|
||||
public List<string>? Colors { get; set; } // e.g. ["#ff0000", "#00ff00"]
|
||||
|
||||
public Proto.UsernameColor ToProtoValue()
|
||||
{
|
||||
var proto = new Proto.UsernameColor
|
||||
{
|
||||
Type = Type,
|
||||
Value = Value,
|
||||
Direction = Direction,
|
||||
};
|
||||
if (Colors is not null)
|
||||
{
|
||||
proto.Colors.AddRange(Colors);
|
||||
}
|
||||
return proto;
|
||||
}
|
||||
|
||||
public static UsernameColor FromProtoValue(Proto.UsernameColor proto)
|
||||
{
|
||||
return new UsernameColor
|
||||
{
|
||||
Type = proto.Type,
|
||||
Value = proto.Value,
|
||||
Direction = proto.Direction,
|
||||
Colors = proto.Colors?.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
@@ -218,6 +244,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
AccountId = AccountId.ToString(),
|
||||
Verification = Verification?.ToProtoValue(),
|
||||
ActiveBadge = ActiveBadge?.ToProtoValue(),
|
||||
UsernameColor = UsernameColor?.ToProtoValue(),
|
||||
CreatedAt = CreatedAt.ToTimestamp(),
|
||||
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||
};
|
||||
@@ -247,6 +274,7 @@ public class SnAccountProfile : ModelBase, IIdentifiedResource
|
||||
Picture = proto.Picture is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Picture),
|
||||
Background = proto.Background is null ? null : SnCloudFileReferenceObject.FromProtoValue(proto.Background),
|
||||
AccountId = Guid.Parse(proto.AccountId),
|
||||
UsernameColor = proto.UsernameColor is not null ? UsernameColor.FromProtoValue(proto.UsernameColor) : null,
|
||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||
};
|
||||
|
@@ -168,6 +168,7 @@ public class SnPostReaction : ModelBase
|
||||
public Guid PostId { get; set; }
|
||||
[JsonIgnore] public SnPost Post { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
[NotMapped] public SnAccount? Account { get; set; }
|
||||
}
|
||||
|
||||
public class SnPostAward : ModelBase
|
||||
|
@@ -59,6 +59,13 @@ message AccountStatus {
|
||||
bytes meta = 10;
|
||||
}
|
||||
|
||||
message UsernameColor {
|
||||
string type = 1;
|
||||
google.protobuf.StringValue value = 2;
|
||||
google.protobuf.StringValue direction = 3;
|
||||
repeated string colors = 4;
|
||||
}
|
||||
|
||||
// Profile contains detailed information about a user
|
||||
message AccountProfile {
|
||||
string id = 1;
|
||||
@@ -89,6 +96,7 @@ message AccountProfile {
|
||||
|
||||
google.protobuf.Timestamp created_at = 22;
|
||||
google.protobuf.Timestamp updated_at = 23;
|
||||
optional UsernameColor username_color = 24;
|
||||
}
|
||||
|
||||
// AccountContact represents a contact method for an account
|
||||
|
@@ -16,7 +16,7 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
|
||||
var afterAt = content[1..];
|
||||
string type;
|
||||
string query;
|
||||
bool hadSlash = afterAt.Contains('/');
|
||||
var hadSlash = afterAt.Contains('/');
|
||||
if (hadSlash)
|
||||
{
|
||||
var parts = afterAt.Split('/', 2);
|
||||
@@ -130,30 +130,17 @@ public class AutocompletionService(AppDatabase db, AccountClientHelper accountsH
|
||||
{
|
||||
var stickers = await db.Stickers
|
||||
.Include(s => s.Pack)
|
||||
.Where(s => EF.Functions.Like(s.Slug, $"{query}%"))
|
||||
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(s => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "sticker",
|
||||
Keyword = s.Slug,
|
||||
Keyword = $":{s.Pack.Prefix}+{s.Slug}:",
|
||||
Data = s
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
// Also possibly search by pack prefix? But user said slug after :
|
||||
// Perhaps combine or search packs
|
||||
var packs = await db.StickerPacks
|
||||
.Where(p => EF.Functions.Like(p.Prefix, $"{query}%"))
|
||||
.Take(limit)
|
||||
.Select(p => new DysonNetwork.Shared.Models.Autocompletion
|
||||
{
|
||||
Type = "sticker_pack",
|
||||
Keyword = p.Prefix,
|
||||
Data = p
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var results = stickers.Concat(packs).Take(limit).ToList();
|
||||
var results = stickers.ToList();
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
@@ -87,7 +87,8 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
@@ -129,7 +130,8 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null &&
|
||||
m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
@@ -148,9 +150,74 @@ public partial class ChatController(
|
||||
}
|
||||
|
||||
|
||||
[GeneratedRegex("@([A-Za-z0-9_-]+)")]
|
||||
[GeneratedRegex(@"@(?:u/)?([A-Za-z0-9_-]+)")]
|
||||
private static partial Regex MentionRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Extracts mentioned users from message content, replies, and forwards
|
||||
/// </summary>
|
||||
private async Task<List<Guid>> ExtractMentionedUsersAsync(string? content, Guid? repliedMessageId,
|
||||
Guid? forwardedMessageId, Guid roomId, Guid? excludeSenderId = null)
|
||||
{
|
||||
var mentionedUsers = new List<Guid>();
|
||||
|
||||
// Add sender of a replied message
|
||||
if (repliedMessageId.HasValue)
|
||||
{
|
||||
var replyingTo = await db.ChatMessages
|
||||
.Where(m => m.Id == repliedMessageId.Value && m.ChatRoomId == roomId)
|
||||
.Include(m => m.Sender)
|
||||
.Select(m => m.Sender)
|
||||
.FirstOrDefaultAsync();
|
||||
if (replyingTo != null)
|
||||
mentionedUsers.Add(replyingTo.AccountId);
|
||||
}
|
||||
|
||||
// Add sender of a forwarded message
|
||||
if (forwardedMessageId.HasValue)
|
||||
{
|
||||
var forwardedMessage = await db.ChatMessages
|
||||
.Where(m => m.Id == forwardedMessageId.Value)
|
||||
.Select(m => new { m.SenderId })
|
||||
.FirstOrDefaultAsync();
|
||||
if (forwardedMessage != null)
|
||||
{
|
||||
mentionedUsers.Add(forwardedMessage.SenderId);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mentions from content using regex
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
var mentionedNames = MentionRegex()
|
||||
.Matches(content)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (mentionedNames.Count > 0)
|
||||
{
|
||||
var queryRequest = new LookupAccountBatchRequest();
|
||||
queryRequest.Names.AddRange(mentionedNames);
|
||||
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
|
||||
var mentionedIds = queryResponse.Select(a => Guid.Parse(a.Id)).ToList();
|
||||
|
||||
if (mentionedIds.Count > 0)
|
||||
{
|
||||
var mentionedMembers = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && mentionedIds.Contains(m.AccountId))
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Where(m => excludeSenderId == null || m.AccountId != excludeSenderId.Value)
|
||||
.Select(m => m.AccountId)
|
||||
.ToListAsync();
|
||||
mentionedUsers.AddRange(mentionedMembers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mentionedUsers.Distinct().ToList();
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/messages")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "chat.messages.create")]
|
||||
@@ -188,6 +255,7 @@ public partial class ChatController(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Validate reply and forward message IDs exist
|
||||
if (request.RepliedMessageId.HasValue)
|
||||
{
|
||||
var repliedMessage = await db.ChatMessages
|
||||
@@ -208,28 +276,9 @@ public partial class ChatController(
|
||||
message.ForwardedMessageId = forwardedMessage.Id;
|
||||
}
|
||||
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var mentioned = MentionRegex()
|
||||
.Matches(request.Content)
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ToList();
|
||||
if (mentioned.Count > 0)
|
||||
{
|
||||
var queryRequest = new LookupAccountBatchRequest();
|
||||
queryRequest.Names.AddRange(mentioned);
|
||||
var queryResponse = (await accounts.LookupAccountBatchAsync(queryRequest)).Accounts;
|
||||
var mentionedId = queryResponse
|
||||
.Select(a => Guid.Parse(a.Id))
|
||||
.ToList();
|
||||
var mentionedMembers = await db.ChatMembers
|
||||
.Where(m => m.ChatRoomId == roomId && mentionedId.Contains(m.AccountId))
|
||||
.Where(m => m.JoinedAt != null && m.LeaveAt == null)
|
||||
.Select(m => m.Id)
|
||||
.ToListAsync();
|
||||
message.MembersMentioned = mentionedMembers;
|
||||
}
|
||||
}
|
||||
// Extract mentioned users
|
||||
message.MembersMentioned = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId);
|
||||
|
||||
var result = await cs.SendMessageAsync(message, member, member.ChatRoom);
|
||||
|
||||
@@ -259,6 +308,7 @@ public partial class ChatController(
|
||||
(request.AttachmentsId == null || request.AttachmentsId.Count == 0))
|
||||
return BadRequest("You cannot send an empty message.");
|
||||
|
||||
// Validate reply and forward message IDs exist
|
||||
if (request.RepliedMessageId.HasValue)
|
||||
{
|
||||
var repliedMessage = await db.ChatMessages
|
||||
@@ -275,6 +325,11 @@ public partial class ChatController(
|
||||
return BadRequest("The message you're forwarding does not exist.");
|
||||
}
|
||||
|
||||
// Update mentions based on new content and references
|
||||
var updatedMentions = await ExtractMentionedUsersAsync(request.Content, request.RepliedMessageId,
|
||||
request.ForwardedMessageId, roomId, accountId);
|
||||
message.MembersMentioned = updatedMentions;
|
||||
|
||||
// Call service method to update the message
|
||||
await cs.UpdateMessageAsync(
|
||||
message,
|
||||
@@ -324,7 +379,8 @@ public partial class ChatController(
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await db.ChatMembers
|
||||
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
.AnyAsync(m =>
|
||||
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
if (!isMember)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
|
||||
@@ -333,14 +389,16 @@ public partial class ChatController(
|
||||
}
|
||||
|
||||
[HttpPost("{roomId:guid}/autocomplete")]
|
||||
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete([FromBody] AutocompletionRequest request, Guid roomId)
|
||||
public async Task<ActionResult<List<DysonNetwork.Shared.Models.Autocompletion>>> ChatAutoComplete(
|
||||
[FromBody] AutocompletionRequest request, Guid roomId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
return Unauthorized();
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var isMember = await db.ChatMembers
|
||||
.AnyAsync(m => m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
.AnyAsync(m =>
|
||||
m.AccountId == accountId && m.ChatRoomId == roomId && m.JoinedAt != null && m.LeaveAt == null);
|
||||
if (!isMember)
|
||||
return StatusCode(403, "You are not a member of this chat room.");
|
||||
|
||||
|
@@ -15,7 +15,6 @@ public partial class ChatService(
|
||||
FileService.FileServiceClient filesClient,
|
||||
FileReferenceService.FileReferenceServiceClient fileRefs,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IRealtimeService realtime,
|
||||
ILogger<ChatService> logger
|
||||
)
|
||||
{
|
||||
@@ -198,8 +197,6 @@ public partial class ChatService(
|
||||
public async Task<SnChatMessage> SendMessageAsync(SnChatMessage message, SnChatMember sender, SnChatRoom room)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
||||
message.CreatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
message.UpdatedAt = message.CreatedAt;
|
||||
|
||||
// First complete the save operation
|
||||
db.ChatMessages.Add(message);
|
||||
@@ -209,20 +206,25 @@ public partial class ChatService(
|
||||
await CreateFileReferencesForMessageAsync(message);
|
||||
|
||||
// Then start the delivery process
|
||||
var localMessage = message;
|
||||
var localSender = sender;
|
||||
var localRoom = room;
|
||||
var localLogger = logger;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeliverMessageAsync(message, sender, room);
|
||||
await DeliverMessageAsync(localMessage, localSender, localRoom);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
||||
localLogger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
||||
}
|
||||
});
|
||||
|
||||
// Process link preview in the background to avoid delaying message sending
|
||||
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
|
||||
var localMessageForPreview = message;
|
||||
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(localMessageForPreview));
|
||||
|
||||
message.Sender = sender;
|
||||
message.ChatRoom = room;
|
||||
@@ -532,27 +534,10 @@ public partial class ChatService(
|
||||
{
|
||||
RoomId = room.Id,
|
||||
SenderId = sender.Id,
|
||||
ProviderName = realtime.ProviderName
|
||||
ProviderName = "Built-in WebRTC Signaling",
|
||||
SessionId = Guid.NewGuid().ToString() // Simple session ID for built-in signaling
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var sessionConfig = await realtime.CreateSessionAsync(room.Id, new Dictionary<string, object>
|
||||
{
|
||||
{ "room_id", room.Id },
|
||||
{ "user_id", sender.AccountId },
|
||||
});
|
||||
|
||||
// Store session details
|
||||
call.SessionId = sessionConfig.SessionId;
|
||||
call.UpstreamConfig = sessionConfig.Parameters;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the exception but continue with call creation
|
||||
throw new InvalidOperationException($"Failed to create {realtime.ProviderName} session: {ex.Message}");
|
||||
}
|
||||
|
||||
db.ChatRealtimeCall.Add(call);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
@@ -577,26 +562,7 @@ public partial class ChatService(
|
||||
if (sender.Role < ChatMemberRole.Moderator && call.SenderId != sender.Id)
|
||||
throw new InvalidOperationException("You are not the call initiator either the chat room moderator.");
|
||||
|
||||
// End the realtime session if it exists
|
||||
if (!string.IsNullOrEmpty(call.SessionId) && !string.IsNullOrEmpty(call.ProviderName))
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = new RealtimeSessionConfig
|
||||
{
|
||||
SessionId = call.SessionId,
|
||||
Parameters = call.UpstreamConfig
|
||||
};
|
||||
|
||||
await realtime.EndSessionAsync(call.SessionId, config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the exception but continue with call ending
|
||||
throw new InvalidOperationException($"Failed to end {call.ProviderName} session: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// For built-in WebRTC signaling, just set the end time
|
||||
call.EndedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
db.ChatRealtimeCall.Update(call);
|
||||
await db.SaveChangesAsync();
|
||||
|
639
DysonNetwork.Sphere/Chat/Realtime/README.md
Normal file
639
DysonNetwork.Sphere/Chat/Realtime/README.md
Normal file
@@ -0,0 +1,639 @@
|
||||
# WebRTC Signaling Server - Client Implementation Guide
|
||||
|
||||
This document explains how clients should implement WebRTC signaling to work with the DysonNetwork WebRTC server.
|
||||
|
||||
## Overview
|
||||
|
||||
The WebRTC signaling server provides a WebSocket-based signaling channel for WebRTC peer-to-peer communication within chat rooms. It handles authentication, room membership verification, and message broadcasting between clients in the same chat room.
|
||||
|
||||
When using with the Gateway, the `/api` should be replaced with `<gateway>/sphere`
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Signaling Endpoint**: `GET /api/chat/realtime/{chatId}`
|
||||
- **Authentication**: JWT-based (handled by existing middleware)
|
||||
- **Message Format**: WebSocketPacket (structured JSON packets)
|
||||
- **Protocol**: Room-based broadcasting with client management and enforced sender validation
|
||||
|
||||
## Client Implementation
|
||||
|
||||
### 1. Prerequisites
|
||||
|
||||
Before implementing WebRTC signaling, ensure your client:
|
||||
|
||||
1. **Has Valid Authentication**: Must provide a valid JWT token for the authenticated user
|
||||
2. **Is a Chat Room Member**: User must be an active member of the specified chat room
|
||||
3. **Supports WebSockets**: Must be capable of establishing WebSocket connections
|
||||
|
||||
### 2. Connection Establishment
|
||||
|
||||
#### 2.1 WebSocket Connection URL
|
||||
|
||||
```
|
||||
ws://your-server.com/api/chat/realtime/{chatId}
|
||||
```
|
||||
|
||||
- **Protocol**: `ws://` (or `wss://` for secure connections)
|
||||
- **Path**: `/api/chat/realtime/{chatId}` where `{chatId}` is the chat room GUID
|
||||
- **Authentication**: Handled via existing JWT middleware (no additional query parameters needed)
|
||||
|
||||
#### 2.2 Authentication
|
||||
|
||||
The authentication is handled automatically by the server's middleware that:
|
||||
|
||||
1. Checks for valid JWT token in the request
|
||||
2. Extracts the authenticated user (`Account`) from `HttpContext.Items["CurrentUser"]`
|
||||
3. Validates that the user is a member of the specified chat room
|
||||
4. Returns `401 Unauthorized` if not authenticated or `403 Forbidden` if not a room member
|
||||
|
||||
#### 2.3 Connection Example (JavaScript)
|
||||
|
||||
```javascript
|
||||
class SignalingClient {
|
||||
constructor(chatId, serverUrl = 'ws://localhost:5000', userId, userName) {
|
||||
this.chatId = chatId;
|
||||
this.ws = null;
|
||||
this.serverUrl = serverUrl;
|
||||
this.isConnected = false;
|
||||
this.userId = userId; // Current user ID
|
||||
this.userName = userName; // Current user name
|
||||
this.onMessageHandlers = [];
|
||||
}
|
||||
|
||||
// Connect to the signaling server
|
||||
async connect() {
|
||||
const url = `${this.serverUrl}/api/chat/realtime/${this.chatId}`;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = (event) => {
|
||||
this.isConnected = true;
|
||||
console.log('Connected to signaling server for chat:', this.chatId);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.handleMessage(event.data);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.isConnected = false;
|
||||
console.log('Disconnected from signaling server');
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to signaling server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from the signaling server
|
||||
disconnect() {
|
||||
if (this.ws && this.isConnected) {
|
||||
this.ws.close();
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Message Handling
|
||||
|
||||
#### 3.1 Enforced Message Format
|
||||
|
||||
The signaling server broadcasts messages using the WebSocketPacket format. All messages are automatically wrapped by the server with validated sender information. Clients should send only the signaling type and data, and receive complete packets with sender details.
|
||||
|
||||
**WebSocketPacket Format:**
|
||||
|
||||
For signaling messages (see SignalingMessage model):
|
||||
```json
|
||||
{
|
||||
"type": "webrtc.signal",
|
||||
"data": {
|
||||
"type": "signaling-message-type",
|
||||
"data": {
|
||||
"offer": "...SDP string here...",
|
||||
"answer": "...SDP string here...",
|
||||
"candidate": {...ICE candidate data...}
|
||||
},
|
||||
"to": "optional-target-user-id-for-directed-messaging",
|
||||
"senderAccountId": "server-validated-user-guid",
|
||||
"senderInfo": {
|
||||
"id": "user-guid",
|
||||
"name": "username",
|
||||
"nick": "display nickname",
|
||||
"profile": {},
|
||||
"updatedAt": "2022-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For connection established:
|
||||
```json
|
||||
{
|
||||
"type": "webrtc",
|
||||
"data": {
|
||||
"userId": "user-guid",
|
||||
"roomId": "room-guid",
|
||||
"message": "Connected to call...",
|
||||
"timestamp": "2022-01-01T00:00:00Z",
|
||||
"participants": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Incoming Messages
|
||||
|
||||
Implement a message handler to process signaling data with user identity:
|
||||
|
||||
```javascript
|
||||
class SignalingClient {
|
||||
constructor(chatId, serverUrl = 'ws://localhost:5000', userId, userName) {
|
||||
this.chatId = chatId;
|
||||
this.ws = null;
|
||||
this.serverUrl = serverUrl;
|
||||
this.isConnected = false;
|
||||
this.userId = userId; // Current user ID
|
||||
this.userName = userName; // Current user name
|
||||
this.onMessageHandlers = [];
|
||||
}
|
||||
|
||||
// ... WebSocket connection methods ...
|
||||
|
||||
handleMessage(message) {
|
||||
try {
|
||||
// Parse WebSocketPacket
|
||||
const packet = JSON.parse(message);
|
||||
|
||||
if (packet.type === 'signaling') {
|
||||
// Extract signaling message with server-validated sender info
|
||||
const signalingMessage = packet.data;
|
||||
const senderId = signalingMessage.SenderAccountId;
|
||||
const senderInfo = signalingMessage.SenderInfo;
|
||||
|
||||
// Ignore messages from yourself (server broadcasts to all clients)
|
||||
if (senderId === this.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use sender's nick or name for display
|
||||
const senderDisplay = senderInfo?.nick || senderInfo?.name || senderId;
|
||||
console.log(`Received ${signalingMessage.type} from ${senderDisplay} (${senderId})`);
|
||||
|
||||
// Call handlers with signal type and data and sender info
|
||||
this.onMessageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(signalingMessage, senderId, senderInfo);
|
||||
} catch (error) {
|
||||
console.error('Error in message handler:', error);
|
||||
}
|
||||
});
|
||||
} else if (packet.type === 'webrtc') {
|
||||
// Handle connection established or other server messages
|
||||
console.log('Received server message:', packet.data.message);
|
||||
} else {
|
||||
console.warn('Unknown packet type:', packet.type);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocketPacket:', message, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Register message handlers
|
||||
onMessage(handler) {
|
||||
this.onMessageHandlers.push(handler);
|
||||
return () => {
|
||||
// Return unsubscribe function
|
||||
const index = this.onMessageHandlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
this.onMessageHandlers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
sendMessage(messageData) {
|
||||
if (!this.isConnected || !this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('Cannot send message: WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Server will automatically add sender info - just send the signaling data
|
||||
const messageStr = JSON.stringify(messageData);
|
||||
this.ws.send(messageStr);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3 User Identity Tracking
|
||||
|
||||
Track connected peers with full account information:
|
||||
|
||||
```javascript
|
||||
class SignalingClient {
|
||||
constructor(chatId, serverUrl, userId, userName) {
|
||||
this.chatId = chatId;
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
this.serverUrl = serverUrl;
|
||||
this.ws = null;
|
||||
this.isConnected = false;
|
||||
this.connectedPeers = new Map(); // userId -> senderInfo
|
||||
this.onPeerHandlers = [];
|
||||
this.onMessageHandlers = [];
|
||||
}
|
||||
|
||||
handleMessage(message) {
|
||||
try {
|
||||
const packet = JSON.parse(message);
|
||||
|
||||
if (packet.type === 'signaling') {
|
||||
const signalingMessage = packet.data;
|
||||
const senderId = signalingMessage.SenderAccountId;
|
||||
const senderInfo = signalingMessage.SenderInfo;
|
||||
|
||||
// Track peer information with full account data
|
||||
if (!this.connectedPeers.has(senderId)) {
|
||||
this.connectedPeers.set(senderId, senderInfo);
|
||||
this.onPeerHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(senderId, senderInfo, 'connected');
|
||||
} catch (error) {
|
||||
console.error('Error in peer handler:', error);
|
||||
}
|
||||
});
|
||||
console.log(`New peer connected: ${senderInfo?.name || senderId} (${senderId})`);
|
||||
}
|
||||
|
||||
// Ignore messages from yourself
|
||||
if (senderId === this.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Call handlers with signaling message and sender info
|
||||
this.onMessageHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(signalingMessage, senderId, senderInfo);
|
||||
} catch (error) {
|
||||
console.error('Error in message handler:', error);
|
||||
}
|
||||
});
|
||||
} else if (packet.type === 'webrtc') {
|
||||
// Handle connection established or other server messages
|
||||
console.log('Received server message:', packet.data.message);
|
||||
} else {
|
||||
console.warn('Unknown packet type:', packet.type);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocketPacket:', message, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Register peer connection/disconnection handlers
|
||||
onPeer(handler) {
|
||||
this.onPeerHandlers.push(handler);
|
||||
return () => {
|
||||
const index = this.onPeerHandlers.indexOf(handler);
|
||||
if (index > -1) {
|
||||
this.onPeerHandlers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get list of connected peers with full account info
|
||||
getConnectedPeers() {
|
||||
return Array.from(this.connectedPeers.entries()).map(([userId, senderInfo]) => ({
|
||||
userId,
|
||||
userInfo: senderInfo
|
||||
}));
|
||||
}
|
||||
|
||||
// Find user info by user ID
|
||||
getUserInfo(userId) {
|
||||
return this.connectedPeers.get(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. WebRTC Integration
|
||||
|
||||
#### 4.1 Complete Implementation Example
|
||||
|
||||
```javascript
|
||||
class WebRTCCPUB extends SignalingClient {
|
||||
constructor(chatId, serverUrl) {
|
||||
super(chatId, serverUrl);
|
||||
this.peerConnection = null;
|
||||
this.localStream = null;
|
||||
this.remoteStream = null;
|
||||
|
||||
// Initialize WebRTCPeerConnection with configuration
|
||||
this.initPeerConnection();
|
||||
}
|
||||
|
||||
initPeerConnection() {
|
||||
const configuration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
this.peerConnection = new RTCPeerConnection(configuration);
|
||||
|
||||
// Handle ICE candidates
|
||||
this.peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
// Send ICE candidate via signaling server
|
||||
this.sendMessage({
|
||||
type: 'ice-candidate',
|
||||
candidate: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remote stream
|
||||
this.peerConnection.ontrack = (event) => {
|
||||
this.remoteStream = event.streams[0];
|
||||
// Attach remote stream to video element
|
||||
if (this.onRemoteStream) {
|
||||
this.onRemoteStream(this.remoteStream);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Register for signaling messages
|
||||
onMessage(signalingMessage, senderId, senderInfo) {
|
||||
super.onMessage(signalingMessage, senderId, senderInfo).then(() => {
|
||||
this.handleSignalingMessage(signalingMessage);
|
||||
});
|
||||
}
|
||||
|
||||
handleSignalingMessage(signalingMessage) {
|
||||
switch (signalingMessage.type) {
|
||||
case 'offer':
|
||||
this.handleOffer(signalingMessage.data.offer);
|
||||
break;
|
||||
case 'answer':
|
||||
this.handleAnswer(signalingMessage.data.answer);
|
||||
break;
|
||||
case 'ice-candidate':
|
||||
this.handleIceCandidate(signalingMessage.data.candidate);
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown message type:', signalingMessage.type);
|
||||
}
|
||||
}
|
||||
|
||||
async createOffer() {
|
||||
try {
|
||||
const offer = await this.peerConnection.createOffer();
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
|
||||
// Send offer via signaling server
|
||||
this.sendMessage({
|
||||
type: 'offer',
|
||||
offer: offer
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating offer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleOffer(offer) {
|
||||
try {
|
||||
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
|
||||
// Send answer via signaling server
|
||||
this.sendMessage({
|
||||
type: 'answer',
|
||||
answer: answer
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling offer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleAnswer(answer) {
|
||||
try {
|
||||
await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
} catch (error) {
|
||||
console.error('Error handling answer:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIceCandidate(candidate) {
|
||||
try {
|
||||
await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
} catch (error) {
|
||||
console.error('Error handling ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get user media and add to peer connection
|
||||
async startLocalStream(constraints = { audio: true, video: true }) {
|
||||
try {
|
||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
this.peerConnection.addTrack(track, this.localStream);
|
||||
});
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('Error accessing media devices:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Usage Flow
|
||||
|
||||
#### 5.1 Basic Usage Pattern
|
||||
|
||||
```javascript
|
||||
// 1. Create signaling client
|
||||
const signaling = new WebRTCCPUB(chatId, serverUrl);
|
||||
|
||||
// 2. Set up event handlers
|
||||
signaling.onRemoteStream = (stream) => {
|
||||
// Attach remote stream to video element
|
||||
remoteVideoElement.srcObject = stream;
|
||||
};
|
||||
|
||||
// 3. Connect to signaling server
|
||||
await signaling.connect();
|
||||
|
||||
// 4. Get local media stream
|
||||
await signaling.startLocalStream();
|
||||
|
||||
// 5. Create offer (for the caller)
|
||||
await signaling.createOffer();
|
||||
|
||||
// The signaling server will automatically broadcast messages to other clients in the room
|
||||
```
|
||||
|
||||
#### 5.2 Complete Call Flow Example
|
||||
|
||||
```javascript
|
||||
async function initiateCall(chatId, serverUrl) {
|
||||
const caller = new WebRTCCPUB(chatId, serverUrl);
|
||||
|
||||
// Connect to signaling server
|
||||
await caller.connect();
|
||||
|
||||
// Get local stream
|
||||
const localStream = await caller.startLocalStream();
|
||||
localVideoElement.srcObject = localStream;
|
||||
|
||||
// Create and send offer
|
||||
await caller.createOffer();
|
||||
|
||||
// Wait for remote stream
|
||||
caller.onRemoteStream = (remoteStream) => {
|
||||
remoteVideoElement.srcObject = remoteStream;
|
||||
console.log('Call connected!');
|
||||
};
|
||||
}
|
||||
|
||||
async function answerCall(chatId, serverUrl) {
|
||||
const answerer = new WebRTCCPUB(chatId, serverUrl);
|
||||
|
||||
// Connect to signaling server
|
||||
await answerer.connect();
|
||||
|
||||
// Get local stream
|
||||
const localStream = await answerer.startLocalStream();
|
||||
localVideoElement.srcObject = localStream;
|
||||
|
||||
// WebRTC signaling is handled automatically by the message handlers
|
||||
answerer.onRemoteStream = (remoteStream) => {
|
||||
remoteVideoElement.srcObject = remoteStream;
|
||||
console.log('Call connected!');
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Error Handling
|
||||
|
||||
#### 6.1 Connection Errors
|
||||
|
||||
```javascript
|
||||
// Handle connection errors
|
||||
signaling.ws.addEventListener('error', (event) => {
|
||||
console.error('WebSocket connection error:', event);
|
||||
// Attempt reconnection or show error to user
|
||||
});
|
||||
|
||||
// Handle server close
|
||||
signaling.ws.addEventListener('close', (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason);
|
||||
|
||||
// Reconnect if clean closure
|
||||
if (event.wasClean) {
|
||||
// Re-establish connection if needed
|
||||
} else {
|
||||
// Report error
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### 6.2 WebRTC Errors
|
||||
|
||||
```javascript
|
||||
// Handle getUserMedia errors
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
} catch (error) {
|
||||
switch (error.name) {
|
||||
case 'NotAllowedError':
|
||||
console.error('User denied media access');
|
||||
break;
|
||||
case 'NotFoundError':
|
||||
console.error('No media devices found');
|
||||
break;
|
||||
default:
|
||||
console.error('Error accessing media:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Best Practices
|
||||
|
||||
#### 7.1 Connection Management
|
||||
- **Reconnection Logic**: Implement exponential backoff for reconnection attempts
|
||||
- **Connection Pooling**: Re-use connections when possible
|
||||
- **Cleanup**: Always close connections and clean up resources
|
||||
|
||||
#### 7.2 Message Handling
|
||||
- **Message Validation**: Validate incoming signaling messages
|
||||
- **Error Resilience**: Gracefully handle malformed messages
|
||||
- **Message Types**: Define clear message type conventions
|
||||
|
||||
#### 7.3 WebRTC Configuration
|
||||
- **ICE Servers**: Configure multiple STUN/TURN servers for reliability
|
||||
- **Codec Preferences**: Set preferred codecs for optimal performance
|
||||
- **Bandwidth Management**: Implement appropriate bitrate controls
|
||||
|
||||
#### 7.4 Security Considerations
|
||||
- **Input Validation**: Validate all signaling data
|
||||
- **Rate Limiting**: Implement appropriate rate limiting for signaling messages
|
||||
- **Authentication**: Ensure proper authentication before establishing connections
|
||||
|
||||
### 8. Room Isolation
|
||||
|
||||
The signaling server guarantees that:
|
||||
- **Messages stay within rooms**: Clients only receive messages from other clients in the same chat room
|
||||
- **Authentication per connection**: Each WebSocket connection is individually authenticated
|
||||
- **Member validation**: Only active chat room members can connect and send messages
|
||||
|
||||
### 9. Troubleshooting
|
||||
|
||||
#### 9.1 Common Issues
|
||||
- **Connection refused**: Check if JWT token is valid and user is room member
|
||||
- **Messages not received**: Verify room membership and connection status
|
||||
- **WebRTC failures**: Check ICE server configuration and network connectivity
|
||||
|
||||
#### 9.2 Debug Tips
|
||||
- Enable console logging for signaling events
|
||||
- Monitor WebSocket connection state
|
||||
- Validate signaling message formats
|
||||
- Check browser developer tools for network activity
|
||||
|
||||
## API Reference
|
||||
|
||||
### WebSocket Endpoint
|
||||
- **URL Pattern**: `/api/chat/realtime/{chatId}`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: JWT (middleware-handled)
|
||||
- **Protocol**: WebSocket (ws/wss)
|
||||
|
||||
### Response Codes
|
||||
- **401**: Unauthorized - Invalid or missing JWT
|
||||
- **403**: Forbidden - User not member of chat room
|
||||
- **400**: Bad Request - Not a WebSocket request
|
||||
|
||||
### Message Format
|
||||
- **Encoding**: UTF-8 text
|
||||
- **Format**: WebSocketPacket JSON (server-enforced structure)
|
||||
- **Broadcasting**: Automatic to all room members except sender with validated sender information
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [WebRTC API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
|
||||
- [WebSocket API Documentation](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
|
||||
- [WebRTC Signaling Fundamentals](https://webrtc.org/getting-started/signaling-channels)
|
@@ -1,10 +1,12 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Chat.Realtime;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
|
||||
|
||||
namespace DysonNetwork.Sphere.Chat;
|
||||
|
||||
@@ -13,6 +15,15 @@ public class RealtimeChatConfiguration
|
||||
public string Endpoint { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class SignalingMessage
|
||||
{
|
||||
public string Type { get; set; } = null!;
|
||||
public object? Data { get; set; }
|
||||
public string? To { get; set; }
|
||||
public string? AccountId { get; set; }
|
||||
public SnAccount? Account { get; set; }
|
||||
}
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/chat/realtime")]
|
||||
public class RealtimeCallController(
|
||||
@@ -20,31 +31,36 @@ public class RealtimeCallController(
|
||||
AppDatabase db,
|
||||
ChatService cs,
|
||||
ChatRoomService crs,
|
||||
IRealtimeService realtime
|
||||
ILogger<RealtimeCallController> logger
|
||||
) : ControllerBase
|
||||
{
|
||||
private readonly RealtimeChatConfiguration _config =
|
||||
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
|
||||
|
||||
// A thread-safe collection to hold connected WebSocket clients per chat room.
|
||||
private static readonly
|
||||
ConcurrentDictionary<string, ConcurrentDictionary<Guid, (WebSocket Socket, string
|
||||
AccountId, int Role)>> RoomClients = new();
|
||||
|
||||
// A thread-safe collection to hold participants in each room.
|
||||
private static readonly
|
||||
ConcurrentDictionary<string, ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>>
|
||||
RoomParticipants = new();
|
||||
|
||||
/// <summary>
|
||||
/// This endpoint is especially designed for livekit webhooks,
|
||||
/// for update the call participates and more.
|
||||
/// Learn more at: https://docs.livekit.io/home/server/webhooks/
|
||||
/// This endpoint is for WebRTC signaling webhooks if needed in the future.
|
||||
/// Currently built-in WebRTC signaling doesn't require external webhooks.
|
||||
/// </summary>
|
||||
[HttpPost("webhook")]
|
||||
[SwaggerIgnore]
|
||||
public async Task<IActionResult> WebhookReceiver()
|
||||
public Task<IActionResult> WebhookReceiver()
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var postData = await reader.ReadToEndAsync();
|
||||
var authHeader = Request.Headers.Authorization.ToString();
|
||||
|
||||
await realtime.ReceiveWebhook(postData, authHeader);
|
||||
|
||||
return Ok();
|
||||
// Built-in WebRTC signaling doesn't require webhooks
|
||||
// Return success to indicate endpoint exists for potential future use
|
||||
return Task.FromResult<IActionResult>(Ok("Webhook received - built-in WebRTC signaling active"));
|
||||
}
|
||||
|
||||
[HttpGet("{roomId:guid}")]
|
||||
[HttpGet("{roomId:guid}/status")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SnRealtimeCall>> GetOngoingCall(Guid roomId)
|
||||
{
|
||||
@@ -94,46 +110,32 @@ public class RealtimeCallController(
|
||||
return BadRequest("Call session is not properly configured.");
|
||||
|
||||
var isAdmin = member.Role >= ChatMemberRole.Moderator;
|
||||
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
|
||||
|
||||
// Get LiveKit endpoint from configuration
|
||||
// Get WebRTC signaling server endpoint from configuration
|
||||
var endpoint = _config.Endpoint ??
|
||||
throw new InvalidOperationException("LiveKit endpoint configuration is missing");
|
||||
throw new InvalidOperationException("WebRTC signaling endpoint configuration is missing");
|
||||
|
||||
// Inject the ChatRoomService
|
||||
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
|
||||
|
||||
// Get current participants from the LiveKit service
|
||||
// Get current participants from the participant list
|
||||
var participants = new List<CallParticipant>();
|
||||
if (realtime is LiveKitRealtimeService livekitService)
|
||||
var roomKey = ongoingCall.RoomId.ToString();
|
||||
if (RoomParticipants.TryGetValue(roomKey, out var partsDict))
|
||||
{
|
||||
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId);
|
||||
participants = [];
|
||||
|
||||
foreach (var p in roomParticipants)
|
||||
{
|
||||
var participant = new CallParticipant
|
||||
participants.AddRange(from part in partsDict.Values
|
||||
select new CallParticipant
|
||||
{
|
||||
Identity = p.Identity,
|
||||
Name = p.Name,
|
||||
AccountId = p.AccountId,
|
||||
JoinedAt = p.JoinedAt
|
||||
};
|
||||
|
||||
// Fetch the ChatMember profile if we have an account ID
|
||||
if (p.AccountId.HasValue)
|
||||
participant.Profile = await chatRoomService.GetRoomMember(p.AccountId.Value, roomId);
|
||||
|
||||
participants.Add(participant);
|
||||
}
|
||||
Identity = part.Account.Id,
|
||||
Name = part.Account.Name,
|
||||
AccountId = Guid.Parse(part.Account.Id),
|
||||
JoinedAt = part.JoinedAt
|
||||
});
|
||||
}
|
||||
|
||||
// Create the response model
|
||||
// Create the response model for built-in WebRTC signaling
|
||||
var response = new JoinCallResponse
|
||||
{
|
||||
Provider = realtime.ProviderName,
|
||||
Provider = "Built-in WebRTC Signaling",
|
||||
Endpoint = endpoint,
|
||||
Token = userToken,
|
||||
Token = "", // No external token needed for built-in signaling
|
||||
CallId = ongoingCall.Id,
|
||||
RoomName = ongoingCall.SessionId,
|
||||
IsAdmin = isAdmin,
|
||||
@@ -186,6 +188,212 @@ public class RealtimeCallController(
|
||||
return BadRequest(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket signaling endpoint for WebRTC calls in a specific chat room.
|
||||
/// Path: /api/chat/realtime/{chatId}
|
||||
/// Requires JWT authentication (handled by middleware).
|
||||
/// </summary>
|
||||
[HttpGet("{chatId:guid}")]
|
||||
public async Task SignalingWebSocket(Guid chatId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||
{
|
||||
HttpContext.Response.StatusCode = 401;
|
||||
await HttpContext.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the user is a member of the chat room
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var member = await db.ChatMembers
|
||||
.Where(m => m.AccountId == accountId && m.ChatRoomId == chatId && m.JoinedAt != null && m.LeaveAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (member == null || member.Role < ChatMemberRole.Member)
|
||||
{
|
||||
HttpContext.Response.StatusCode = 403;
|
||||
await HttpContext.Response.WriteAsync("Forbidden: Not a member of this chat room");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!HttpContext.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
HttpContext.Response.StatusCode = 400;
|
||||
await HttpContext.Response.WriteAsync("Bad Request: WebSocket connection expected");
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
|
||||
var clientId = Guid.NewGuid();
|
||||
|
||||
// Add a client to the room-specific clients dictionary
|
||||
var roomKey = chatId.ToString();
|
||||
var roomDict = RoomClients.GetOrAdd(roomKey,
|
||||
_ => new ConcurrentDictionary<Guid, (WebSocket, string, int)>());
|
||||
roomDict.TryAdd(clientId, (webSocket, currentUser.Id, member.Role));
|
||||
|
||||
// Add to the participant list
|
||||
var participantsDict = RoomParticipants.GetOrAdd(roomKey,
|
||||
_ => new ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>());
|
||||
var wasAdded = participantsDict.TryAdd(currentUser.Id, (currentUser, DateTime.UtcNow));
|
||||
|
||||
logger.LogInformation(
|
||||
"WebRTC signaling client connected: {ClientId} ({UserId}) in room {RoomId}. Total clients in room: {Count}",
|
||||
clientId, currentUser.Id, chatId, roomDict.Count);
|
||||
|
||||
// Get other participants as CallParticipant objects
|
||||
var otherParticipants = participantsDict.Values
|
||||
.Where(p => p.Account.Id != currentUser.Id)
|
||||
.Select(p => new CallParticipant
|
||||
{
|
||||
Identity = p.Account.Id,
|
||||
Name = p.Account.Name,
|
||||
AccountId = Guid.Parse(p.Account.Id),
|
||||
Account = SnAccount.FromProtoValue(p.Account),
|
||||
JoinedAt = p.JoinedAt
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var welcomePacket = new WebSocketPacket
|
||||
{
|
||||
Type = "webrtc",
|
||||
Data = new
|
||||
{
|
||||
userId = currentUser.Id,
|
||||
roomId = chatId,
|
||||
message = $"Connected to call of #{chatId}.",
|
||||
timestamp = DateTime.UtcNow.ToString("o"),
|
||||
participants = otherParticipants
|
||||
}
|
||||
};
|
||||
var responseBytes = welcomePacket.ToBytes();
|
||||
await webSocket.SendAsync(new ArraySegment<byte>(responseBytes), WebSocketMessageType.Text, true,
|
||||
CancellationToken.None);
|
||||
|
||||
// Broadcast user-joined to existing clients if this is the first connection for this user in the room
|
||||
if (wasAdded)
|
||||
{
|
||||
var joinPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "webrtc.signal",
|
||||
Data = new SignalingMessage
|
||||
{
|
||||
Type = "user-joined",
|
||||
AccountId = currentUser.Id,
|
||||
Account = SnAccount.FromProtoValue(currentUser),
|
||||
Data = new { }
|
||||
}
|
||||
};
|
||||
await BroadcastMessageToRoom(chatId, clientId, joinPacket);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use a MemoryStream to build the full message from potentially multiple chunks.
|
||||
using var ms = new MemoryStream();
|
||||
// A larger buffer can be more efficient, but the loop is what handles correctness.
|
||||
var buffer = new byte[1024 * 8];
|
||||
|
||||
while (webSocket.State == WebSocketState.Open)
|
||||
{
|
||||
ms.SetLength(0); // Clear the stream for the new message.
|
||||
WebSocketReceiveResult result;
|
||||
do
|
||||
{
|
||||
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
} while (!result.EndOfMessage);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
break;
|
||||
|
||||
var packet = WebSocketPacket.FromBytes(ms.ToArray());
|
||||
var signalingMessage = packet.GetData<SignalingMessage>();
|
||||
if (signalingMessage is null)
|
||||
{
|
||||
logger.LogWarning("Signaling message could not be parsed, dismissed...");
|
||||
continue;
|
||||
}
|
||||
|
||||
signalingMessage.AccountId = currentUser.Id;
|
||||
signalingMessage.Account = SnAccount.FromProtoValue(currentUser);
|
||||
var broadcastPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "webrtc.signal",
|
||||
Data = signalingMessage
|
||||
};
|
||||
|
||||
logger.LogDebug("Message received from {ClientId} ({UserId}): Type={MessageType}", clientId, currentUser.Id, signalingMessage.Type);
|
||||
await BroadcastMessageToRoom(chatId, clientId, broadcastPacket);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException wsex) when (wsex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
|
||||
{
|
||||
// This is an expected exception when a client closes the browser tab.
|
||||
logger.LogDebug("WebRTC signaling client connection was closed prematurely for user {UserId}",
|
||||
currentUser.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error with WebRTC signaling client connection for user {UserId}", currentUser.Id);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove the client from the room
|
||||
if (roomDict.TryRemove(clientId, out _))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"WebRTC signaling client disconnected: {ClientId} ({UserId}). Total clients in room: {Count}",
|
||||
clientId, currentUser.Id, roomDict.Count);
|
||||
|
||||
// If no more connections from this account, remove from participants
|
||||
if (roomDict.Values.All(v => v.AccountId != currentUser.Id))
|
||||
{
|
||||
var tempParticipantsDict = RoomParticipants.GetOrAdd(roomKey,
|
||||
_ => new ConcurrentDictionary<string, (Account Account, DateTime JoinedAt)>());
|
||||
if (tempParticipantsDict.TryRemove(currentUser.Id, out _))
|
||||
{
|
||||
logger.LogInformation("Participant {UserId} removed from room {RoomId}", currentUser.Id,
|
||||
chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
webSocket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BroadcastMessageToRoom(Guid roomId, Guid senderId, WebSocketPacket packet)
|
||||
{
|
||||
var roomKey = roomId.ToString();
|
||||
if (!RoomClients.TryGetValue(roomKey, out var roomDict))
|
||||
return;
|
||||
|
||||
var messageBytes = packet.ToBytes();
|
||||
var segment = new ArraySegment<byte>(messageBytes);
|
||||
|
||||
var signalingMessage = packet.GetData<SignalingMessage>();
|
||||
var targetAccountId = signalingMessage?.To;
|
||||
|
||||
foreach (var pair in roomDict)
|
||||
{
|
||||
// Skip sender unless it's broadcast message
|
||||
if (!string.IsNullOrEmpty(targetAccountId) && pair.Key == senderId) continue;
|
||||
|
||||
// If directed message, only send to target
|
||||
if (!string.IsNullOrEmpty(targetAccountId) && pair.Value.AccountId != targetAccountId) continue;
|
||||
|
||||
if (pair.Value.Socket.State != WebSocketState.Open) continue;
|
||||
await pair.Value.Socket.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
logger.LogDebug("Message broadcasted to {ClientId} in room {RoomId}", pair.Key, roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Response model for joining a call
|
||||
@@ -250,7 +458,7 @@ public class CallParticipant
|
||||
/// <summary>
|
||||
/// The participant's profile in the chat
|
||||
/// </summary>
|
||||
public SnChatMember? Profile { get; set; }
|
||||
public SnAccount? Account { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the participant joined the call
|
||||
|
0
DysonNetwork.Sphere/Post/AccountHelperClient.cs
Normal file
0
DysonNetwork.Sphere/Post/AccountHelperClient.cs
Normal file
@@ -4,6 +4,7 @@ using DysonNetwork.Shared.Auth;
|
||||
using DysonNetwork.Shared.Data;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Shared.Registry;
|
||||
using DysonNetwork.Sphere.Poll;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.WebReader;
|
||||
@@ -23,6 +24,7 @@ public class PostController(
|
||||
AppDatabase db,
|
||||
PostService ps,
|
||||
PublisherService pub,
|
||||
AccountClientHelper accountsHelper,
|
||||
AccountService.AccountServiceClient accounts,
|
||||
ActionLogService.ActionLogServiceClient als,
|
||||
PaymentService.PaymentServiceClient payments,
|
||||
@@ -97,7 +99,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -197,7 +199,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -228,7 +230,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -271,6 +273,14 @@ public class PostController(
|
||||
.Take(take)
|
||||
.Skip(offset)
|
||||
.ToListAsync();
|
||||
|
||||
var accountsProto = await accountsHelper.GetAccountBatch(reactions.Select(r => r.AccountId).ToList());
|
||||
var accounts = accountsProto.ToDictionary(a => Guid.Parse(a.Id), a => SnAccount.FromProtoValue(a));
|
||||
|
||||
foreach (var reaction in reactions)
|
||||
if (accounts.TryGetValue(reaction.AccountId, out var account))
|
||||
reaction.Account = account;
|
||||
|
||||
return Ok(reactions);
|
||||
}
|
||||
|
||||
@@ -283,7 +293,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -314,7 +324,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -342,7 +352,7 @@ public class PostController(
|
||||
if (currentUser != null)
|
||||
{
|
||||
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id });
|
||||
{ AccountId = currentUser.Id });
|
||||
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
}
|
||||
|
||||
@@ -448,7 +458,10 @@ public class PostController(
|
||||
|
||||
if (request.RepliedPostId is not null)
|
||||
{
|
||||
var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value);
|
||||
var repliedPost = await db.Posts
|
||||
.Where(p => p.Id == request.RepliedPostId.Value)
|
||||
.Include(p => p.Publisher)
|
||||
.FirstOrDefaultAsync();
|
||||
if (repliedPost is null) return BadRequest("Post replying to was not found.");
|
||||
post.RepliedPost = repliedPost;
|
||||
post.RepliedPostId = repliedPost.Id;
|
||||
@@ -456,7 +469,10 @@ public class PostController(
|
||||
|
||||
if (request.ForwardedPostId is not null)
|
||||
{
|
||||
var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value);
|
||||
var forwardedPost = await db.Posts
|
||||
.Where(p => p.Id == request.ForwardedPostId.Value)
|
||||
.Include(p => p.Publisher)
|
||||
.FirstOrDefaultAsync();
|
||||
if (forwardedPost is null) return BadRequest("Forwarded post was not found.");
|
||||
post.ForwardedPost = forwardedPost;
|
||||
post.ForwardedPostId = forwardedPost.Id;
|
||||
@@ -536,7 +552,7 @@ public class PostController(
|
||||
|
||||
var friendsResponse =
|
||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||
|
||||
@@ -632,7 +648,7 @@ public class PostController(
|
||||
|
||||
var friendsResponse =
|
||||
await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
{ AccountId = currentUser.Id.ToString() });
|
||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||
|
||||
|
@@ -41,7 +41,6 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
|
||||
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||
|
||||
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||
}).AddDataAnnotationsLocalization(options =>
|
||||
|
@@ -237,6 +237,22 @@ public class StickerController(
|
||||
return Redirect($"/drive/files/{sticker.Image.Id}?original=true");
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
|
||||
{
|
||||
var queryable = db.Stickers
|
||||
.Include(s => s.Pack)
|
||||
.Where(s => EF.Functions.Like(s.Pack.Prefix + "+" + s.Slug, $"{query}%"))
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.AsQueryable();
|
||||
|
||||
var totalCount = await queryable.CountAsync();
|
||||
Response.Headers["X-Total"] = totalCount.ToString();
|
||||
|
||||
var stickers = await queryable.Take(take).Skip(offset).ToListAsync();
|
||||
return Ok(stickers);
|
||||
}
|
||||
|
||||
[HttpGet("{packId:guid}/content/{id:guid}")]
|
||||
public async Task<ActionResult<SnSticker>> GetSticker(Guid packId, Guid id)
|
||||
{
|
||||
|
@@ -4,7 +4,7 @@
|
||||
"SiteUrl": "https://solian.app",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
|
@@ -1,38 +1,38 @@
|
||||
{
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"SiteUrl": "https://solian.app",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Debug": true,
|
||||
"BaseUrl": "http://localhost:5071",
|
||||
"SiteUrl": "https://solian.app",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"RealtimeChat": {
|
||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||
"ApiKey": "",
|
||||
"ApiSecret": ""
|
||||
},
|
||||
"Translation": {
|
||||
"Provider": "Tencent",
|
||||
"Region": "ap-hongkong",
|
||||
"ProjectId": "0",
|
||||
"SecretId": "",
|
||||
"SecretKey": ""
|
||||
},
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Sphere",
|
||||
"Url": "https://localhost:7099"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||
},
|
||||
"GeoIp": {
|
||||
"DatabasePath": "/app/keys/GeoLite2-City.mmdb"
|
||||
},
|
||||
"RealtimeChat": {
|
||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||
"ApiKey": "",
|
||||
"ApiSecret": ""
|
||||
},
|
||||
"Translation": {
|
||||
"Provider": "Tencent",
|
||||
"Region": "ap-hongkong",
|
||||
"ProjectId": "0",
|
||||
"SecretId": "",
|
||||
"SecretKey": ""
|
||||
},
|
||||
"KnownProxies": ["127.0.0.1", "::1"],
|
||||
"Etcd": {
|
||||
"Insecure": true
|
||||
},
|
||||
"Service": {
|
||||
"Name": "DysonNetwork.Sphere",
|
||||
"Url": "https://localhost:7099"
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user