XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Collections.Concurrent;
using System.Text.Json;
using System.Windows;
using System.Windows.Threading;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.Views.Windows;

namespace XFEToolBox.Client.Utilities.Chat;

/// <summary>
/// Coordinates call state over the authenticated realtime client. WebRTC media and
/// per-peer audio processing remain isolated inside <see cref="VoiceCallWindow"/>.
/// </summary>
public sealed class VoiceCallService : IAsyncDisposable
{
    private static readonly TimeSpan CallCommandTimeout = TimeSpan.FromSeconds(10);
    private static readonly Lazy<VoiceCallService> SharedInstance = new(
        static () => new VoiceCallService(ChatRealtimeClient.Shared),
        LazyThreadSafetyMode.ExecutionAndPublication);
    private readonly ChatRealtimeClient _realtimeClient;
    private readonly VoiceCallOptions _options;
    private readonly Dispatcher _dispatcher;
    private readonly ConcurrentDictionary<string, IncomingVoiceCall> _incomingCalls = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, IReadOnlyList<VoiceCallParticipant>> _participantSnapshots = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, ConcurrentQueue<PendingVoiceCallSignal>> _pendingSignals = new(StringComparer.Ordinal);
    private readonly SemaphoreSlim _operationLock = new(1, 1);
    private VoiceCallWindow? _callWindow;
    private ActiveVoiceCall? _activeCall;
    private int _disposed;

    public static VoiceCallService Current => SharedInstance.Value;

    public VoiceCallService(
        ChatRealtimeClient realtimeClient,
        VoiceCallOptions? options = null,
        Dispatcher? dispatcher = null)
    {
        _realtimeClient = realtimeClient ?? throw new ArgumentNullException(nameof(realtimeClient));
        _options = options ?? VoiceCallOptions.FromEnvironment();
        if (_options.MaximumMeshParticipants is < 2 or > 8)
            throw new ArgumentOutOfRangeException(nameof(options), "Mesh calls support between 2 and 8 participants.");
        _dispatcher = dispatcher ?? Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher;
        _realtimeClient.EnvelopeReceived += RealtimeClient_EnvelopeReceived;
        _realtimeClient.ConnectionStateChanged += RealtimeClient_ConnectionStateChanged;
    }

    public event EventHandler<IncomingVoiceCallEventArgs>? IncomingCallReceived;

    public event EventHandler<VoiceCallErrorEventArgs>? Error;

    public ActiveVoiceCall? ActiveCall => _activeCall;

    /// <summary>Starts the shared signalling connection. Safe to call repeatedly from ChatPage.Loaded.</summary>
    public Task StartAsync() => _realtimeClient.StartAsync();

    public Task StartDirectCallAsync(
        string friendUserId,
        string? conversationId = null,
        CancellationToken cancellationToken = default) =>
        StartCallAsync("user", friendUserId, conversationId, cancellationToken);

    public Task StartGroupCallAsync(
        string groupId,
        string? conversationId = null,
        CancellationToken cancellationToken = default) =>
        StartCallAsync("group", groupId, conversationId ?? groupId, cancellationToken);

    public async Task AcceptAsync(string callId, CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
        if (!_incomingCalls.TryGetValue(callId, out var invitation))
            throw new InvalidOperationException("该通话邀请不存在或已经失效。");

        await EnsureRealtimeConnectedAsync(cancellationToken);
        await _operationLock.WaitAsync(cancellationToken);
        try
        {
            EnsureNoOtherCall(callId);
            try
            {
                await _realtimeClient.SendRequestAsync(
                    "call.accept",
                    new { callId },
                    invitation.ConversationId,
                    CallCommandTimeout,
                    cancellationToken);
            }
            catch
            {
                await AbandonCallBestEffortAsync(callId, invitation.ConversationId);
                throw;
            }

            _incomingCalls.TryRemove(callId, out _);
            _activeCall = new ActiveVoiceCall(
                callId,
                invitation.TargetType,
                invitation.TargetId,
                invitation.ConversationId,
                IsIncoming: true);
            await OpenCallWindowAsync(_activeCall, invitation.FromDisplayName);
        }
        finally
        {
            _operationLock.Release();
        }
    }

