feat(calls): add consent-based signaling to existing ChatHub

Wire shared PostIt call signaling, account-derived relay policy and temporary TURN credentials. Keep calls disabled pending native Android/Linux media integration. Document experimental Android crash and laboratory removal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 01:00:19 +00:00
commit 5e48831d6d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1368 additions and 16 deletions

View file

@ -0,0 +1,76 @@
#nullable enable
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Server.Helpers;
namespace Yavsc.Server.Hubs;
public partial class ChatHub
{
public async Task<CallSession> InviteCall(string roomName, string callee)
{
var caller = CallUser();
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
throw new HubException("Invalid call participant or room.");
RequireCallPresence(roomName, caller, callee);
var relayOnly = await RequiresRelayAsync(caller, callee);
return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId,
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly);
}
public async Task AcceptCall(Guid callId)
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
await RequiresRelayAsync(session.Caller, session.Callee));
}
public Task EndCall(Guid callId) => _calls.EndAsync(callId, CallUser(), Context.ConnectionId);
public async Task SendCallSignal(Guid callId, CallSignal signal)
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
{
await _calls.EndForUserAsync(user, "privacy-changed");
throw new HubException("Call ended because the IP privacy policy changed. Start a new call.");
}
await _calls.SignalAsync(callId, user, Context.ConnectionId, signal);
}
private string CallUser()
{
var user = Context.User?.GetUserName();
if (string.IsNullOrWhiteSpace(user) || _cxManager.GetUserName(Context.ConnectionId) != user)
throw new HubException("An authenticated chat connection is required.");
return user;
}
private void RequireCallPresence(string room, string caller, string callee)
{
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||
!_cxManager.IsPresent(room, caller) || !_cxManager.IsPresent(room, callee))
throw new HubException("Both call participants must be present in the chat room.");
}
private async Task<bool> RequiresRelayAsync(string caller, string callee)
{
var users = await _dbContext.Users.AsNoTracking()
.Where(u => u.UserName == caller || u.UserName == callee)
.Select(u => new { u.Id, u.HideIpAddressInCalls }).ToArrayAsync(Context.ConnectionAborted);
if (users.Length != 2)
throw new HubException("Call participants are unavailable.");
var ids = users.Select(u => u.Id).ToArray();
if (await _dbContext.BlackListed.AnyAsync(
b => ids.Contains(b.OwnerId) && ids.Contains(b.UserId), Context.ConnectionAborted))
throw new HubException("Calls between these participants are not allowed.");
return users.Any(u => u.HideIpAddressInCalls);
}
}