Skip to content

Commit

Permalink
Merge pull request #255 from smoogipoo/friend-presence-2
Browse files Browse the repository at this point in the history
Always broadcast user presence to users' friends
  • Loading branch information
peppy authored Jan 15, 2025
2 parents 718fb5a + 83f5408 commit 8987732
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 37 deletions.
10 changes: 5 additions & 5 deletions SampleMultiplayerClient/SampleMultiplayerClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
</ItemGroup>

</Project>
10 changes: 5 additions & 5 deletions SampleSpectatorClient/SampleSpectatorClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
</ItemGroup>

</Project>
98 changes: 88 additions & 10 deletions osu.Server.Spectator.Tests/MetadataHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ public class MetadataHubTest

private readonly MetadataHub hub;
private readonly EntityStore<MetadataClientState> userStates;
private readonly Mock<HubCallerContext> mockUserContext;
private readonly Mock<IMetadataClient> mockCaller;
private readonly Mock<IMetadataClient> mockWatchersGroup;
private readonly Mock<IGroupManager> mockGroupManager;
private readonly Mock<IDatabaseAccess> mockDatabase;
private readonly Mock<IHubCallerClients<IMetadataClient>> mockClients;

public MetadataHubTest()
{
Expand All @@ -48,25 +50,21 @@ public MetadataHubTest()
new Mock<IDailyChallengeUpdater>().Object,
new Mock<IScoreProcessedSubscriber>().Object);

var mockContext = new Mock<HubCallerContext>();
mockContext.Setup(ctx => ctx.UserIdentifier).Returns(user_id.ToString());

mockWatchersGroup = new Mock<IMetadataClient>();
mockCaller = new Mock<IMetadataClient>();
mockGroupManager = new Mock<IGroupManager>();

var mockClients = new Mock<IHubCallerClients<IMetadataClient>>();
mockClients = new Mock<IHubCallerClients<IMetadataClient>>();
mockClients.Setup(clients => clients.Group(It.IsAny<string>()))
.Returns(new Mock<IMetadataClient>().Object);
mockClients.Setup(clients => clients.Group(MetadataHub.ONLINE_PRESENCE_WATCHERS_GROUP))
.Returns(mockWatchersGroup.Object);
mockClients.Setup(clients => clients.Caller)
.Returns(mockCaller.Object);

mockGroupManager = new Mock<IGroupManager>();

// this is to ensure that the `Context.GetHttpContext()` call in `MetadataHub.OnConnectedAsync()` doesn't nullref
// (the method in question is an extension, and it accesses `Features`; mocking further is not required).
mockContext.Setup(ctx => ctx.Features).Returns(new Mock<IFeatureCollection>().Object);
mockUserContext = createUserContext(user_id);

hub.Context = mockContext.Object;
hub.Context = mockUserContext.Object;
hub.Clients = mockClients.Object;
hub.Groups = mockGroupManager.Object;
}
Expand Down Expand Up @@ -227,5 +225,85 @@ public async Task UserWatchingHandling()
mgr => mgr.RemoveFromGroupAsync(It.IsAny<string>(), MetadataHub.ONLINE_PRESENCE_WATCHERS_GROUP, It.IsAny<CancellationToken>()),
Times.Once);
}

[Fact]
public async Task UserFriendsAlwaysNotified()
{
const int friend_id = 56;
const int non_friend_id = 57;

Mock<HubCallerContext> friendContext = createUserContext(friend_id);
Mock<HubCallerContext> nonFriendContext = createUserContext(non_friend_id);

mockDatabase.Setup(d => d.GetUserFriendsAsync(user_id)).ReturnsAsync([friend_id]);
mockClients.Setup(clients => clients.Group(MetadataHub.FRIEND_PRESENCE_WATCHERS_GROUP(friend_id)))
.Returns(() => mockCaller.Object);

await hub.OnConnectedAsync();

// Friend connects...
hub.Context = friendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, It.Is<UserPresence>(p => p.Status == UserStatus.Online)), Times.Once);