    public async Task RejectAsync(string callId, CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
        await EnsureRealtimeConnectedAsync(cancellationToken);
        if (!_incomingCalls.TryGetValue(callId, out var invitation))
            throw new InvalidOperationException("该通话邀请不存在或已经失效。");
        await _realtimeClient.SendRequestAsync(
            "call.reject",
            new { callId },
            invitation.ConversationId,
            CallCommandTimeout,
            cancellationToken);
        _incomingCalls.TryRemove(callId, out _);
        _participantSnapshots.TryRemove(callId, out _);
        _pendingSignals.TryRemove(callId, out _);
    }

    public async Task LeaveAsync(CancellationToken cancellationToken = default)
    {
        await _operationLock.WaitAsync(cancellationToken);
        try
        {
            var active = _activeCall;
            _activeCall = null;
            if (active is not null)
            {
                _participantSnapshots.TryRemove(active.CallId, out _);
                _pendingSignals.TryRemove(active.CallId, out _);
            }
            try
            {
                if (active is not null && _realtimeClient.IsConnected)
                    await _realtimeClient.SendRequestAsync(
                        "call.leave",
                        new { callId = active.CallId },
                        active.ConversationId,
                        TimeSpan.FromSeconds(4),
                        cancellationToken);
            }
            catch (ChatRealtimeRequestException exception) when (
                exception.Code is "call.not_found" or "call.not_allowed" or "realtime.disconnected")
            {
                // The local call still has to close when the server has already removed it.
            }
            finally
            {
                await CloseCallWindowAsync(suppressLeaveEvent: true);
            }
        }
        finally
        {
            _operationLock.Release();
        }
    }

    private async Task StartCallAsync(
        string targetType,
        string targetId,
        string? conversationId,
        CancellationToken cancellationToken)
    {
        ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
        ArgumentException.ThrowIfNullOrWhiteSpace(targetId);
        await EnsureRealtimeConnectedAsync(cancellationToken);
        await _operationLock.WaitAsync(cancellationToken);
        try
        {
            EnsureNoOtherCall(null);
            var callId = Guid.NewGuid().ToString("N");
            try
            {
                await _realtimeClient.SendRequestAsync(
                    "call.invite",
                    new
                    {
                        callId,
                        targetType,
                        targetId,
                        media = "audio"
                    },
                    conversationId,
                    CallCommandTimeout,
                    cancellationToken);
            }
            catch
            {
                await AbandonCallBestEffortAsync(callId, conversationId);
                throw;
            }

            _activeCall = new ActiveVoiceCall(callId, targetType, targetId, conversationId, IsIncoming: false);
            await OpenCallWindowAsync(_activeCall, targetType == "group" ? "群聊语音" : "好友语音");
        }
        finally
        {
            _operationLock.Release();
        }
    }

    private void EnsureNoOtherCall(string? acceptingCallId)
    {
        if (_activeCall is not null &&
            !string.Equals(_activeCall.CallId, acceptingCallId, StringComparison.Ordinal))
            throw new InvalidOperationException("当前已有进行中的通话。");
    }

    private async Task AbandonCallBestEffortAsync(string callId, string? conversationId)
    {
        _participantSnapshots.TryRemove(callId, out _);
        _pendingSignals.TryRemove(callId, out _);
        if (!_realtimeClient.IsConnected) return;
        try
        {
            await _realtimeClient.SendRequestAsync(
                "call.leave",
                new { callId },
                conversationId,
                TimeSpan.FromSeconds(2));
        }
        catch
        {
        }
    }

    private async Task EnsureRealtimeConnectedAsync(CancellationToken cancellationToken)
    {
        await _realtimeClient.StartAsync();
        if (!await _realtimeClient.WaitUntilConnectedAsync(TimeSpan.FromSeconds(15), cancellationToken))
            throw new InvalidOperationException("实时通信服务连接超时,请检查网络后重试。");
    }

    private void RealtimeClient_EnvelopeReceived(object? sender, ChatRealtimeEnvelopeEventArgs e) =>
        _ = HandleRealtimeEnvelopeSafeAsync(e.Envelope);

    private async Task HandleRealtimeEnvelopeSafeAsync(ChatClientRealtimeEnvelope envelope)
    {
        try
        {
            await HandleRealtimeEnvelopeAsync(envelope);
        }
        catch (Exception exception)
        {
            RaiseError("处理通话事件失败。", exception);
        }
    }

