mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[api] fix 2025 friends notification
This commit is contained in:
@@ -216,6 +216,12 @@ inconsistency here without checking the client first.
|
|||||||
`GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table
|
`GiftPackage` null — no box is minted. `GET /econ/customAvatarItems/v1/owned` is that table
|
||||||
joined to the item record PLUS everything the caller created (drafts included): a creator
|
joined to the item record PLUS everything the caller created (drafts included): a creator
|
||||||
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
|
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
|
||||||
|
- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`:
|
||||||
|
`/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
|
||||||
|
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
|
||||||
|
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
|
||||||
|
refresh relationship state: sent alone, the request "worked" server-side and the target
|
||||||
|
saw nothing. Same pattern as the cheer.
|
||||||
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
getMutualFriendIds,
|
getMutualFriendIds,
|
||||||
getNotificationsForPlayer,
|
getNotificationsForPlayer,
|
||||||
getRelationshipsForPlayer,
|
getRelationshipsForPlayer,
|
||||||
|
MessageType,
|
||||||
MUTUAL_FRIENDS_LIMIT,
|
MUTUAL_FRIENDS_LIMIT,
|
||||||
|
RelationshipType,
|
||||||
removeFriend,
|
removeFriend,
|
||||||
sendFriendRequest,
|
sendFriendRequest,
|
||||||
setRelationshipFlag,
|
setRelationshipFlag,
|
||||||
@@ -122,6 +124,34 @@ async function notifyBoth(
|
|||||||
await notifyRelationship(c, otherId, change.other)
|
await notifyRelationship(c, otherId, change.other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store and push the MESSAGE that a friend-graph change shows the other player. The
|
||||||
|
* `RelationshipChanged` frames above only refresh relationship state; what the client
|
||||||
|
* actually surfaces — the request in their inbox with accept/decline, the "accepted your
|
||||||
|
* request" line — is a Message of the right {@link MessageType} delivered as
|
||||||
|
* `MessageReceived`, and stored so it's still there in `GET /api/messages/v2/get` for a
|
||||||
|
* player who was offline. `Data` is NULL for both — the client's Message DTO carries
|
||||||
|
* `"Data": null` on a FriendInvite (a payload-less type, unlike a DM's empty string); the
|
||||||
|
* sender id is the whole payload.
|
||||||
|
* Delivery failure is logged by `pushMessage`; the row is committed either way.
|
||||||
|
*/
|
||||||
|
async function pushFriendMessage(
|
||||||
|
c: Context<App>,
|
||||||
|
fromPlayerId: number,
|
||||||
|
toPlayerId: number,
|
||||||
|
type: MessageType.FriendInvite | MessageType.FriendRequestAccepted
|
||||||
|
): Promise<void> {
|
||||||
|
await pushMessage(
|
||||||
|
c,
|
||||||
|
await createNotification(c.env.DB, {
|
||||||
|
FromPlayerId: fromPlayerId,
|
||||||
|
ToPlayerId: toPlayerId,
|
||||||
|
Type: type,
|
||||||
|
Data: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
|
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
|
||||||
* private to the caller's own side of the row, so only the caller is notified. The
|
* private to the caller's own side of the row, so only the caller is notified. The
|
||||||
@@ -454,8 +484,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
'/api/relationships/v2/sendfriendrequest',
|
'/api/relationships/v2/sendfriendrequest',
|
||||||
friendMutation(
|
friendMutation(
|
||||||
'Send a friend request',
|
'Send a friend request',
|
||||||
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
|
'Offer friendship to another player. A new request also lands in the target’s inbox ' +
|
||||||
'and notifies nobody.'
|
'as a `FriendInvite` message (type 4), pushed as `MessageReceived` — that message, ' +
|
||||||
|
'not the `RelationshipChanged` frame, is what the client shows them. Re-sending an ' +
|
||||||
|
'outstanding request is a no-op and notifies nobody.'
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
@@ -464,6 +496,17 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
const change = await sendFriendRequest(c.env.DB, id, target)
|
const change = await sendFriendRequest(c.env.DB, id, target)
|
||||||
await notifyBoth(c, id, target, change)
|
await notifyBoth(c, id, target, change)
|
||||||
|
// A NEW request is what the target sees as a FriendInvite message (the reference
|
||||||
|
// sends it on exactly this outcome). A request that crosses one the target already
|
||||||
|
// had out auto-accepts instead — so the target hears their own request was accepted,
|
||||||
|
// as if they'd been accepted the ordinary way. A no-op re-send says nothing.
|
||||||
|
if (change.changed) {
|
||||||
|
if (change.self.RelationshipType === RelationshipType.FriendRequestSent) {
|
||||||
|
await pushFriendMessage(c, id, target, MessageType.FriendInvite)
|
||||||
|
} else if (change.self.RelationshipType === RelationshipType.Friend) {
|
||||||
|
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.json(change.self)
|
return c.json(change.self)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -474,8 +517,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
'/api/relationships/v2/acceptfriendrequest',
|
'/api/relationships/v2/acceptfriendrequest',
|
||||||
friendMutation(
|
friendMutation(
|
||||||
'Accept a friend request',
|
'Accept a friend request',
|
||||||
'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' +
|
'Turn a pending incoming request into a friendship. The requester is told with a ' +
|
||||||
'no-op and notifies nobody.'
|
'`FriendRequestAccepted` message (type 40) pushed as `MessageReceived`. Accepting ' +
|
||||||
|
'nothing pending is a no-op and notifies nobody.'
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
@@ -484,6 +528,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||||
const change = await acceptFriendRequest(c.env.DB, id, target)
|
const change = await acceptFriendRequest(c.env.DB, id, target)
|
||||||
await notifyBoth(c, id, target, change)
|
await notifyBoth(c, id, target, change)
|
||||||
|
// The requester is told, as a FriendRequestAccepted message from the accepter.
|
||||||
|
if (change.changed) {
|
||||||
|
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
|
||||||
|
}
|
||||||
return c.json(change.self)
|
return c.json(change.self)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6470,8 +6470,11 @@ describe('relationships', () => {
|
|||||||
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
||||||
|
|
||||||
// Both sides hear about it, each seeing the other player and their own side's
|
// Both sides hear about it, each seeing the other player and their own side's
|
||||||
// type: the sender Sent (1), the recipient Received (2).
|
// type: the sender Sent (1), the recipient Received (2). The target ALSO gets the
|
||||||
expect(await sentNotifications()).toEqual([
|
// request itself as a FriendInvite (4) message — that is what the client shows.
|
||||||
|
const sent = await sentNotifications()
|
||||||
|
expect(sent).toHaveLength(3)
|
||||||
|
expect(sent.slice(0, 2)).toEqual([
|
||||||
{
|
{
|
||||||
playerId: 770,
|
playerId: 770,
|
||||||
notificationType: 1,
|
notificationType: 1,
|
||||||
@@ -6483,6 +6486,34 @@ describe('relationships', () => {
|
|||||||
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
expect(sent[2]?.playerId).toBe(771)
|
||||||
|
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||||
|
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null })
|
||||||
|
|
||||||
|
// The message is stored under the same id, so an offline target finds it on login.
|
||||||
|
const frameId = (sent[2]?.data as unknown as { Id: number }).Id
|
||||||
|
expect(frameId).toBeGreaterThan(0)
|
||||||
|
const inbox = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') })
|
||||||
|
).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }>
|
||||||
|
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => {
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||||
|
await resetNotifications()
|
||||||
|
// Re-sending an outstanding request is a no-op: nothing is pushed at all.
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||||
|
expect(await sentNotifications()).toEqual([])
|
||||||
|
// The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester
|
||||||
|
// (772) is told their request was accepted rather than asked anything.
|
||||||
|
await mutate('/api/relationships/v2/sendfriendrequest', '773', 772)
|
||||||
|
const sent = await sentNotifications()
|
||||||
|
expect(sent).toHaveLength(3)
|
||||||
|
expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true)
|
||||||
|
expect(sent[2]?.playerId).toBe(772)
|
||||||
|
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||||
|
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('accepting notifies both players as Friend', async () => {
|
test('accepting notifies both players as Friend', async () => {
|
||||||
@@ -6490,8 +6521,13 @@ describe('relationships', () => {
|
|||||||
await resetNotifications()
|
await resetNotifications()
|
||||||
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||||
|
|
||||||
const sent = await sentNotifications()
|
const all = await sentNotifications()
|
||||||
expect(sent).toHaveLength(2)
|
expect(all).toHaveLength(3)
|
||||||
|
// The requester also gets the acceptance as a FriendRequestAccepted (40) message.
|
||||||
|
expect(all[2]?.playerId).toBe(780)
|
||||||
|
expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||||
|
expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null })
|
||||||
|
const sent = all.slice(0, 2)
|
||||||
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||||
expect(sent).toEqual(
|
expect(sent).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
@@ -6538,8 +6574,10 @@ describe('relationships', () => {
|
|||||||
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
||||||
|
|
||||||
const sent = await sentNotifications()
|
const sent = await sentNotifications()
|
||||||
expect(sent).toHaveLength(2)
|
expect(sent).toHaveLength(3)
|
||||||
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
|
for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3)
|
||||||
|
// ...and 820, whose request this crossed, is told it was accepted.
|
||||||
|
expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user