// Non-friend connects...
hub.Context = nonFriendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);

// Friend disconnects...
hub.Context = friendContext.Object;
await hub.OnDisconnectedAsync(null);
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, null), Times.Once);

// Non-friend disconnects...
hub.Context = nonFriendContext.Object;
await hub.OnDisconnectedAsync(null);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);
}

[Fact]
public async Task FriendPresenceBroadcastWhenConnected()
{
const int friend_id = 56;
const int non_friend_id = 57;

Mock<HubCallerContext> friendContext = createUserContext(friend_id);
Mock<HubCallerContext> nonFriendContext = createUserContext(non_friend_id);

mockDatabase.Setup(d => d.GetUserFriendsAsync(user_id)).ReturnsAsync([friend_id]);
mockClients.Setup(clients => clients.Group(MetadataHub.FRIEND_PRESENCE_WATCHERS_GROUP(friend_id)))
.Returns(() => mockCaller.Object);

// Friend connects...
hub.Context = friendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);

// Non-friend connects...
hub.Context = nonFriendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);

// We connect...
mockCaller.Invocations.Clear();
hub.Context = mockUserContext.Object;
await hub.OnConnectedAsync();
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, It.IsAny<UserPresence>()), Times.Once);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);
}

private Mock<HubCallerContext> createUserContext(int userId)
{
var mockContext = new Mock<HubCallerContext>();
mockContext.Setup(ctx => ctx.ConnectionId).Returns(userId.ToString());
mockContext.Setup(ctx => ctx.UserIdentifier).Returns(userId.ToString());
// this is to ensure that the `Context.GetHttpContext()` call in `MetadataHub.OnConnectedAsync()` doesn't nullref
// (the method in question is an extension, and it accesses `Features`; mocking further is not required).
mockContext.Setup(ctx => ctx.Features).Returns(new Mock<IFeatureCollection>().Object);
return mockContext;
}
}
}
8 changes: 4 additions & 4 deletions osu.Server.Spectator.Tests/osu.Server.Spectator.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.18.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
16 changes: 16 additions & 0 deletions osu.Server.Spectator/Database/DatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,22 @@ public async Task<bool> IsScoreProcessedAsync(long scoreId)
});
}

public async Task<IEnumerable<int>> GetUserFriendsAsync(int userId)
{
var connection = await getConnectionAsync();

// Query pulled from osu!bancho.
return await connection.QueryAsync<int>(
"SELECT zebra_id FROM phpbb_zebra z "
+ "JOIN phpbb_users u ON z.zebra_id = u.user_id "
+ "WHERE z.user_id = @UserId "
+ "AND friend = 1 "
+ "AND (`user_warnings` = '0' and `user_type` = '0')", new
{
UserId = userId
});
}

public async Task<bool> GetUserAllowsPMs(int userId)
{
var connection = await getConnectionAsync();
Expand Down
5 changes: 5 additions & 0 deletions osu.Server.Spectator/Database/IDatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ public interface IDatabaseAccess : IDisposable
/// </summary>
Task<phpbb_zebra?> GetUserRelation(int userId, int zebraId);

/// <summary>
/// Lists the specified user's friends.
/// </summary>
Task<IEnumerable<int>> GetUserFriendsAsync(int userId);

/// <summary>
/// Returns <see langword="true"/> if the user with the supplied <paramref name="userId"/> allows private messages from people not on their friends list.
/// </summary>
Expand Down
33 changes: 33 additions & 0 deletions osu.Server.Spectator/Entities/EntityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,39 @@ public async Task<ItemUsage<T>> GetForUse(long id, bool createOnMissing = false)
throw new TimeoutException("Could not allocate new entity after multiple retries. Something very bad has happened");
}