    private async Task HandleRealtimeEnvelopeAsync(ChatClientRealtimeEnvelope envelope)
    {
        switch (envelope.Type)
        {
            case "call.invite":
                HandleIncomingInvite(envelope);
                break;
            case "call.created":
            case "call.accept":
            case "call.join":
                await HandleParticipantsChangedAsync(envelope);
                break;
            case "call.leave":
                await HandleParticipantLeftAsync(envelope);
                break;
            case "call.reject":
                await HandleCallRejectedAsync(envelope);
                break;
            case "call.ended":
                await HandleCallEndedAsync(envelope);
                break;
            case "webrtc.offer":
            case "webrtc.answer":
            case "webrtc.ice":
                await ForwardWebRtcSignalToWindowAsync(envelope);
                break;
            case "event.error":
                HandleServerError(envelope.Payload);
                break;
        }
    }

    private void HandleIncomingInvite(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId) ||
            !TryGetString(envelope.Payload, "targetType", out var targetType) ||
            !TryGetString(envelope.Payload, "targetId", out var targetId) ||
            !TryGetString(envelope.Payload, "fromUserId", out var fromUserId))
            return;

        var displayName = GetString(envelope.Payload, "fromDisplayName") ?? fromUserId;
        var invitation = new IncomingVoiceCall(
            callId,
            fromUserId,
            displayName,
            targetType,
            targetId,
            envelope.ConversationId,
            envelope.OccurredAtUtc);
        _incomingCalls[callId] = invitation;
        _dispatcher.BeginInvoke(() =>
            IncomingCallReceived?.Invoke(this, new IncomingVoiceCallEventArgs(invitation)));
    }

    private async Task HandleParticipantsChangedAsync(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId)) return;
        var participants = ReadParticipants(envelope.Payload);
        _participantSnapshots[callId] = participants;
        if (!IsActiveCall(callId)) return;
        var window = _callWindow;
        if (window is not null)
            await window.UpdateParticipantsAsync(participants);
    }

    private async Task HandleParticipantLeftAsync(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId)) return;
        var participants = ReadParticipants(envelope.Payload);
        _participantSnapshots[callId] = participants;
        if (!IsActiveCall(callId)) return;
        if (TryGetString(envelope.Payload, "userId", out var userId))
        {
            if (_activeCall?.TargetType == "user" &&
                !string.Equals(userId, ClientSession.CurrentUser?.Id, StringComparison.Ordinal))
            {
                await LeaveAsync();
                RaiseError("对方已离开语音通话。", null);
                return;
            }
            if (_callWindow is { } window)
                await window.RemoveParticipantAsync(userId);
        }
        if (_callWindow is { } activeWindow)
            await activeWindow.UpdateParticipantsAsync(participants);
    }

    private async Task HandleCallRejectedAsync(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId) || !IsActiveCall(callId)) return;
        if (_activeCall?.TargetType != "user") return;
        var displayName = GetString(envelope.Payload, "displayName") ?? "对方";
        await LeaveAsync();
        RaiseError($"{displayName} 拒绝了语音通话。", null);
    }

    private async Task HandleCallEndedAsync(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId) || !IsActiveCall(callId))
            return;
        _activeCall = null;
        _participantSnapshots.TryRemove(callId, out _);
        _pendingSignals.TryRemove(callId, out _);
        await CloseCallWindowAsync(suppressLeaveEvent: true);
    }

    private async Task ForwardWebRtcSignalToWindowAsync(ChatClientRealtimeEnvelope envelope)
    {
        if (!TryGetString(envelope.Payload, "callId", out var callId) ||
            !TryGetString(envelope.Payload, "fromUserId", out var fromUserId) ||
            !envelope.Payload.TryGetProperty("signal", out var signal))
            return;
        if (IsActiveCall(callId) && _callWindow is { } window)
        {
            await window.ApplyRemoteSignalAsync(envelope.Type, fromUserId, signal.Clone());
            return;
        }
        if (!IsActiveCall(callId) &&
            !_incomingCalls.ContainsKey(callId) &&
            !_participantSnapshots.ContainsKey(callId))
            return;
        var queue = _pendingSignals.GetOrAdd(
            callId,
            static _ => new ConcurrentQueue<PendingVoiceCallSignal>());
        while (queue.Count >= 128) queue.TryDequeue(out _);
        queue.Enqueue(new PendingVoiceCallSignal(envelope.Type, fromUserId, signal.Clone()));
    }

    private void HandleServerError(JsonElement payload)
    {
        var code = GetString(payload, "code") ?? "realtime.error";
        if (!code.StartsWith("call.", StringComparison.Ordinal) &&
            !code.StartsWith("webrtc.", StringComparison.Ordinal))
            return;
        RaiseError(GetString(payload, "message") ?? "通话服务返回错误。", null);
    }

    private void RealtimeClient_ConnectionStateChanged(
        object? sender,
        ChatRealtimeConnectionStateChangedEventArgs e)
    {
        var window = _callWindow;
        if (window is not null)
            _ = window.SetRealtimeStateAsync(e.State.ToString(), e.Error?.Message);
        if (e.State is ChatRealtimeConnectionState.Reconnecting
            or ChatRealtimeConnectionState.Stopped
            or ChatRealtimeConnectionState.WaitingForLogin)
            _ = EndCallAfterRealtimeLossSafeAsync(e.Error);
    }

    private async Task EndCallAfterRealtimeLossSafeAsync(Exception? exception)
    {
        try
        {
            await _operationLock.WaitAsync();
            try
            {
                var active = _activeCall;
                _activeCall = null;
                _incomingCalls.Clear();
                _participantSnapshots.Clear();
                _pendingSignals.Clear();
                if (active is null)
                    return;
                await CloseCallWindowAsync(suppressLeaveEvent: true);
            }
            finally
            {
                _operationLock.Release();
            }
            RaiseError("实时信令连接已中断,本次通话已结束。请在网络恢复后重新发起。", exception);
        }
        catch (Exception endException)
        {
            RaiseError("实时连接中断后无法完整清理通话。", endException);
        }
    }

    private async Task OpenCallWindowAsync(ActiveVoiceCall call, string title)
    {
        await _dispatcher.InvokeAsync(() =>
        {
            if (_callWindow is not null)
            {
                if (_callWindow.WindowState == WindowState.Minimized)
                    _callWindow.WindowState = WindowState.Normal;
                _callWindow.Activate();
                return;
            }

            var currentUser = ClientSession.CurrentUser
                              ?? throw new InvalidOperationException("必须登录后才能开始通话。");
            _callWindow = new VoiceCallWindow(
                call.CallId,
                currentUser.Id,
                string.IsNullOrWhiteSpace(currentUser.NickName) ? currentUser.UserName : currentUser.NickName,
                title,
                _options);
            _callWindow.SignalGenerated += CallWindow_SignalGenerated;
            _callWindow.LeaveRequested += CallWindow_LeaveRequested;
            _callWindow.Closed += CallWindow_Closed;
            _callWindow.Show();
        });
        if (_participantSnapshots.TryGetValue(call.CallId, out var participants) && _callWindow is { } window)
            await window.UpdateParticipantsAsync(participants);
        if (_pendingSignals.TryRemove(call.CallId, out var signals))
        {
            while (signals.TryDequeue(out var signal) && _callWindow is { } activeWindow)
                await activeWindow.ApplyRemoteSignalAsync(signal.SignalType, signal.FromUserId, signal.Signal);
        }
    }

    private void CallWindow_SignalGenerated(object? sender, VoiceCallSignalEventArgs e) =>
        _ = SendWindowSignalSafeAsync(e);

    private async Task SendWindowSignalSafeAsync(VoiceCallSignalEventArgs signal)
    {
        try
        {
            var active = _activeCall;
            if (active is null || !string.Equals(active.CallId, signal.CallId, StringComparison.Ordinal))
                return;
            if (!await _realtimeClient.SendAsync(signal.SignalType, signal.Payload, active.ConversationId))
                RaiseError("实时连接已断开,WebRTC 信令未能发送。", null);
        }
        catch (Exception exception)
        {
            RaiseError("发送 WebRTC 信令失败。", exception);
        }
    }

    private void CallWindow_LeaveRequested(object? sender, EventArgs e) => _ = LeaveSafeAsync();

    private async Task LeaveSafeAsync()
    {
        try { await LeaveAsync(); }
        catch (Exception exception) { RaiseError("离开通话失败。", exception); }
    }

    private void CallWindow_Closed(object? sender, EventArgs e)
    {
        if (sender is VoiceCallWindow window)
        {
            window.SignalGenerated -= CallWindow_SignalGenerated;
            window.LeaveRequested -= CallWindow_LeaveRequested;
            window.Closed -= CallWindow_Closed;
        }
        _callWindow = null;
    }

    private async Task CloseCallWindowAsync(bool suppressLeaveEvent)
    {
        VoiceCallWindow? closingWindow = null;
        await _dispatcher.InvokeAsync(() =>
        {
            var window = _callWindow;
            _callWindow = null;
            if (window is null)
                return;
            closingWindow = window;
            if (suppressLeaveEvent)
                window.SuppressLeaveNotification();
            window.Close();
        });
        if (closingWindow is not null)
            await closingWindow.WaitForDisposalAsync();
    }

    private bool IsActiveCall(string callId) =>
        string.Equals(_activeCall?.CallId, callId, StringComparison.Ordinal);

    private static IReadOnlyList<VoiceCallParticipant> ReadParticipants(JsonElement payload)
    {
        if (payload.ValueKind != JsonValueKind.Object ||
            !payload.TryGetProperty("participants", out var items) ||
            items.ValueKind != JsonValueKind.Array)
            return [];

        var result = new List<VoiceCallParticipant>();
        foreach (var item in items.EnumerateArray())
        {
            if (!TryGetString(item, "userId", out var userId))
                continue;
            result.Add(new VoiceCallParticipant(userId, GetString(item, "displayName") ?? userId));
        }
        return result;
    }

    private static bool TryGetString(JsonElement payload, string propertyName, out string value)
    {
        value = string.Empty;
        if (payload.ValueKind != JsonValueKind.Object ||
            !payload.TryGetProperty(propertyName, out var property) ||
            property.ValueKind != JsonValueKind.String)
            return false;
        value = property.GetString()?.Trim() ?? string.Empty;
        return value.Length > 0;
    }

    private static string? GetString(JsonElement payload, string propertyName) =>
        TryGetString(payload, propertyName, out var value) ? value : null;

    private sealed record PendingVoiceCallSignal(
        string SignalType,
        string FromUserId,
        JsonElement Signal);

    private void RaiseError(string message, Exception? exception) =>
        _dispatcher.BeginInvoke(() => Error?.Invoke(this, new VoiceCallErrorEventArgs(message, exception)));

    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref _disposed, 1) != 0)
            return;
        _realtimeClient.EnvelopeReceived -= RealtimeClient_EnvelopeReceived;
        _realtimeClient.ConnectionStateChanged -= RealtimeClient_ConnectionStateChanged;
        using var shutdown = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        try { await LeaveAsync(shutdown.Token); }
        catch { await CloseCallWindowAsync(suppressLeaveEvent: true); }
        _operationLock.Dispose();
    }
}

