diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 1bd050f..e0e6995 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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() }) diff --git a/apps/api/src/routes/social.ts b/apps/api/src/routes/social.ts index 7ba86c0..9bd3321 100644 --- a/apps/api/src/routes/social.ts +++ b/apps/api/src/routes/social.ts @@ -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, message: Message): Promise { + 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({ 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: '' }) + } + ) + + // 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>().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', { - toPlayerId, - error: err instanceof Error ? err.message : String(err), - }) + ) + if (results.includes(false)) { return c.json({ success: false, error: 'Failed to deliver message' }, 500) } diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index bbeef53..e8f3983 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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) => { + 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',