Add Cloudflare (Dyte) as a provider of call #2
82
DysonNetwork.Pass/Team/Team.cs
Normal file
82
DysonNetwork.Pass/Team/Team.cs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Team;
|
||||||
|
|
||||||
|
public enum TeamType
|
||||||
|
{
|
||||||
|
Individual,
|
||||||
|
Organizational
|
||||||
|
}
|
||||||
|
|
||||||
|
[Index(nameof(Name), IsUnique = true)]
|
||||||
|
public class Team : ModelBase, IIdentifiedResource
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public TeamType Type { get; set; }
|
||||||
|
[MaxLength(256)] public string Name { get; set; } = string.Empty;
|
||||||
|
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||||
|
[MaxLength(4096)] public string? Bio { get; set; }
|
||||||
|
|
||||||
|
// Outdated fields, for backward compability
|
||||||
|
[MaxLength(32)] public string? PictureId { get; set; }
|
||||||
|
[MaxLength(32)] public string? BackgroundId { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public Account.VerificationMark? Verification { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<TeamMember> Members { get; set; } = new List<TeamMember>();
|
||||||
|
[JsonIgnore] public ICollection<TeamFeature> Features { get; set; } = new List<TeamFeature>();
|
||||||
|
|
||||||
|
public Guid? AccountId { get; set; }
|
||||||
|
public Account.Account? Account { get; set; }
|
||||||
|
|
||||||
|
public string ResourceIdentifier => $"publisher/{Id}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TeamMemberRole
|
||||||
|
{
|
||||||
|
Owner = 100,
|
||||||
|
Manager = 75,
|
||||||
|
Editor = 50,
|
||||||
|
Viewer = 25
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TeamMember : ModelBase
|
||||||
|
{
|
||||||
|
public Guid TeamId { get; set; }
|
||||||
|
[JsonIgnore] public Team Team { get; set; } = null!;
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account.Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public TeamMemberRole Role { get; set; } = TeamMemberRole.Viewer;
|
||||||
|
public Instant? JoinedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TeamSubscriptionStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Expired,
|
||||||
|
Cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TeamFeature : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
[MaxLength(1024)] public string Flag { get; set; } = null!;
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public Guid TeamId { get; set; }
|
||||||
|
public Team Team { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract class TeamFeatureFlag
|
||||||
|
{
|
||||||
|
public static List<string> AllFlags => [Develop];
|
||||||
|
public static string Develop = "develop";
|
||||||
|
}
|
308
DysonNetwork.Sphere/Chat/Realtime/CloudflareService.cs
Normal file
308
DysonNetwork.Sphere/Chat/Realtime/CloudflareService.cs
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Chat.Realtime;
|
||||||
|
|
||||||
|
public class CloudflareRealtimeService : IRealtimeService
|
||||||
|
{
|
||||||
|
private readonly AppDatabase _db;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private RSA? _publicKey;
|
||||||
|
|
||||||
|
public CloudflareRealtimeService(
|
||||||
|
AppDatabase db,
|
||||||
|
HttpClient httpClient,
|
||||||
|
IConfiguration configuration
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
var apiKey = _configuration["Realtime:Cloudflare:ApiKey"];
|
||||||
|
var apiSecret = _configuration["Realtime:Cloudflare:ApiSecret"]!;
|
||||||
|
var credentials = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:{apiSecret}"));
|
||||||
|
_httpClient.BaseAddress = new Uri("https://rtk.realtime.cloudflare.com/v2/");
|
||||||
|
_httpClient.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ProviderName => "Cloudflare";
|
||||||
|
|
||||||
|
public async Task<RealtimeSessionConfig> CreateSessionAsync(Guid roomId, Dictionary<string, object> metadata)
|
||||||
|
{
|
||||||
|
var roomName = $"Room Call #{roomId.ToString().Replace("-", "")}";
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
title = roomName,
|
||||||
|
preferred_region = _configuration["Realtime:Cloudflare:PreferredRegion"]
|
||||||
|
};
|
||||||
|
|
||||||
|
var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.PostAsync("meetings", content);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var responseContent = await response.Content.ReadAsStringAsync();
|
||||||
|
var meetingResponse = JsonSerializer.Deserialize<CfMeetingResponse>(responseContent);
|
||||||
|
if (meetingResponse is null) throw new Exception("Failed to create meeting with cloudflare");
|
||||||
|
|
||||||
|
return new RealtimeSessionConfig
|
||||||
|
{
|
||||||
|
SessionId = meetingResponse.Data.Id,
|
||||||
|
Parameters = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "meetingId", meetingResponse.Data.Id }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task EndSessionAsync(string sessionId, RealtimeSessionConfig config)
|
||||||
|
{
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
status = "INACTIVE"
|
||||||
|
};
|
||||||
|
|
||||||
|
var content = new StringContent(JsonSerializer.Serialize(requestBody), Encoding.UTF8, "application/json");
|
||||||
|
var response = await _httpClient.PatchAsync($"sessions/{sessionId}", content);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
|
||||||
|
{
|
||||||
|
return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// First try to get the participant by their custom ID
|
||||||
|
var participantCheckResponse = await _httpClient
|
||||||
|
.GetAsync($"meetings/{sessionId}/participants/{account.Id}");
|
||||||
|
|
||||||
|
if (participantCheckResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
// Participant exists, get a new token
|
||||||
|
var tokenResponse = await _httpClient
|
||||||
|
.PostAsync($"meetings/{sessionId}/participants/{account.Id}/token", null);
|
||||||
|
tokenResponse.EnsureSuccessStatusCode();
|
||||||
|
var tokenContent = await tokenResponse.Content.ReadAsStringAsync();
|
||||||
|
var tokenData = JsonSerializer.Deserialize<CfResponse<CfTokenResponse>>(tokenContent);
|
||||||
|
if (tokenData == null || !tokenData.Success)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to get participant token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenData.Data?.Token ?? throw new Exception("Token is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Participant doesn't exist, create a new one
|
||||||
|
var baseUrl = _configuration["BaseUrl"];
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
name = "@" + account.Name,
|
||||||
|
picture = account.Profile.Picture is not null
|
||||||
|
? $"{baseUrl}/api/files/{account.Profile.Picture.Id}"
|
||||||
|
: null,
|
||||||
|
preset_name = isAdmin ? "group_call_host" : "group_call_participant",
|
||||||
|
custom_participant_id = account.Id.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(requestBody),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
var createResponse = await _httpClient.PostAsync($"meetings/{sessionId}/participants", content);
|
||||||
|
createResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var responseContent = await createResponse.Content.ReadAsStringAsync();
|
||||||
|
var participantData = JsonSerializer.Deserialize<CfResponse<CfParticipantResponse>>(responseContent);
|
||||||
|
if (participantData == null || !participantData.Success)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to create participant");
|
||||||
|
}
|
||||||
|
|
||||||
|
return participantData.Data?.Token ?? throw new Exception("Token is null");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log the error or handle it appropriately
|
||||||
|
throw new Exception($"Failed to get or create participant: {ex.Message}", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReceiveWebhook(string body, string authHeader)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(authHeader))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Auth header is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_publicKey == null)
|
||||||
|
{
|
||||||
|
await GetPublicKeyAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var signature = authHeader.Replace("Signature ", "");
|
||||||
|
var bodyBytes = Encoding.UTF8.GetBytes(body);
|
||||||
|
var signatureBytes = Convert.FromBase64String(signature);
|
||||||
|
|
||||||
|
if (!(_publicKey?.VerifyData(bodyBytes, signatureBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1) ??
|
||||||
|
false))
|
||||||
|
{
|
||||||
|
throw new SecurityTokenException("Webhook signature validation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the webhook event
|
||||||
|
var evt = JsonSerializer.Deserialize<CfWebhookEvent>(body);
|
||||||
|
if (evt is null) return;
|
||||||
|
|
||||||
|
switch (evt.Type)
|
||||||
|
{
|
||||||
|
case "meeting.ended":
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
await _db.ChatRealtimeCall
|
||||||
|
.Where(c => c.SessionId == evt.Event.Meeting.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.EndedAt, now)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WebhooksConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("keys")] public List<WebhookKey> Keys { get; set; } = new List<WebhookKey>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WebhookKey
|
||||||
|
{
|
||||||
|
[JsonPropertyName("publicKeyPem")] public string PublicKeyPem { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GetPublicKeyAsync()
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync("https://rtk.realtime.cloudflare.com/.well-known/webhooks.json");
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
|
var webhooksConfig = JsonSerializer.Deserialize<WebhooksConfig>(content);
|
||||||
|
var publicKeyPem = webhooksConfig?.Keys.FirstOrDefault()?.PublicKeyPem;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(publicKeyPem))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Public key not found in webhooks configuration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_publicKey = RSA.Create();
|
||||||
|
_publicKey.ImportFromPem(publicKeyPem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CfMeetingResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")] public CfMeetingData Data { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CfMeetingData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("roomName")] public string RoomName { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("title")] public string Title { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("status")] public string Status { get; set; } = string.Empty;
|
||||||
|
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
|
||||||
|
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CfParticipant
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
public string CustomParticipantId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CfResponse<T>
|
||||||
|
{
|
||||||
|
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("data")] public T? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CfTokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CfParticipantResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("customUserId")] public string CustomUserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("presetName")] public string PresetName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("isActive")] public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token")] public string Token { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CfWebhookEvent
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("type")] public string Type { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("webhookId")] public string WebhookId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("event")] public EventData Event { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EventData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("meeting")] public MeetingData Meeting { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("participant")] public ParticipantData Participant { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MeetingData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("roomName")] public string RoomName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("title")] public string Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("status")] public string Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ParticipantData
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("userId")] public string UserId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("customParticipantId")]
|
||||||
|
public string CustomParticipantId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("presetName")] public string PresetName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("joinedAt")] public DateTime JoinedAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("leftAt")] public DateTime? LeftAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -30,7 +30,7 @@ public interface IRealtimeService
|
|||||||
Task EndSessionAsync(string sessionId, RealtimeSessionConfig config);
|
Task EndSessionAsync(string sessionId, RealtimeSessionConfig config);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a token for user to join the session
|
/// Gets a token for user to join the session (synchronous version for backward compatibility)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="account">The user identifier</param>
|
/// <param name="account">The user identifier</param>
|
||||||
/// <param name="sessionId">The session identifier</param>
|
/// <param name="sessionId">The session identifier</param>
|
||||||
@ -38,6 +38,15 @@ public interface IRealtimeService
|
|||||||
/// <returns>User-specific token for the session</returns>
|
/// <returns>User-specific token for the session</returns>
|
||||||
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false);
|
string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a token for user to join the session asynchronously
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">The user identifier</param>
|
||||||
|
/// <param name="sessionId">The session identifier</param>
|
||||||
|
/// <param name="isAdmin">The user is the admin of session</param>
|
||||||
|
/// <returns>Task that resolves to the user-specific token for the session</returns>
|
||||||
|
Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes incoming webhook requests from the realtime service provider
|
/// Processes incoming webhook requests from the realtime service provider
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -10,33 +10,33 @@ namespace DysonNetwork.Sphere.Chat.Realtime;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// LiveKit implementation of the real-time communication service
|
/// LiveKit implementation of the real-time communication service
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LivekitRealtimeService : IRealtimeService
|
public class LiveKitRealtimeService : IRealtimeService
|
||||||
{
|
{
|
||||||
private readonly AppDatabase _db;
|
private readonly AppDatabase _db;
|
||||||
private readonly ICacheService _cache;
|
private readonly ICacheService _cache;
|
||||||
private readonly WebSocketService _ws;
|
private readonly RealtimeStatusService _callStatus;
|
||||||
|
|
||||||
private readonly ILogger<LivekitRealtimeService> _logger;
|
private readonly ILogger<LiveKitRealtimeService> _logger;
|
||||||
private readonly RoomServiceClient _roomService;
|
private readonly RoomServiceClient _roomService;
|
||||||
private readonly AccessToken _accessToken;
|
private readonly AccessToken _accessToken;
|
||||||
private readonly WebhookReceiver _webhookReceiver;
|
private readonly WebhookReceiver _webhookReceiver;
|
||||||
|
|
||||||
public LivekitRealtimeService(
|
public LiveKitRealtimeService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<LivekitRealtimeService> logger,
|
ILogger<LiveKitRealtimeService> logger,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
WebSocketService ws
|
RealtimeStatusService callStatus
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Get LiveKit configuration from appsettings
|
// Get LiveKit configuration from appsettings
|
||||||
var host = configuration["RealtimeChat:Endpoint"] ??
|
var host = configuration["Realtime:LiveKit:Endpoint"] ??
|
||||||
throw new ArgumentNullException("Endpoint configuration is required");
|
throw new ArgumentNullException("Endpoint configuration is required");
|
||||||
var apiKey = configuration["RealtimeChat:ApiKey"] ??
|
var apiKey = configuration["Realtime:LiveKit:ApiKey"] ??
|
||||||
throw new ArgumentNullException("ApiKey configuration is required");
|
throw new ArgumentNullException("ApiKey configuration is required");
|
||||||
var apiSecret = configuration["RealtimeChat:ApiSecret"] ??
|
var apiSecret = configuration["Realtime:LiveKit:ApiSecret"] ??
|
||||||
throw new ArgumentNullException("ApiSecret configuration is required");
|
throw new ArgumentNullException("ApiSecret configuration is required");
|
||||||
|
|
||||||
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
|
_roomService = new RoomServiceClient(host, apiKey, apiSecret);
|
||||||
@ -45,7 +45,7 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
|
|
||||||
_db = db;
|
_db = db;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_ws = ws;
|
_callStatus = callStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@ -112,6 +112,11 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
|
public string GetUserToken(Account.Account account, string sessionId, bool isAdmin = false)
|
||||||
|
{
|
||||||
|
return GetUserTokenAsync(account, sessionId, isAdmin).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string> GetUserTokenAsync(Account.Account account, string sessionId, bool isAdmin = false)
|
||||||
{
|
{
|
||||||
var token = _accessToken.WithIdentity(account.Name)
|
var token = _accessToken.WithIdentity(account.Name)
|
||||||
.WithName(account.Nick)
|
.WithName(account.Nick)
|
||||||
@ -128,7 +133,7 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
|
.WithMetadata(JsonSerializer.Serialize(new Dictionary<string, string>
|
||||||
{ { "account_id", account.Id.ToString() } }))
|
{ { "account_id", account.Id.ToString() } }))
|
||||||
.WithTtl(TimeSpan.FromHours(1));
|
.WithTtl(TimeSpan.FromHours(1));
|
||||||
return token.ToJwt();
|
return Task.FromResult(token.ToJwt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReceiveWebhook(string body, string authHeader)
|
public async Task ReceiveWebhook(string body, string authHeader)
|
||||||
@ -159,7 +164,8 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
evt.Room.Name, evt.Participant.Identity);
|
evt.Room.Name, evt.Participant.Identity);
|
||||||
|
|
||||||
// Broadcast participant list update to all participants
|
// Broadcast participant list update to all participants
|
||||||
await _BroadcastParticipantUpdate(evt.Room.Name);
|
var info = await GetRoomParticipantsAsync(evt.Room.Name);
|
||||||
|
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -174,14 +180,15 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
evt.Room.Name, evt.Participant.Identity);
|
evt.Room.Name, evt.Participant.Identity);
|
||||||
|
|
||||||
// Broadcast participant list update to all participants
|
// Broadcast participant list update to all participants
|
||||||
await _BroadcastParticipantUpdate(evt.Room.Name);
|
var info = await GetRoomParticipantsAsync(evt.Room.Name);
|
||||||
|
await _callStatus.BroadcastParticipantUpdate(evt.Room.Name, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string _GetParticipantsKey(string roomName)
|
private static string _GetParticipantsKey(string roomName)
|
||||||
=> $"RoomParticipants_{roomName}";
|
=> $"RoomParticipants_{roomName}";
|
||||||
|
|
||||||
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
|
private async Task _AddParticipantToCache(string roomName, ParticipantInfo participant)
|
||||||
@ -201,7 +208,7 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the current participants list
|
// Get the current participants list
|
||||||
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ??
|
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ??
|
||||||
[];
|
[];
|
||||||
|
|
||||||
// Check if the participant already exists
|
// Check if the participant already exists
|
||||||
@ -241,7 +248,7 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get current participants list
|
// Get current participants list
|
||||||
var participants = await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey);
|
var participants = await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey);
|
||||||
if (participants == null || !participants.Any())
|
if (participants == null || !participants.Any())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@ -253,36 +260,24 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get participants in a room
|
// Helper method to get participants in a room
|
||||||
public async Task<List<ParticipantCacheItem>> GetRoomParticipantsAsync(string roomName)
|
public async Task<List<ParticipantInfoItem>> GetRoomParticipantsAsync(string roomName)
|
||||||
{
|
{
|
||||||
var participantsKey = _GetParticipantsKey(roomName);
|
var participantsKey = _GetParticipantsKey(roomName);
|
||||||
return await _cache.GetAsync<List<ParticipantCacheItem>>(participantsKey) ?? [];
|
return await _cache.GetAsync<List<ParticipantInfoItem>>(participantsKey) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class to represent a participant in the cache
|
private ParticipantInfoItem CreateParticipantCacheItem(ParticipantInfo participant)
|
||||||
public class ParticipantCacheItem
|
|
||||||
{
|
|
||||||
public string Identity { get; set; } = null!;
|
|
||||||
public string Name { get; set; } = null!;
|
|
||||||
public Guid? AccountId { get; set; }
|
|
||||||
public ParticipantInfo.Types.State State { get; set; }
|
|
||||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
|
||||||
public DateTime JoinedAt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private ParticipantCacheItem CreateParticipantCacheItem(ParticipantInfo participant)
|
|
||||||
{
|
{
|
||||||
// Try to parse account ID from metadata
|
// Try to parse account ID from metadata
|
||||||
Guid? accountId = null;
|
Guid? accountId = null;
|
||||||
var metadata = new Dictionary<string, string>();
|
var metadata = new Dictionary<string, string>();
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(participant.Metadata))
|
if (string.IsNullOrEmpty(participant.Metadata))
|
||||||
return new ParticipantCacheItem
|
return new ParticipantInfoItem
|
||||||
{
|
{
|
||||||
Identity = participant.Identity,
|
Identity = participant.Identity,
|
||||||
Name = participant.Name,
|
Name = participant.Name,
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
State = participant.State,
|
|
||||||
Metadata = metadata,
|
Metadata = metadata,
|
||||||
JoinedAt = DateTime.UtcNow
|
JoinedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
@ -300,92 +295,13 @@ public class LivekitRealtimeService : IRealtimeService
|
|||||||
_logger.LogError(ex, "Failed to parse participant metadata");
|
_logger.LogError(ex, "Failed to parse participant metadata");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ParticipantCacheItem
|
return new ParticipantInfoItem
|
||||||
{
|
{
|
||||||
Identity = participant.Identity,
|
Identity = participant.Identity,
|
||||||
Name = participant.Name,
|
Name = participant.Name,
|
||||||
AccountId = accountId,
|
AccountId = accountId,
|
||||||
State = participant.State,
|
|
||||||
Metadata = metadata,
|
Metadata = metadata,
|
||||||
JoinedAt = DateTime.UtcNow
|
JoinedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Broadcast participant update to all participants in a room
|
|
||||||
private async Task _BroadcastParticipantUpdate(string roomName)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get the room ID from the session name
|
|
||||||
var roomInfo = await _db.ChatRealtimeCall
|
|
||||||
.Where(c => c.SessionId == roomName && c.EndedAt == null)
|
|
||||||
.Select(c => new { c.RoomId, c.Id })
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (roomInfo == null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Could not find room info for session: {SessionName}", roomName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current participants
|
|
||||||
var livekitParticipants = await GetRoomParticipantsAsync(roomName);
|
|
||||||
|
|
||||||
// Get all room members who should receive this update
|
|
||||||
var roomMembers = await _db.ChatMembers
|
|
||||||
.Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null)
|
|
||||||
.Select(m => m.AccountId)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
// Get member profiles for participants who have account IDs
|
|
||||||
var accountIds = livekitParticipants
|
|
||||||
.Where(p => p.AccountId.HasValue)
|
|
||||||
.Select(p => p.AccountId!.Value)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var memberProfiles = new Dictionary<Guid, ChatMember>();
|
|
||||||
if (accountIds.Count != 0)
|
|
||||||
{
|
|
||||||
memberProfiles = await _db.ChatMembers
|
|
||||||
.Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId))
|
|
||||||
.Include(m => m.Account)
|
|
||||||
.ThenInclude(m => m.Profile)
|
|
||||||
.ToDictionaryAsync(m => m.AccountId, m => m);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to CallParticipant objects
|
|
||||||
var participants = livekitParticipants.Select(p => new CallParticipant
|
|
||||||
{
|
|
||||||
Identity = p.Identity,
|
|
||||||
Name = p.Name,
|
|
||||||
AccountId = p.AccountId,
|
|
||||||
JoinedAt = p.JoinedAt,
|
|
||||||
Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile)
|
|
||||||
? profile
|
|
||||||
: null
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// Create the update packet with CallParticipant objects
|
|
||||||
var updatePacket = new WebSocketPacket
|
|
||||||
{
|
|
||||||
Type = WebSocketPacketType.CallParticipantsUpdate,
|
|
||||||
Data = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
{ "room_id", roomInfo.RoomId },
|
|
||||||
{ "call_id", roomInfo.Id },
|
|
||||||
{ "participants", participants }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send the update to all members
|
|
||||||
foreach (var accountId in roomMembers)
|
|
||||||
{
|
|
||||||
_ws.SendPacketToAccount(accountId, updatePacket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
91
DysonNetwork.Sphere/Chat/Realtime/RealtimeStatusService.cs
Normal file
91
DysonNetwork.Sphere/Chat/Realtime/RealtimeStatusService.cs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
using DysonNetwork.Sphere.Connection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Chat.Realtime;
|
||||||
|
|
||||||
|
public class ParticipantInfoItem
|
||||||
|
{
|
||||||
|
public string Identity { get; set; } = null!;
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public Guid? AccountId { get; set; }
|
||||||
|
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||||
|
public DateTime JoinedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RealtimeStatusService(AppDatabase db, WebSocketService ws, ILogger<RealtimeStatusService> logger)
|
||||||
|
{
|
||||||
|
// Broadcast participant update to all participants in a room
|
||||||
|
public async Task BroadcastParticipantUpdate(string roomName, List<ParticipantInfoItem> participantsInfo)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get the room ID from the session name
|
||||||
|
var roomInfo = await db.ChatRealtimeCall
|
||||||
|
.Where(c => c.SessionId == roomName && c.EndedAt == null)
|
||||||
|
.Select(c => new { c.RoomId, c.Id })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (roomInfo == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Could not find room info for session: {SessionName}", roomName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all room members who should receive this update
|
||||||
|
var roomMembers = await db.ChatMembers
|
||||||
|
.Where(m => m.ChatRoomId == roomInfo.RoomId && m.LeaveAt == null)
|
||||||
|
.Select(m => m.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Get member profiles for participants who have account IDs
|
||||||
|
var accountIds = participantsInfo
|
||||||
|
.Where(p => p.AccountId.HasValue)
|
||||||
|
.Select(p => p.AccountId!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var memberProfiles = new Dictionary<Guid, ChatMember>();
|
||||||
|
if (accountIds.Count != 0)
|
||||||
|
{
|
||||||
|
memberProfiles = await db.ChatMembers
|
||||||
|
.Where(m => m.ChatRoomId == roomInfo.RoomId && accountIds.Contains(m.AccountId))
|
||||||
|
.Include(m => m.Account)
|
||||||
|
.ThenInclude(m => m.Profile)
|
||||||
|
.ToDictionaryAsync(m => m.AccountId, m => m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to CallParticipant objects
|
||||||
|
var participants = participantsInfo.Select(p => new CallParticipant
|
||||||
|
{
|
||||||
|
Identity = p.Identity,
|
||||||
|
Name = p.Name,
|
||||||
|
AccountId = p.AccountId,
|
||||||
|
JoinedAt = p.JoinedAt,
|
||||||
|
Profile = p.AccountId.HasValue && memberProfiles.TryGetValue(p.AccountId.Value, out var profile)
|
||||||
|
? profile
|
||||||
|
: null
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Create the update packet with CallParticipant objects
|
||||||
|
var updatePacket = new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = WebSocketPacketType.CallParticipantsUpdate,
|
||||||
|
Data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "room_id", roomInfo.RoomId },
|
||||||
|
{ "call_id", roomInfo.Id },
|
||||||
|
{ "participants", participants }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send the update to all members
|
||||||
|
foreach (var accountId in roomMembers)
|
||||||
|
{
|
||||||
|
ws.SendPacketToAccount(accountId, updatePacket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Error broadcasting participant update for room {RoomName}", roomName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
using DysonNetwork.Sphere.Chat.Realtime;
|
using DysonNetwork.Sphere.Chat.Realtime;
|
||||||
using Livekit.Server.Sdk.Dotnet;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@ -7,11 +6,6 @@ using Swashbuckle.AspNetCore.Annotations;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Chat;
|
namespace DysonNetwork.Sphere.Chat;
|
||||||
|
|
||||||
public class RealtimeChatConfiguration
|
|
||||||
{
|
|
||||||
public string Endpoint { get; set; } = null!;
|
|
||||||
}
|
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/api/chat/realtime")]
|
[Route("/api/chat/realtime")]
|
||||||
public class RealtimeCallController(
|
public class RealtimeCallController(
|
||||||
@ -21,9 +15,6 @@ public class RealtimeCallController(
|
|||||||
IRealtimeService realtime
|
IRealtimeService realtime
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly RealtimeChatConfiguration _config =
|
|
||||||
configuration.GetSection("RealtimeChat").Get<RealtimeChatConfiguration>()!;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// This endpoint is especially designed for livekit webhooks,
|
/// This endpoint is especially designed for livekit webhooks,
|
||||||
/// for update the call participates and more.
|
/// for update the call participates and more.
|
||||||
@ -36,9 +27,9 @@ public class RealtimeCallController(
|
|||||||
using var reader = new StreamReader(Request.Body);
|
using var reader = new StreamReader(Request.Body);
|
||||||
var postData = await reader.ReadToEndAsync();
|
var postData = await reader.ReadToEndAsync();
|
||||||
var authHeader = Request.Headers.Authorization.ToString();
|
var authHeader = Request.Headers.Authorization.ToString();
|
||||||
|
|
||||||
await realtime.ReceiveWebhook(postData, authHeader);
|
await realtime.ReceiveWebhook(postData, authHeader);
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,39 +82,17 @@ public class RealtimeCallController(
|
|||||||
return BadRequest("Call session is not properly configured.");
|
return BadRequest("Call session is not properly configured.");
|
||||||
|
|
||||||
var isAdmin = member.Role >= ChatMemberRole.Moderator;
|
var isAdmin = member.Role >= ChatMemberRole.Moderator;
|
||||||
var userToken = realtime.GetUserToken(currentUser, ongoingCall.SessionId, isAdmin);
|
var userToken = await realtime.GetUserTokenAsync(currentUser, ongoingCall.SessionId, isAdmin);
|
||||||
|
|
||||||
// Get LiveKit endpoint from configuration
|
// Get LiveKit endpoint from configuration
|
||||||
var endpoint = _config.Endpoint ??
|
var endpoint = configuration[$"Realtime:{realtime.ProviderName}:Endpoint"] ?? realtime.ProviderName switch
|
||||||
throw new InvalidOperationException("LiveKit endpoint configuration is missing");
|
|
||||||
|
|
||||||
// Inject the ChatRoomService
|
|
||||||
var chatRoomService = HttpContext.RequestServices.GetRequiredService<ChatRoomService>();
|
|
||||||
|
|
||||||
// Get current participants from the LiveKit service
|
|
||||||
var participants = new List<CallParticipant>();
|
|
||||||
if (realtime is LivekitRealtimeService livekitService)
|
|
||||||
{
|
{
|
||||||
var roomParticipants = await livekitService.GetRoomParticipantsAsync(ongoingCall.SessionId);
|
// Unusable for sure, just for placeholder
|
||||||
participants = [];
|
"LiveKit" => "https://livekit.cloud",
|
||||||
|
"Cloudflare" => "https://rtk.realtime.cloudflare.com/v2",
|
||||||
foreach (var p in roomParticipants)
|
// Unusable for sure, just for placeholder
|
||||||
{
|
_ => "https://example.com"
|
||||||
var participant = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the response model
|
// Create the response model
|
||||||
var response = new JoinCallResponse
|
var response = new JoinCallResponse
|
||||||
@ -133,8 +102,7 @@ public class RealtimeCallController(
|
|||||||
Token = userToken,
|
Token = userToken,
|
||||||
CallId = ongoingCall.Id,
|
CallId = ongoingCall.Id,
|
||||||
RoomName = ongoingCall.SessionId,
|
RoomName = ongoingCall.SessionId,
|
||||||
IsAdmin = isAdmin,
|
IsAdmin = isAdmin
|
||||||
Participants = participants
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
@ -192,7 +160,7 @@ public class JoinCallResponse
|
|||||||
public string Provider { get; set; } = null!;
|
public string Provider { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The LiveKit server endpoint
|
/// The provider server endpoint
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Endpoint { get; set; } = null!;
|
public string Endpoint { get; set; } = null!;
|
||||||
|
|
||||||
@ -215,11 +183,6 @@ public class JoinCallResponse
|
|||||||
/// Whether the user is the admin of the call
|
/// Whether the user is the admin of the call
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Current participants in the call
|
|
||||||
/// </summary>
|
|
||||||
public List<CallParticipant> Participants { get; set; } = new();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -231,22 +194,22 @@ public class CallParticipant
|
|||||||
/// The participant's identity (username)
|
/// The participant's identity (username)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Identity { get; set; } = null!;
|
public string Identity { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The participant's display name
|
/// The participant's display name
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The participant's account ID if available
|
/// The participant's account ID if available
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? AccountId { get; set; }
|
public Guid? AccountId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The participant's profile in the chat
|
/// The participant's profile in the chat
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ChatMember? Profile { get; set; }
|
public ChatMember? Profile { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When the participant joined the call
|
/// When the participant joined the call
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -31,7 +31,10 @@ public class ClientTypeMiddleware(RequestDelegate next)
|
|||||||
|
|
||||||
context.Items["IsWebPage"] = isWebPage;
|
context.Items["IsWebPage"] = isWebPage;
|
||||||
|
|
||||||
if (!isWebPage && context.Request.Path != "/ws" && !context.Request.Path.StartsWithSegments("/api"))
|
var redirectWhiteList = new[] { "/ws", "/.well-known", "/swagger" };
|
||||||
|
if(redirectWhiteList.Any(w => context.Request.Path.StartsWithSegments(w)))
|
||||||
|
await next(context);
|
||||||
|
else if (!isWebPage && !context.Request.Path.StartsWithSegments("/api"))
|
||||||
context.Response.Redirect(
|
context.Response.Redirect(
|
||||||
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
|
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
|
||||||
permanent: false
|
permanent: false
|
||||||
|
@ -229,7 +229,8 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<WalletService>();
|
services.AddScoped<WalletService>();
|
||||||
services.AddScoped<SubscriptionService>();
|
services.AddScoped<SubscriptionService>();
|
||||||
services.AddScoped<PaymentService>();
|
services.AddScoped<PaymentService>();
|
||||||
services.AddScoped<IRealtimeService, LivekitRealtimeService>();
|
services.AddScoped<RealtimeStatusService>();
|
||||||
|
services.AddRealtimeService(configuration);
|
||||||
services.AddScoped<WebReaderService>();
|
services.AddScoped<WebReaderService>();
|
||||||
services.AddScoped<WebFeedService>();
|
services.AddScoped<WebFeedService>();
|
||||||
services.AddScoped<AfdianPaymentHandler>();
|
services.AddScoped<AfdianPaymentHandler>();
|
||||||
@ -242,4 +243,22 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static IServiceCollection AddRealtimeService(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var provider = configuration["Realtime:Provider"];
|
||||||
|
switch (provider)
|
||||||
|
{
|
||||||
|
case "Cloudflare":
|
||||||
|
services.AddHttpClient<IRealtimeService, CloudflareRealtimeService>();
|
||||||
|
break;
|
||||||
|
case "LiveKit":
|
||||||
|
services.AddScoped<IRealtimeService, LiveKitRealtimeService>();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Realtime provider '{provider}' is not supported.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -85,10 +85,18 @@
|
|||||||
"FromName": "Alphabot",
|
"FromName": "Alphabot",
|
||||||
"SubjectPrefix": "Solar Network"
|
"SubjectPrefix": "Solar Network"
|
||||||
},
|
},
|
||||||
"RealtimeChat": {
|
"Realtime": {
|
||||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
"Provider": "Cloudflare",
|
||||||
"ApiKey": "APIs6TiL8wj3A4j",
|
"LiveKit": {
|
||||||
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
||||||
|
"ApiKey": "APIs6TiL8wj3A4j",
|
||||||
|
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
||||||
|
},
|
||||||
|
"Cloudflare": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"ApiSecret": "",
|
||||||
|
"PreferredRegion": "us-east-1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"GeoIp": {
|
"GeoIp": {
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
@ -56,6 +56,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AJwtSecurityTokenHandler_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F477051138f1f40de9077b7b1cdc55c6215fb0_003Ff5_003Fd716e016_003FJwtSecurityTokenHandler_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKestrelServerLimits_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F1e2e5dfcafad4407b569dd5df56a2fbf274e00_003Fa4_003F39445f62_003FKestrelServerLimits_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AKnownResamplers_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fef3339e864a448e2b1ec6fa7bbf4c6661fee00_003Fb3_003Fcdb3e080_003FKnownResamplers_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitModels_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Ff013807925809b234b3ca1be31567576bb8bda08f2c4fa5d290d4d14d8134_003FLivekitModels_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitRoom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F82666257d5ad47354add7af860f66dd85df55ec93e92e8a45891b9bff7bf80ac_003FLivekitRoom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ALivekitRoom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003F82666257d5ad47354add7af860f66dd85df55ec93e92e8a45891b9bff7bf80ac_003FLivekitRoom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMailboxAddress_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8e03e47c46b7469f97abc40667cbcf9b133000_003Fa6_003F83324248_003FMailboxAddress_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AMediaAnalysis_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffef366b36a224d469ff150d30f9a866d23c00_003Fd7_003F5c138865_003FMediaAnalysis_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@ -84,6 +85,7 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe6898c1ddf974e16b95b114722270029e55000_003F6b_003F7530575d_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationFeed_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fad_003Fd26b4d73_003FSyndicationFeed_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationFeed_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fad_003Fd26b4d73_003FSyndicationFeed_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fe1_003Fb136d7be_003FSyndicationItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASyndicationItem_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F5b43b9cf654743f8b9a2eee23c625dd21dd30_003Fe1_003Fb136d7be_003FSyndicationItem_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
Reference in New Issue
Block a user