public sealed class VoiceCallOptions
{
    public IReadOnlyList<VoiceIceServer> IceServers { get; init; } =
        [new VoiceIceServer("stun:stun.l.google.com:19302")];

    public int MaximumMeshParticipants { get; init; } = 8;

    public bool StartWithNoiseSuppression { get; init; } = true;

    /// <summary>
    /// Reads optional deployment ICE settings without embedding long-lived TURN
    /// credentials in source. Separate URLs with semicolons.
    /// </summary>
    public static VoiceCallOptions FromEnvironment()
    {
        var configuredUrls = Environment.GetEnvironmentVariable("XFETOOLBOX_WEBRTC_ICE_SERVERS");
        if (string.IsNullOrWhiteSpace(configuredUrls))
            return new VoiceCallOptions();

        var urls = configuredUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        if (urls.Length == 0)
            return new VoiceCallOptions();
        var userName = Environment.GetEnvironmentVariable("XFETOOLBOX_TURN_USERNAME");
        var credential = Environment.GetEnvironmentVariable("XFETOOLBOX_TURN_CREDENTIAL");
        return new VoiceCallOptions
        {
            IceServers = [new VoiceIceServer(urls, userName, credential)]
        };
    }
}

public sealed record VoiceIceServer(IReadOnlyList<string> Urls, string? UserName = null, string? Credential = null)
{
    public VoiceIceServer(string url, string? userName = null, string? credential = null)
        : this([url], userName, credential)
    {
    }
}

public sealed record ActiveVoiceCall(
    string CallId,
    string TargetType,
    string TargetId,
    string? ConversationId,
    bool IsIncoming);

public sealed record IncomingVoiceCall(
    string CallId,
    string FromUserId,
    string FromDisplayName,
    string TargetType,
    string TargetId,
    string? ConversationId,
    DateTimeOffset InvitedAtUtc);

public sealed record VoiceCallParticipant(string UserId, string DisplayName);

public sealed class IncomingVoiceCallEventArgs(IncomingVoiceCall invitation) : EventArgs
{
    public IncomingVoiceCall Invitation { get; } = invitation;
}

public sealed class VoiceCallErrorEventArgs(string message, Exception? exception) : EventArgs
{
    public string Message { get; } = message;

    public Exception? Exception { get; } = exception;
}