implement /api/messages/v1/sendMultiple

This commit is contained in:
Devin Zuczek
2026-08-09 23:49:01 -04:00
parent c6ec993e2d
commit d461961e54
3 changed files with 182 additions and 11 deletions
+15
View File
@@ -184,6 +184,21 @@ export const SendMessageRequest = z.object({
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. */
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
+108 -10
View File
@@ -16,9 +16,11 @@ import {
intQuery,
json,
JsonArray,
jsonBody,
MutualFriendDto,
RelationshipDto,
SendMessageRequest,
SendMultipleMessagesRequest,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
@@ -44,6 +46,39 @@ import type {
/** The notifications hub is a single global DO instance (see the `notify` worker). */
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
* 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
// a real sender and recipient; `Data` stays a string, empty included (the hub
// drops only null/undefined from the frame).
const message = {
const delivered = await pushMessage(c, {
FromPlayerId: fromPlayerId,
ToPlayerId: toPlayerId,
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
Data: str(body.Data) ?? '',
})
if (!delivered) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toPlayerId,
NotificationType.MessageReceived,
message
return c.json({ success: true, error: '' })
}
)
} catch (err) {
logger.error('failed to push MessageReceived notification', {
toPlayerId,
error: err instanceof Error ? err.message : String(err),
// 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 callers 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 sends 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,
})
)
)
if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
}
+58
View File
@@ -2329,6 +2329,63 @@ describe('messages', () => {
expect(res.status).toBe(401)
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', () => {
@@ -3020,6 +3077,7 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v6/save',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',