495 lines
24 KiB
C#
495 lines
24 KiB
C#
using System;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Hosting;
|
|
using MareSynchronos.Services.Mediator;
|
|
using MareSynchronos.MareConfiguration;
|
|
using MareSynchronos.Services.ServerConfiguration;
|
|
using MareSynchronos.WebAPI.AutoDetect;
|
|
using Dalamud.Plugin.Services;
|
|
using System.Numerics;
|
|
using System.Linq;
|
|
using MareSynchronos.Utils;
|
|
|
|
namespace MareSynchronos.Services.AutoDetect;
|
|
|
|
public class NearbyDiscoveryService : IHostedService, IMediatorSubscriber
|
|
{
|
|
private readonly ILogger<NearbyDiscoveryService> _logger;
|
|
private readonly MareMediator _mediator;
|
|
private readonly MareConfigService _config;
|
|
private readonly DiscoveryConfigProvider _configProvider;
|
|
private readonly DalamudUtilService _dalamud;
|
|
private readonly IObjectTable _objectTable;
|
|
private readonly DiscoveryApiClient _api;
|
|
private CancellationTokenSource? _loopCts;
|
|
private string? _lastPublishedSignature;
|
|
private bool _loggedLocalOnly;
|
|
private int _lastLocalCount = -1;
|
|
private int _lastMatchCount = -1;
|
|
private bool _loggedConfigReady;
|
|
private string? _lastSnapshotSig;
|
|
private volatile bool _isConnected;
|
|
private bool _notifiedDisabled;
|
|
private bool _notifiedEnabled;
|
|
private bool _disableSent;
|
|
private bool _lastAutoDetectState;
|
|
private DateTime _lastHeartbeat = DateTime.MinValue;
|
|
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(75);
|
|
|
|
public NearbyDiscoveryService(ILogger<NearbyDiscoveryService> logger, MareMediator mediator,
|
|
MareConfigService config, DiscoveryConfigProvider configProvider, DalamudUtilService dalamudUtilService,
|
|
IObjectTable objectTable, DiscoveryApiClient api)
|
|
{
|
|
_logger = logger;
|
|
_mediator = mediator;
|
|
_config = config;
|
|
_configProvider = configProvider;
|
|
_dalamud = dalamudUtilService;
|
|
_objectTable = objectTable;
|
|
_api = api;
|
|
}
|
|
|
|
public MareMediator Mediator => _mediator;
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
CancelAndDispose(ref _loopCts);
|
|
_loopCts = new CancellationTokenSource();
|
|
_mediator.Subscribe<ConnectedMessage>(this, _ => { _isConnected = true; _configProvider.TryLoadFromStapled(); });
|
|
_mediator.Subscribe<DisconnectedMessage>(this, _ => { _isConnected = false; _lastPublishedSignature = null; });
|
|
_mediator.Subscribe<AllowPairRequestsToggled>(this, OnAllowPairRequestsToggled);
|
|
_ = Task.Run(() => Loop(_loopCts.Token));
|
|
_lastAutoDetectState = _config.Current.EnableAutoDetectDiscovery;
|
|
return Task.CompletedTask;
|
|
}
|
|
private async void OnAllowPairRequestsToggled(AllowPairRequestsToggled msg)
|
|
{
|
|
try
|
|
{
|
|
if (!_config.Current.EnableAutoDetectDiscovery) return;
|
|
// Force a publish now so the server immediately reflects the new allow/deny state
|
|
_lastPublishedSignature = null; // ensure next loop won't skip
|
|
await PublishSelfOnceAsync(CancellationToken.None).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "OnAllowPairRequestsToggled failed");
|
|
}
|
|
}
|
|
|
|
private async Task PublishSelfOnceAsync(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected) return;
|
|
|
|
if (!_configProvider.HasConfig || _configProvider.IsExpired())
|
|
{
|
|
if (!_configProvider.TryLoadFromStapled())
|
|
{
|
|
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
var ep = _configProvider.PublishEndpoint;
|
|
var saltBytes = _configProvider.Salt;
|
|
if (string.IsNullOrEmpty(ep) || saltBytes is not { Length: > 0 }) return;
|
|
|
|
var saltHex = Convert.ToHexString(saltBytes);
|
|
string? displayName = null;
|
|
ushort meWorld = 0;
|
|
try
|
|
{
|
|
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
|
if (me != null)
|
|
{
|
|
displayName = me.Name.TextValue;
|
|
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
|
|
meWorld = (ushort)mePc.HomeWorld.RowId;
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (string.IsNullOrEmpty(displayName)) return;
|
|
|
|
var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
|
|
var ok = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
|
|
_logger.LogInformation("Nearby publish (manual/immediate): {result}", ok ? "success" : "failed");
|
|
if (ok)
|
|
{
|
|
_lastPublishedSignature = selfHash;
|
|
_lastHeartbeat = DateTime.UtcNow;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Immediate publish failed");
|
|
}
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_mediator.UnsubscribeAll(this);
|
|
CancelAndDispose(ref _loopCts);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private static void CancelAndDispose(ref CancellationTokenSource? cts)
|
|
{
|
|
if (cts == null) return;
|
|
try
|
|
{
|
|
cts.Cancel();
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
}
|
|
|
|
cts.Dispose();
|
|
cts = null;
|
|
}
|
|
|
|
private async Task Loop(CancellationToken ct)
|
|
{
|
|
_configProvider.TryLoadFromStapled();
|
|
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
bool currentState = _config.Current.EnableAutoDetectDiscovery;
|
|
if (currentState != _lastAutoDetectState)
|
|
{
|
|
_lastAutoDetectState = currentState;
|
|
if (currentState)
|
|
{
|
|
// Force immediate publish on toggle ON
|
|
try
|
|
{
|
|
// Ensure well-known is present
|
|
if (!_configProvider.HasConfig || _configProvider.IsExpired())
|
|
{
|
|
if (!_configProvider.TryLoadFromStapled())
|
|
{
|
|
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
var ep = _configProvider.PublishEndpoint;
|
|
var saltBytes = _configProvider.Salt;
|
|
if (!string.IsNullOrEmpty(ep) && saltBytes is { Length: > 0 })
|
|
{
|
|
var saltHex = Convert.ToHexString(saltBytes);
|
|
string? displayName = null;
|
|
ushort meWorld = 0;
|
|
try
|
|
{
|
|
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
|
if (me != null)
|
|
{
|
|
displayName = me.Name.TextValue;
|
|
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
|
|
meWorld = (ushort)mePc.HomeWorld.RowId;
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (!string.IsNullOrEmpty(displayName))
|
|
{
|
|
var selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
|
|
_lastPublishedSignature = null; // ensure future loop doesn't skip
|
|
var okNow = await _api.PublishAsync(ep!, new[] { selfHash }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
|
|
_logger.LogInformation("Nearby immediate publish on toggle ON: {result}", okNow ? "success" : "failed");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Nearby immediate publish on toggle ON failed");
|
|
}
|
|
|
|
if (!_notifiedEnabled)
|
|
{
|
|
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default));
|
|
_notifiedEnabled = true;
|
|
_notifiedDisabled = false;
|
|
_disableSent = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var ep = _configProvider.PublishEndpoint;
|
|
if (!string.IsNullOrEmpty(ep) && !_disableSent)
|
|
{
|
|
var disableUrl = ep.Replace("/publish", "/disable");
|
|
try { await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false); _disableSent = true; } catch { }
|
|
}
|
|
if (!_notifiedDisabled)
|
|
{
|
|
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default));
|
|
_notifiedDisabled = true;
|
|
_notifiedEnabled = false;
|
|
}
|
|
}
|
|
}
|
|
if (!_config.Current.EnableAutoDetectDiscovery || !_dalamud.IsLoggedIn || !_isConnected)
|
|
{
|
|
if (!_config.Current.EnableAutoDetectDiscovery && !string.IsNullOrEmpty(_configProvider.PublishEndpoint))
|
|
{
|
|
var disableUrl = _configProvider.PublishEndpoint.Replace("/publish", "/disable");
|
|
try
|
|
{
|
|
if (!_disableSent)
|
|
{
|
|
await _api.DisableAsync(disableUrl, ct).ConfigureAwait(false);
|
|
_disableSent = true;
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (!_notifiedDisabled)
|
|
{
|
|
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect disabled : you are not visible.", default));
|
|
_notifiedDisabled = true;
|
|
_notifiedEnabled = false;
|
|
}
|
|
}
|
|
await Task.Delay(1000, ct).ConfigureAwait(false);
|
|
continue;
|
|
}
|
|
if (!_configProvider.HasConfig || _configProvider.IsExpired())
|
|
{
|
|
if (!_configProvider.TryLoadFromStapled())
|
|
{
|
|
await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
else if (!_loggedConfigReady && _configProvider.NearbyEnabled)
|
|
{
|
|
_loggedConfigReady = true;
|
|
_logger.LogInformation("Nearby: well-known loaded and enabled; refresh={refresh}s, expires={exp}", _configProvider.RefreshSec, _configProvider.SaltExpiresAt);
|
|
}
|
|
|
|
var entries = await GetLocalNearbyAsync().ConfigureAwait(false);
|
|
// Log when local count changes (including 0) to indicate activity
|
|
if (entries.Count != _lastLocalCount)
|
|
{
|
|
_lastLocalCount = entries.Count;
|
|
_logger.LogTrace("Nearby: {count} players detected locally", _lastLocalCount);
|
|
}
|
|
|
|
// Try query server if config and endpoints are present
|
|
if (_configProvider.NearbyEnabled && !_configProvider.IsExpired() &&
|
|
_configProvider.Salt is { Length: > 0 })
|
|
{
|
|
try
|
|
{
|
|
var saltHex = Convert.ToHexString(_configProvider.Salt!);
|
|
// map hash->index for result matching
|
|
Dictionary<string, int> hashToIndex = new(StringComparer.Ordinal);
|
|
List<string> hashes = new(entries.Count);
|
|
foreach (var (entry, idx) in entries.Select((e, i) => (e, i)))
|
|
{
|
|
var h = (saltHex + entry.Name + entry.WorldId.ToString()).GetHash256();
|
|
hashToIndex[h] = idx;
|
|
hashes.Add(h);
|
|
}
|
|
|
|
try
|
|
{
|
|
var snapSig = string.Join(',', hashes.OrderBy(s => s, StringComparer.Ordinal)).GetHash256();
|
|
if (!string.Equals(snapSig, _lastSnapshotSig, StringComparison.Ordinal))
|
|
{
|
|
_lastSnapshotSig = snapSig;
|
|
var sample = entries.Take(5).Select(e =>
|
|
{
|
|
var hh = (saltHex + e.Name + e.WorldId.ToString()).GetHash256();
|
|
var shortH = hh.Length > 8 ? hh[..8] : hh;
|
|
return $"{e.Name}({e.WorldId})->{shortH}";
|
|
});
|
|
var saltShort = saltHex.Length > 8 ? saltHex[..8] : saltHex;
|
|
_logger.LogTrace("Nearby snapshot: {count} entries; salt={saltShort}…; samples=[{samples}]",
|
|
entries.Count, saltShort, string.Join(", ", sample));
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (!string.IsNullOrEmpty(_configProvider.PublishEndpoint))
|
|
{
|
|
string? displayName = null;
|
|
string? selfHash = null;
|
|
try
|
|
{
|
|
var me = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
|
if (me != null)
|
|
{
|
|
displayName = me.Name.TextValue;
|
|
ushort meWorld = 0;
|
|
if (me is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter mePc)
|
|
meWorld = (ushort)mePc.HomeWorld.RowId;
|
|
_logger.LogTrace("Nearby self ident: {name} ({world})", displayName, meWorld);
|
|
selfHash = (saltHex + displayName + meWorld.ToString()).GetHash256();
|
|
}
|
|
}
|
|
catch { /* ignore */ }
|
|
|
|
if (!string.IsNullOrEmpty(selfHash))
|
|
{
|
|
var sig = selfHash!;
|
|
if (!string.Equals(sig, _lastPublishedSignature, StringComparison.Ordinal))
|
|
{
|
|
_lastPublishedSignature = sig;
|
|
var shortSelf = selfHash!.Length > 8 ? selfHash[..8] : selfHash;
|
|
_logger.LogDebug("Nearby publish: self presence updated (hash={hash})", shortSelf);
|
|
var ok = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
|
|
_logger.LogInformation("Nearby publish result: {result}", ok ? "success" : "failed");
|
|
if (ok) _lastHeartbeat = DateTime.UtcNow;
|
|
if (ok)
|
|
{
|
|
if (!_notifiedEnabled)
|
|
{
|
|
_mediator.Publish(new NotificationMessage("Nearby Detection", "AutoDetect enabled : you are now visible.", default));
|
|
_notifiedEnabled = true;
|
|
_notifiedDisabled = false;
|
|
_disableSent = false; // allow future /disable when turning off again
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No changes; perform heartbeat publish if interval elapsed
|
|
if (DateTime.UtcNow - _lastHeartbeat >= HeartbeatInterval)
|
|
{
|
|
var okHb = await _api.PublishAsync(_configProvider.PublishEndpoint!, new[] { selfHash! }, displayName, ct, _config.Current.AllowAutoDetectPairRequests).ConfigureAwait(false);
|
|
_logger.LogDebug("Nearby heartbeat publish: {result}", okHb ? "success" : "failed");
|
|
if (okHb) _lastHeartbeat = DateTime.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogDebug("Nearby publish skipped (no changes)");
|
|
}
|
|
}
|
|
}
|
|
// else: no self character available; skip publish silently
|
|
}
|
|
|
|
// Query for matches if endpoint is available
|
|
if (!string.IsNullOrEmpty(_configProvider.QueryEndpoint))
|
|
{
|
|
// chunked queries
|
|
int batch = Math.Max(1, _configProvider.MaxQueryBatch);
|
|
List<ServerMatch> allMatches = new();
|
|
for (int i = 0; i < hashes.Count; i += batch)
|
|
{
|
|
var slice = hashes.Skip(i).Take(batch).ToArray();
|
|
var res = await _api.QueryAsync(_configProvider.QueryEndpoint!, slice, ct).ConfigureAwait(false);
|
|
if (res != null && res.Count > 0) allMatches.AddRange(res);
|
|
}
|
|
|
|
if (allMatches.Count > 0)
|
|
{
|
|
foreach (var m in allMatches)
|
|
{
|
|
if (hashToIndex.TryGetValue(m.Hash, out var idx))
|
|
{
|
|
var e = entries[idx];
|
|
entries[idx] = new NearbyEntry(e.Name, e.WorldId, e.Distance, true, m.Token, m.DisplayName, m.Uid);
|
|
}
|
|
}
|
|
}
|
|
if (allMatches.Count > 0)
|
|
{
|
|
_logger.LogInformation("Nearby: server returned {count} matches", allMatches.Count);
|
|
}
|
|
else
|
|
{
|
|
_logger.LogTrace("Nearby: server returned {count} matches", allMatches.Count);
|
|
}
|
|
|
|
// Log change in number of Umbra matches
|
|
int matchCount = entries.Count(e => e.IsMatch);
|
|
if (matchCount != _lastMatchCount)
|
|
{
|
|
_lastMatchCount = matchCount;
|
|
if (matchCount > 0)
|
|
{
|
|
var matchSamples = entries.Where(e => e.IsMatch).Take(5)
|
|
.Select(e => string.IsNullOrEmpty(e.DisplayName) ? e.Name : e.DisplayName!);
|
|
_logger.LogInformation("Nearby: {count} Umbra users nearby [{samples}]",
|
|
matchCount, string.Join(", ", matchSamples));
|
|
}
|
|
else
|
|
{
|
|
_logger.LogTrace("Nearby: {count} Umbra users nearby", matchCount);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Nearby query failed; falling back to local list");
|
|
if (ex.Message.Contains("DISCOVERY_SALT_EXPIRED", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_logger.LogInformation("Nearby: salt expired, refetching well-known");
|
|
try { await _configProvider.TryFetchFromServerAsync(ct).ConfigureAwait(false); } catch { }
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (!_loggedLocalOnly)
|
|
{
|
|
_loggedLocalOnly = true;
|
|
_logger.LogDebug("Nearby: well-known not available or disabled; running in local-only mode");
|
|
}
|
|
}
|
|
_mediator.Publish(new DiscoveryListUpdated(entries));
|
|
|
|
var delayMs = Math.Max(1000, _configProvider.MinQueryIntervalMs);
|
|
if (entries.Count == 0) delayMs = Math.Max(delayMs, 5000);
|
|
await Task.Delay(delayMs, ct).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "NearbyDiscoveryService loop error");
|
|
await Task.Delay(2000, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<List<NearbyEntry>> GetLocalNearbyAsync()
|
|
{
|
|
var list = new List<NearbyEntry>();
|
|
try
|
|
{
|
|
var local = await _dalamud.RunOnFrameworkThread(() => _dalamud.GetPlayerCharacter()).ConfigureAwait(false);
|
|
var localPos = local?.Position ?? Vector3.Zero;
|
|
int maxDist = Math.Clamp(_config.Current.AutoDetectMaxDistanceMeters, 5, 100);
|
|
|
|
int limit = Math.Min(200, _objectTable.Length);
|
|
for (int i = 0; i < limit; i++)
|
|
{
|
|
var obj = await _dalamud.RunOnFrameworkThread(() => _objectTable[i]).ConfigureAwait(false);
|
|
if (obj == null || obj.ObjectKind != Dalamud.Game.ClientState.Objects.Enums.ObjectKind.Player) continue;
|
|
if (local != null && obj.Address == local.Address) continue;
|
|
|
|
float dist = local == null ? float.NaN : Vector3.Distance(localPos, obj.Position);
|
|
if (!float.IsNaN(dist) && dist > maxDist) continue;
|
|
|
|
string name = obj.Name.TextValue;
|
|
ushort worldId = 0;
|
|
if (obj is Dalamud.Game.ClientState.Objects.SubKinds.IPlayerCharacter pc)
|
|
worldId = (ushort)pc.HomeWorld.RowId;
|
|
|
|
list.Add(new NearbyEntry(name, worldId, dist, false, null, null, null));
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignore
|
|
}
|
|
return list;
|
|
}
|
|
}
|