mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
implement /api/messages/v1/sendMultiple
This commit is contained in:
@@ -184,6 +184,21 @@ export const SendMessageRequest = z.object({
|
|||||||
Data: z.string().optional().describe('The message payload; often empty'),
|
Data: z.string().optional().describe('The message payload; often empty'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /api/messages/v1/sendMultiple` JSON body — the same message fanned out to
|
||||||
|
* several recipients. Unlike the form-encoded single send, this one is real JSON, so
|
||||||
|
* `Type` arrives as a number and `ToPlayerIds` as an array of numbers. The sender is
|
||||||
|
* still taken from the bearer token, not the body.
|
||||||
|
*/
|
||||||
|
export const SendMultipleMessagesRequest = z.object({
|
||||||
|
ToPlayerIds: z.array(z.int()).describe('Account ids of the recipients'),
|
||||||
|
Type: z
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe('The Message-model type, e.g. `20`. Passed through unmapped; defaults to 0'),
|
||||||
|
Data: z.string().optional().describe('The message payload; often empty'),
|
||||||
|
})
|
||||||
|
|
||||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||||
|
|
||||||
|
|||||||
+109
-11
@@ -16,9 +16,11 @@ import {
|
|||||||
intQuery,
|
intQuery,
|
||||||
json,
|
json,
|
||||||
JsonArray,
|
JsonArray,
|
||||||
|
jsonBody,
|
||||||
MutualFriendDto,
|
MutualFriendDto,
|
||||||
RelationshipDto,
|
RelationshipDto,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
SendMultipleMessagesRequest,
|
||||||
SuccessErrorEnvelope,
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
@@ -44,6 +46,39 @@ import type {
|
|||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
|
||||||
|
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
|
||||||
|
*/
|
||||||
|
type Message = {
|
||||||
|
FromPlayerId: number
|
||||||
|
ToPlayerId: number
|
||||||
|
Type: number
|
||||||
|
Data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
|
||||||
|
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
|
||||||
|
* is no message store behind this, so the notification is the whole delivery.
|
||||||
|
*/
|
||||||
|
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
|
message.ToPlayerId,
|
||||||
|
NotificationType.MessageReceived,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to push MessageReceived notification', {
|
||||||
|
toPlayerId: message.ToPlayerId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||||
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||||
@@ -303,24 +338,87 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
|||||||
// The Message the notification carries. Mirrors the coach message's shape with
|
// The Message the notification carries. Mirrors the coach message's shape with
|
||||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||||
// drops only null/undefined from the frame).
|
// drops only null/undefined from the frame).
|
||||||
const message = {
|
const delivered = await pushMessage(c, {
|
||||||
FromPlayerId: fromPlayerId,
|
FromPlayerId: fromPlayerId,
|
||||||
ToPlayerId: toPlayerId,
|
ToPlayerId: toPlayerId,
|
||||||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||||||
Data: str(body.Data) ?? '',
|
Data: str(body.Data) ?? '',
|
||||||
|
})
|
||||||
|
if (!delivered) {
|
||||||
|
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return c.json({ success: true, error: '' })
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
}
|
||||||
toPlayerId,
|
)
|
||||||
NotificationType.MessageReceived,
|
|
||||||
message
|
// The bulk form of the send above: one message, several recipients. Posted as JSON
|
||||||
|
// (`{"ToPlayerIds":[205],"Type":20,"Data":""}`), not the form encoding the single
|
||||||
|
// send uses, so `Type` arrives as a number here.
|
||||||
|
.post(
|
||||||
|
'/api/messages/v1/sendMultiple',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Social'],
|
||||||
|
summary: 'Send one message to several players',
|
||||||
|
description:
|
||||||
|
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
|
||||||
|
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
|
||||||
|
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
|
||||||
|
'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' +
|
||||||
|
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
|
||||||
|
'The body is JSON rather than the single send’s form encoding, so `Type` is a ' +
|
||||||
|
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
|
||||||
|
'string, commonly empty. Repeated ids are delivered once.\n\n' +
|
||||||
|
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
|
||||||
|
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
|
||||||
|
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
|
||||||
|
'behind it a swallowed error would be a silently dropped message.',
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
|
||||||
|
responses: {
|
||||||
|
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||||
|
400: json(SuccessErrorEnvelope, 'No usable id in `ToPlayerIds`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const fromPlayerId = await authedId(c)
|
||||||
|
if (fromPlayerId === null) return unauthorized(c)
|
||||||
|
|
||||||
|
const body = (await c.req.json<Record<string, unknown>>().catch(() => ({}))) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
// Ids may arrive as numbers or as numeric strings; drop anything that isn't an
|
||||||
|
// id and de-duplicate, so a repeated id doesn't deliver the message twice.
|
||||||
|
const toPlayerIds = [
|
||||||
|
...new Set(
|
||||||
|
(Array.isArray(body.ToPlayerIds) ? body.ToPlayerIds : [])
|
||||||
|
.map((v) => (typeof v === 'number' ? v : Number.parseInt(String(v), 10)))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if (toPlayerIds.length === 0) {
|
||||||
|
return c.json({ success: false, error: 'ToPlayerIds is required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = typeof body.Type === 'number' ? body.Type : Number(body.Type) || 0
|
||||||
|
const data = typeof body.Data === 'string' ? body.Data : ''
|
||||||
|
|
||||||
|
// Every recipient is attempted even if an earlier one fails — the reachable
|
||||||
|
// players get their message either way.
|
||||||
|
const results = await Promise.all(
|
||||||
|
toPlayerIds.map((toPlayerId) =>
|
||||||
|
pushMessage(c, {
|
||||||
|
FromPlayerId: fromPlayerId,
|
||||||
|
ToPlayerId: toPlayerId,
|
||||||
|
Type: type,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
} catch (err) {
|
)
|
||||||
logger.error('failed to push MessageReceived notification', {
|
if (results.includes(false)) {
|
||||||
toPlayerId,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
})
|
|
||||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2329,6 +2329,63 @@ describe('messages', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
expect(await pushed()).toEqual([])
|
expect(await pushed()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The bulk form takes a JSON body, not the form encoding the single send uses.
|
||||||
|
const sendMultiple = async (body: unknown, headers?: Record<string, string>) => {
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
return exports.default.fetch(`${ORIGIN}/api/messages/v1/sendMultiple`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST /api/messages/v1/sendMultiple pushes one frame per recipient', async () => {
|
||||||
|
const res = await sendMultiple(
|
||||||
|
{ ToPlayerIds: [205, 206], Type: 20, Data: 'hi' },
|
||||||
|
await bearer('42')
|
||||||
|
)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||||
|
|
||||||
|
// Each frame is addressed to its own recipient; the sender is the token's subject.
|
||||||
|
expect(await pushed()).toEqual([
|
||||||
|
{
|
||||||
|
playerId: 205,
|
||||||
|
notificationType: MESSAGE_RECEIVED,
|
||||||
|
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
playerId: 206,
|
||||||
|
notificationType: MESSAGE_RECEIVED,
|
||||||
|
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
|
||||||
|
const res = await sendMultiple({ ToPlayerIds: [205, 205] }, await bearer('42'))
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const sent = await pushed()
|
||||||
|
expect(sent).toHaveLength(1)
|
||||||
|
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
|
||||||
|
for (const body of [{ Type: 20 }, { ToPlayerIds: [] }, { ToPlayerIds: ['nope', 0] }]) {
|
||||||
|
const res = await sendMultiple(body, await bearer('42'))
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerIds is required' })
|
||||||
|
expect(await pushed()).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('POST /api/messages/v1/sendMultiple is auth-gated', async () => {
|
||||||
|
const res = await sendMultiple({ ToPlayerIds: [205] })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(await pushed()).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('mutual friends', () => {
|
describe('mutual friends', () => {
|
||||||
@@ -3020,6 +3077,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/inventions/v1/settags',
|
'POST /api/inventions/v1/settags',
|
||||||
'POST /api/inventions/v1/updateprice',
|
'POST /api/inventions/v1/updateprice',
|
||||||
'POST /api/inventions/v6/save',
|
'POST /api/inventions/v6/save',
|
||||||
|
'POST /api/messages/v1/sendMultiple',
|
||||||
'POST /api/messages/v2/send',
|
'POST /api/messages/v2/send',
|
||||||
'POST /api/playerReputation/v1/bulk',
|
'POST /api/playerReputation/v1/bulk',
|
||||||
'POST /api/playerReputation/v2/bulk',
|
'POST /api/playerReputation/v2/bulk',
|
||||||
|
|||||||
Reference in New Issue
Block a user