188 lines
7.6 KiB
C#
188 lines
7.6 KiB
C#
using System.Collections.Concurrent;
|
|
using PlotLine.Models;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IWordCompanionPresenceService
|
|
{
|
|
Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration);
|
|
Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat);
|
|
Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId);
|
|
Task<WordCompanionPresenceStatus> GetStatusAsync(int userId);
|
|
Task<bool> IsConnectedAsync(int userId);
|
|
Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync();
|
|
Task<string?> GetCompanionConnectionIdAsync(int userId);
|
|
}
|
|
|
|
public sealed class WordCompanionPresenceService : IWordCompanionPresenceService
|
|
{
|
|
private static readonly TimeSpan HeartbeatTimeout = TimeSpan.FromSeconds(55);
|
|
private readonly ConcurrentDictionary<int, PresenceRecord> recordsByUser = new();
|
|
private readonly ConcurrentDictionary<string, int> usersByConnection = new(StringComparer.Ordinal);
|
|
|
|
public Task<WordCompanionPresenceStatus> RegisterAsync(int userId, string connectionId, WordCompanionPresenceRegistration registration)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var record = new PresenceRecord
|
|
{
|
|
UserID = userId,
|
|
ConnectionID = connectionId,
|
|
Status = WordCompanionPresenceStatuses.Connected,
|
|
CompanionVersion = Clean(registration.CompanionVersion),
|
|
MachineName = Clean(registration.MachineName),
|
|
DocumentOpen = registration.DocumentOpen,
|
|
CurrentDocumentName = Clean(registration.CurrentDocumentName),
|
|
LinkedProjectID = Positive(registration.LinkedProjectID),
|
|
LinkedBookID = Positive(registration.LinkedBookID),
|
|
ConnectedUtc = now,
|
|
LastHeartbeatUtc = now
|
|
};
|
|
|
|
recordsByUser.AddOrUpdate(userId, record, (_, _) => record);
|
|
usersByConnection[connectionId] = userId;
|
|
return Task.FromResult(ToStatus(record, now));
|
|
}
|
|
|
|
public Task<WordCompanionPresenceStatus> HeartbeatAsync(int userId, string connectionId, WordCompanionPresenceHeartbeat heartbeat)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var record = recordsByUser.AddOrUpdate(
|
|
userId,
|
|
_ => new PresenceRecord
|
|
{
|
|
UserID = userId,
|
|
ConnectionID = connectionId,
|
|
ConnectedUtc = now
|
|
},
|
|
(_, existing) =>
|
|
{
|
|
existing.ConnectionID = connectionId;
|
|
return existing;
|
|
});
|
|
|
|
record.Status = WordCompanionPresenceStatuses.Connected;
|
|
record.CompanionVersion = Clean(heartbeat.CompanionVersion) ?? record.CompanionVersion;
|
|
record.MachineName = Clean(heartbeat.MachineName) ?? record.MachineName;
|
|
record.DocumentOpen = heartbeat.DocumentOpen;
|
|
record.CurrentDocumentName = Clean(heartbeat.CurrentDocumentName);
|
|
record.LinkedProjectID = Positive(heartbeat.LinkedProjectID);
|
|
record.LinkedBookID = Positive(heartbeat.LinkedBookID);
|
|
record.LastHeartbeatUtc = now;
|
|
|
|
usersByConnection[connectionId] = userId;
|
|
return Task.FromResult(ToStatus(record, now));
|
|
}
|
|
|
|
public Task<WordCompanionPresenceStatus?> DisconnectAsync(string connectionId)
|
|
{
|
|
if (!usersByConnection.TryRemove(connectionId, out var userId)
|
|
|| !recordsByUser.TryGetValue(userId, out var record)
|
|
|| !string.Equals(record.ConnectionID, connectionId, StringComparison.Ordinal))
|
|
{
|
|
return Task.FromResult<WordCompanionPresenceStatus?>(null);
|
|
}
|
|
|
|
record.Status = WordCompanionPresenceStatuses.Offline;
|
|
record.ConnectionID = string.Empty;
|
|
return Task.FromResult<WordCompanionPresenceStatus?>(ToStatus(record, DateTime.UtcNow));
|
|
}
|
|
|
|
public Task<WordCompanionPresenceStatus> GetStatusAsync(int userId)
|
|
{
|
|
if (!recordsByUser.TryGetValue(userId, out var record))
|
|
{
|
|
return Task.FromResult(new WordCompanionPresenceStatus { UserID = userId });
|
|
}
|
|
|
|
return Task.FromResult(ToStatus(record, DateTime.UtcNow));
|
|
}
|
|
|
|
public async Task<bool> IsConnectedAsync(int userId)
|
|
=> (await GetStatusAsync(userId)).IsConnected;
|
|
|
|
public Task<string?> GetCompanionConnectionIdAsync(int userId)
|
|
{
|
|
if (!recordsByUser.TryGetValue(userId, out var record))
|
|
{
|
|
return Task.FromResult<string?>(null);
|
|
}
|
|
|
|
var status = ToStatus(record, DateTime.UtcNow);
|
|
return Task.FromResult(status.IsConnected && !string.IsNullOrWhiteSpace(record.ConnectionID)
|
|
? record.ConnectionID
|
|
: null);
|
|
}
|
|
|
|
public Task<IReadOnlyList<WordCompanionPresenceStatus>> MarkStaleOfflineAsync()
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var changed = new List<WordCompanionPresenceStatus>();
|
|
foreach (var record in recordsByUser.Values)
|
|
{
|
|
if (!string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
|
|
|| !record.LastHeartbeatUtc.HasValue
|
|
|| now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
record.Status = WordCompanionPresenceStatuses.Offline;
|
|
if (!string.IsNullOrWhiteSpace(record.ConnectionID))
|
|
{
|
|
usersByConnection.TryRemove(record.ConnectionID, out _);
|
|
record.ConnectionID = string.Empty;
|
|
}
|
|
|
|
changed.Add(ToStatus(record, now));
|
|
}
|
|
|
|
return Task.FromResult<IReadOnlyList<WordCompanionPresenceStatus>>(changed);
|
|
}
|
|
|
|
private static WordCompanionPresenceStatus ToStatus(PresenceRecord record, DateTime now)
|
|
{
|
|
var connected = string.Equals(record.Status, WordCompanionPresenceStatuses.Connected, StringComparison.Ordinal)
|
|
&& record.LastHeartbeatUtc.HasValue
|
|
&& now - record.LastHeartbeatUtc.Value <= HeartbeatTimeout;
|
|
|
|
return new WordCompanionPresenceStatus
|
|
{
|
|
UserID = record.UserID,
|
|
IsConnected = connected,
|
|
Status = connected ? WordCompanionPresenceStatuses.Connected : WordCompanionPresenceStatuses.Offline,
|
|
CompanionVersion = record.CompanionVersion,
|
|
MachineName = record.MachineName,
|
|
DocumentOpen = record.DocumentOpen,
|
|
CurrentDocumentName = record.CurrentDocumentName,
|
|
LinkedProjectID = record.LinkedProjectID,
|
|
LinkedBookID = record.LinkedBookID,
|
|
ConnectedUtc = record.ConnectedUtc,
|
|
LastHeartbeatUtc = record.LastHeartbeatUtc
|
|
};
|
|
}
|
|
|
|
private static string? Clean(string? value)
|
|
{
|
|
var cleaned = (value ?? string.Empty).Trim();
|
|
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
|
|
}
|
|
|
|
private static int? Positive(int? value)
|
|
=> value is > 0 ? value.Value : null;
|
|
|
|
private sealed class PresenceRecord
|
|
{
|
|
public int UserID { get; init; }
|
|
public string ConnectionID { get; set; } = string.Empty;
|
|
public string Status { get; set; } = WordCompanionPresenceStatuses.Offline;
|
|
public string? CompanionVersion { get; set; }
|
|
public string? MachineName { get; set; }
|
|
public bool DocumentOpen { get; set; }
|
|
public string? CurrentDocumentName { get; set; }
|
|
public int? LinkedProjectID { get; set; }
|
|
public int? LinkedBookID { get; set; }
|
|
public DateTime? ConnectedUtc { get; set; }
|
|
public DateTime? LastHeartbeatUtc { get; set; }
|
|
}
|
|
}
|