/// <summary>
/// Attempts to retrieve an existing entity with a lock for use.
/// </summary>
/// <param name="id">The ID of the requested entity.</param>
/// <returns>An existing <see cref="ItemUsage{T}"/> which allows reading or writing the item, or <c>null</c> if no entity exists. This should be disposed after usage.</returns>
public async Task<ItemUsage<T>?> TryGetForUse(long id)
{
TrackedEntity? item;

lock (entityMapping)
{
if (!entityMapping.TryGetValue(id, out item))
{
DogStatsd.Increment($"{statsDPrefix}.get-notfound");
return null;
}
}

try
{
await item.ObtainLockAsync();
}
// this may be thrown if the item was destroyed between when we retrieved the item usage and took the lock.
catch (InvalidOperationException)
{
DogStatsd.Increment($"{statsDPrefix}.get-notfound");
return null;
}

DogStatsd.Increment($"{statsDPrefix}.get");
return new ItemUsage<T>(item);
}

public async Task Destroy(long id)
{
TrackedEntity? item;
Expand Down
22 changes: 21 additions & 1 deletion osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class MetadataHub : StatefulUserHub<IMetadataClient, MetadataClientState>
private readonly IScoreProcessedSubscriber scoreProcessedSubscriber;

internal const string ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers";
internal static string FRIEND_PRESENCE_WATCHERS_GROUP(int userId) => $"metadata:online-presence-watchers:{userId}";

internal static string MultiplayerRoomWatchersGroup(long roomId) => $"metadata:multiplayer-room-watchers:{roomId}";

Expand Down Expand Up @@ -69,6 +70,21 @@ public override async Task OnConnectedAsync()

await logLogin(usage);
await Clients.Caller.DailyChallengeUpdated(dailyChallengeUpdater.Current);

using (var db = databaseFactory.GetInstance())
{
foreach (int friendId in await db.GetUserFriendsAsync(usage.Item.UserId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, FRIEND_PRESENCE_WATCHERS_GROUP(friendId));

// Check if the friend is online, and if they are, broadcast to the connected user.
using (var friendUsage = await TryGetStateFromUser(friendId))
{
if (friendUsage?.Item != null && shouldBroadcastPresenceToOtherUsers(friendUsage.Item))
await Clients.Caller.FriendPresenceUpdated(friendId, friendUsage.Item.ToUserPresence());
}
}
}
}
}

Expand Down Expand Up @@ -222,7 +238,11 @@ private Task broadcastUserPresenceUpdate(int userId, UserPresence? userPresence)
// we never want appearing offline users to have their status broadcast to other clients.
Debug.Assert(userPresence?.Status != UserStatus.Offline);

return Clients.Group(ONLINE_PRESENCE_WATCHERS_GROUP).UserPresenceUpdated(userId, userPresence);
return Task.WhenAll
(
Clients.Group(ONLINE_PRESENCE_WATCHERS_GROUP).UserPresenceUpdated(userId, userPresence),
Clients.Group(FRIEND_PRESENCE_WATCHERS_GROUP(userId)).FriendPresenceUpdated(userId, userPresence)
);
}

private bool shouldBroadcastPresenceToOtherUsers(MetadataClientState state)
Expand Down
2 changes: 2 additions & 0 deletions osu.Server.Spectator/Hubs/StatefulUserHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,7 @@ protected async Task<ItemUsage<TUserState>> GetOrCreateLocalUserState()
}

protected Task<ItemUsage<TUserState>> GetStateFromUser(int userId) => UserStates.GetForUse(userId);

protected Task<ItemUsage<TUserState>?> TryGetStateFromUser(int userId) => UserStates.TryGetForUse(userId);
}
}
24 changes: 12 additions & 12 deletions osu.Server.Spectator/osu.Server.Spectator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.405" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="AWSSDK.S3" Version="3.7.411.6" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="DogStatsD-CSharp-Client" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Catch" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Mania" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Osu" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Taiko" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Server.OsuQueueProcessor" Version="2024.507.0" />
<PackageReference Include="Sentry.AspNetCore" Version="4.12.1" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Catch" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Mania" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Osu" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Taiko" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Server.OsuQueueProcessor" Version="2024.1111.0" />
<PackageReference Include="Sentry.AspNetCore" Version="5.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down

0 comments on commit 8987732

Please sign in to comment.