diff --git a/apps/chat/migrations/0003_thread_type.sql b/apps/chat/migrations/0003_thread_type.sql new file mode 100644 index 0000000..771e862 --- /dev/null +++ b/apps/chat/migrations/0003_thread_type.sql @@ -0,0 +1,12 @@ +-- Give a thread its kind. `chat_thread_type` is the client's ChatThreadType enum +-- (0 Player · 1 Club · 2 Party), which 0002 left off because every thread this server +-- served was a plain player conversation and src/thread-db.ts answered a constant 0. +-- Party threads (`POST /thread/party`) are type 2, so the value now varies per row and +-- has to be stored. Generated from src/thread-db.ts (THREAD_SCHEMA_DDL) — keep in sync. +-- +-- Defaulted 0 (Player), which is what every existing row is: the column is backfilled by +-- the default, not by an UPDATE. +-- +-- No index: it is read alongside the thread row that is already being fetched by primary +-- key or by the membership join, never selected on. +ALTER TABLE message_thread ADD COLUMN chat_thread_type INTEGER NOT NULL DEFAULT 0; diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index 79c01c5..c96683d 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -16,6 +16,7 @@ import { ChatResult, ChatThreadDto, ChatThreadWithMessagesDto, + CreatePartyChatResponse, CreateThreadRequest, CreateThreadResponse, FavoriteThreadRequest, @@ -23,8 +24,8 @@ import { json, messageCountParam, NOT_A_MEMBER_RESPONSE, + PartyChatThread, PartyInviteSettings, - PartyThread, RenameThreadRequest, SendMessageRequest, SendMessageResponse, @@ -36,11 +37,16 @@ import { } from './openapi' import { addThreadMember, + ChatThreadType, + createThread, getOrCreateThreadWithMembers, getThreadForPlayer, getThreadMemberIds, + getPartyThreadForPlayer, + getThreadMeta, getThreadsForPlayer, isThreadMember, + joinedChatContents, leftChatContents, markThreadRead, postMessage, @@ -54,6 +60,7 @@ import { import type { Context } from 'hono' import type { App, Env } from './context' import type { ChatMessage } from './message-db' +import type { ChatThread } from './thread-db' /** * Resolve the account id from a Bearer token. Returns `null` when the header is @@ -107,6 +114,25 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4 */ const PARTY_INVITE_LIFETIME_MINUTES = 60 +/** + * Whether a party is still open to newcomers — `GET /thread/party` joins the caller only + * inside this window, measured from the thread's `created_at`. + * + * It is the invite lifetime above, deliberately the same number rather than a second one: + * a player joins a party by holding its id in `LatestPartyChat`, which is what an invite + * puts there, so the join is the redemption of that invite and can't outlive it. A party + * older than the window still belongs to the people already on it — this gates JOINING, + * not reading, so nobody's own party expires out from under them. + * + * Fails CLOSED on a `created_at` that won't parse: no timestamp, no join. Nothing writes + * one that can't, and the alternative is an unbounded join window on a corrupt row. + */ +function isPartyJoinable(createdAt: string, now = Date.now()): boolean { + const opened = Date.parse(createdAt) + if (Number.isNaN(opened)) return false + return now - opened <= PARTY_INVITE_LIFETIME_MINUTES * 60_000 +} + /** * Who may start a chat with a player — the client's `ChatPrivacy` enum, served numerically * like every other enum on this build. `Friends` is what a fresh account reports, and what @@ -134,6 +160,20 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const const DM_PRIVACY_KEY = 'directMessagePrivacySetting' const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting' +/** + * Where a player's CURRENT party lives: the thread id of the party they most recently + * opened, written by `POST /thread/party` and read back by the GET on the same path. + * + * It is a player setting rather than a column because the party is a property of the + * PLAYER, not of the thread — "which party am I in" has one answer per person, and a + * player is in exactly one at a time. The settings bag is already read and written per + * player here, the same way the two privacy settings are. + * + * Nothing clears it: a party the player has left, or one that no longer exists, is + * filtered out on the read instead, which also covers an id written by something else. + */ +const LATEST_PARTY_CHAT_KEY = 'LatestPartyChat' + /** * A `ChatPrivacy` out of whatever was stored or posted — the member name as the client * sends it (case-insensitively), or the ordinal as the GET serves it, since a value that @@ -200,16 +240,87 @@ async function writeChatPrivacy( accountId: number, settings: Partial> ): Promise { - const merged: Record = { ...(await getPlayerSettings(env, accountId)) } + const patch: Record = {} for (const [key, value] of Object.entries(settings)) { - if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value] + if (value !== undefined) patch[key] = CHAT_PRIVACY_NAMES[value] } + await mergePlayerSettings(env, accountId, patch) +} + +/** + * Merge keys into the player's settings map, the way the `playersettings` worker's own PUT + * does. Never a whole-map write: the bag holds every setting the player has (OOBE state, + * tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings, + * which is what that worker stores and what its GET serves back. + */ +async function mergePlayerSettings( + env: Env, + accountId: number, + patch: Record +): Promise { + const merged: Record = { ...(await getPlayerSettings(env, accountId)), ...patch } await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged)) } +/** + * The thread id in the player's `LatestPartyChat` setting, or null when they have no party + * — nothing stored, or something stored that isn't a positive integer (the bag's values are + * strings, and this one could have been written by hand). + */ +async function readLatestPartyChatId(env: Env, accountId: number): Promise { + const stored = (await getPlayerSettings(env, accountId)) ?? {} + const id = Number.parseInt(String(stored[LATEST_PARTY_CHAT_KEY] ?? ''), 10) + return Number.isNaN(id) || id <= 0 ? null : id +} + /** The hub is a single global Durable Object instance, as every worker addresses it. */ const HUB_INSTANCE = 'global' +/** + * Push a thread's most recent message to everyone on it. This is how a NEW thread + * announces itself. + * + * The client has exactly two chat channels, `ChatMessageReceived` and `PlayerLeftChat`, + * and both carry a MESSAGE: there is no "a thread was opened" or "you were added" frame to + * send. So a conversation someone gains access to stays invisible on their client until a + * message arrives on it — which is why `/thread/withmembers` used to go unnoticed until the + * sender typed something, the thread having been created with only its "started a chat" + * notice and that notice never having left the database. + * + * Sending the notice fixes that without inventing anything: the message being pushed is one + * that genuinely exists on the thread. A no-op for a thread with nothing in it. + */ +async function pushThreadLatestMessage(c: Context, chatThreadId: number): Promise { + const [latest] = await getThreadMessages(c.env.DB, chatThreadId, { limit: 1 }) + if (latest === undefined) return + await pushChatMessage(c, latest) +} + +/** + * Announce that a player is now on a thread: post the `Player <@U…> joined` notice and push + * it to the whole thread. + * + * The exact shape of the leave route's goodbye, and for the same reasons. Everyone is told, + * not just the player who joined: a roster change is the thread's business — the others + * need to know who they are talking to — and there is no roster channel to say it on, so + * the notice is the message AND the signal. The new member is a member by the time this + * runs, so the same push is what puts the conversation on their screen. + * + * Call it AFTER the membership row exists, or the joiner is left out of the fan-out. + */ +async function announceJoin( + c: Context, + chatThreadId: number, + playerId: number +): Promise { + const notice = await postMessage(c.env.DB, { + chatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + contents: joinedChatContents(playerId), + }) + await pushChatMessage(c, notice) +} + /** * Push ChatMessageReceived to everyone in the thread once a message lands, so the * conversation updates live instead of on the next poll. @@ -303,6 +414,33 @@ function toSentChatMessage(message: ChatMessage) { } } +/** + * A thread in the PascalCase shape `POST /thread/party` answers — the client's + * CreatePartyChat formatter, which is its own and reads none of the camelCase keys the + * thread payloads carry. Ten wire keys; see {@link PartyChatThread} for why the CLR + * type's other three never appear. + * + * `Messages` and `LatestMessage` are both present here, unlike the camelCase pair which + * carries one or the other, and `ChatThreadName` goes out NULL when unnamed rather than + * as the empty string the camelCase projections must send. + */ +function toPartyChatThread(thread: ChatThread, messages: ChatMessage[]) { + return { + ChatThreadId: thread.chatThreadId, + ChatThreadType: thread.chatThreadType, + LastReadMessageId: thread.lastReadMessageId, + Messages: messages.map(toSentChatMessage), + LatestMessage: thread.latestMessage === null ? null : toSentChatMessage(thread.latestMessage), + PlayerIds: thread.playerIds, + // This formatter takes the null; only the camelCase projections have to send ''. + ChatThreadName: thread.chatThreadName === '' ? null : thread.chatThreadName, + SnoozedUntil: thread.snoozedUntil, + IsFavorited: thread.isFavorited, + // No thread on this table carries a club — club chat lives in the `clubs` worker. + ClubId: null, + } +} + /** * Send a message to a thread that already exists — every message after the one that * opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is @@ -607,7 +745,12 @@ const app = new Hono() const members = [...new Set([id, ...(await memberIds(c))])] if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) - const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) + const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id) + // A thread nobody has been told about is a thread nobody sees. Push its opening + // notice the moment it exists, rather than leaving the conversation to surface on + // whatever message happens to follow — there may not be one: this route is also + // called with an empty `messageContents`. + if (created) await pushThreadLatestMessage(c, chatThreadId) const contents = (await formField(c, 'messageContents'))?.trim() const posted = @@ -663,21 +806,74 @@ const app = new Hono() } ) - // The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is - // unknown — it hasn't been observed off a live client — so this answers an empty - // object, which parses as "no party" rather than failing the client's deserializer the - // way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored. - // Replace the body once the real shape is captured. + // The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's + // GetPartyChat. + // + // TWO paths, cheapest first: + // + // 1. ALREADY IN A PARTY — one D1 query (`getPartyThreadForPlayer`: the membership join, + // filtered to party threads, newest first) answers it outright. This is the common + // case, every read after the first, and it touches the settings KV not at all. + // 2. NOT IN ONE YET — only then is the caller's `LatestPartyChat` player setting read. + // The POST below writes that key for the player who OPENED the party; the client + // writes it (through `playersettings`) for a player pulled into someone else's. Such + // a player holds the key but no membership row — nothing has added them — so a + // membership-only read answered them "no party", which is the bug the join fixes. + // The read puts them on the thread and then serves it: `GET /thread/party` is how + // you enter a party, not merely how you look at one. + // + // Reaching path 2 means the caller is in NO party, so they cannot already be a member of + // the thread the key names — which is why the join here needs no membership check of its + // own, and why the age gate below can be unconditional. + // + // On path 2 the thread is checked to exist, to be a party, and to be YOUNGER THAN THE + // INVITE LIFETIME before anyone is added to it, so a key naming a DM, a thread that is + // gone, or an hours-old party can't produce a membership row in it. + // + // The age check gates JOINING only — path 1 never reaches it, so a party keeps being + // served to the players already on it however old it is, rather than going dark on them + // after an hour. `LatestPartyChat` is the caller's own player setting, which the client + // can PUT to anything through the `playersettings` worker, so the key IS the invite here + // and the window is what keeps it from being a permanent one. Tighten further (an invite + // the party actually issued) if parties ever need to be closed outright. + // + // SAME PATH, DIFFERENT BODY from the POST: this one is the BARE thread, with no + // `{ ChatThread, ChatResult }` wrapper around it. Same ten-key PascalCase projection + // inside, so the two share `toPartyChatThread` — but nothing else, which is why these + // are two handlers rather than one verb-agnostic one. + // + // No party answers `{}`: the client parses that as a thread with everything at its + // default, which reads as "no party", where a 404 or a null body would fail its + // deserializer. + // + // `maxCount` and `mode` are accepted and ignored. `maxCount=1` is most likely the + // number of party chats wanted, which is already what a single-thread body serves; if + // it turns out to size `Messages` instead, ignoring it only ever serves MORE history + // than asked for, where guessing wrong the other way would truncate the party's + // messages to one. `mode` is unknown. .get( '/thread/party', describeRoute({ tags: ['Threads'], - summary: 'The caller’s party thread (stub)', + summary: 'The caller’s current party thread (GetPartyChat)', description: [ - 'STUB — the response shape has not been observed off a live client, so this answers an', - 'empty object `{}`, which parses as "no party" rather than failing the client’s', - 'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and', - 'ignored. Replace the body once the real shape is captured.', + 'The party the caller is currently in: the newest party thread they are a member of,', + 'answered from a single query. Failing that, the thread named by their own', + '`LatestPartyChat` player setting — which `POST /thread/party` writes for the player', + 'who opened the party and the client writes for a player who joins someone else’s.', + '', + 'That second path JOINS: a caller who is not on any party yet is ADDED to the thread', + 'their key names and then served it, which is how a player pulled into someone else’s', + 'party enters it — they hold the key but no membership row, and a membership-only read', + 'answers them "no party". The thread must exist, be a party, and be younger than the', + '60-minute invite lifetime before anyone is added, so a key naming a DM, a deleted', + 'thread or a stale party answers `{}` and writes nothing. The age gate is on JOINING', + 'only — a player already on a party is served it however old it is. A join posts a', + '"Player <@U…> joined" notice and pushes it to the party, so the people already in it', + 'see who arrived.', + '', + 'The BARE thread, unlike the POST on the same path, which wraps the same projection in', + '`{ ChatThread, ChatResult }`. `maxCount` and `mode` are accepted and ignored.', ].join(' '), security: AUTHED, parameters: [ @@ -685,26 +881,121 @@ const app = new Hono() name: 'maxCount', in: 'query', required: false, - description: 'Page size the client sends (1). Ignored by the stub', + description: 'Page size the client sends (1). Accepted and ignored', schema: { type: 'integer' }, }, { name: 'mode', in: 'query', required: false, - description: 'Unknown mode selector the client sends (0). Ignored by the stub', + description: 'Unknown mode selector the client sends (0). Accepted and ignored', schema: { type: 'integer' }, }, ], responses: { - 200: json(PartyThread, 'Always `{}` — the stub carries no party'), + 200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'), 401: UNAUTHORIZED_RESPONSE, }, }), async (c) => { const id = await authedId(c) if (id === null) return c.body(null, 401) - return c.json({}) + + // Path 1: already in a party. One query, no settings read. + let thread = await getPartyThreadForPlayer(c.env.DB, id) + + // Path 2: not in one — the key is the only thing that can name a party to join. + if (thread === null) { + const chatThreadId = await readLatestPartyChatId(c.env, id) + if (chatThreadId === null) return c.json({}) + + // Checked BEFORE the join: a gone thread, one of some other type, or a party + // too old to still be taking people isn't something to put anybody on — + // whoever wrote the key. + const meta = await getThreadMeta(c.env.DB, chatThreadId) + if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({}) + if (!isPartyJoinable(meta.createdAt)) return c.json({}) + + await addThreadMember(c.env.DB, chatThreadId, id) + // The same announcement the add-member route makes: the party learns someone + // walked in. It is also usually a party's FIRST message — one opens empty. + await announceJoin(c, chatThreadId, id) + thread = await getThreadForPlayer(c.env.DB, chatThreadId, id) + if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after join`) + } + + const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, { + limit: DEFAULT_THREAD_MESSAGE_COUNT, + }) + return c.json(toPartyChatThread(thread, messages)) + } + ) + + // Open a party — the client's CreatePartyChat. A thread of type 2 + // (`ChatThreadType.Party`) holding only the caller, which the client then fills by + // inviting people onto it (`POST /thread/{id}/member/{playerId}`). The one place a + // thread is opened with a single member: every other create refuses a roster of just + // yourself, because a DM with nobody in it is a mistake, whereas a party you are so far + // the only member of is exactly what starting one looks like. + // + // Takes NO query params and NO body — the client posts to the bare path. + // + // Always a NEW party, never a fetch-or-create: a party is a session, not a standing + // conversation with a set of people, so resolving to the one you left this morning + // would hand the invitees its history. + // + // It opens EMPTY — no system "started a chat" notice, unlike every other new thread + // here. The observed response carries `Messages: []` with a null `LatestMessage`, so a + // notice would be a message the reference doesn't post. + // + // The body is the PascalCase `{ ChatThread, ChatResult }` wrapper, bare — no + // `{ success, error, value }` envelope — and the thread inside it is its own + // projection: ten keys, both `Messages` and `LatestMessage`, a null `ChatThreadName`, + // and a `ClubId` that exists nowhere else. See `toPartyChatThread`. + .post( + '/thread/party', + describeRoute({ + tags: ['Threads'], + summary: 'Open a party thread for the caller (CreatePartyChat)', + description: [ + 'The client’s CreatePartyChat. Opens a thread of type 2 (Party) whose only member is', + 'the caller — the client fills it by inviting players on afterwards. No query params', + 'and no body. Always a new party, never a fetch-or-create: a party is a session rather', + 'than a standing conversation, so an old one would hand the invitees its history. The', + 'only create that accepts a roster of just the caller, and the only one that opens with', + 'no messages at all — no “started a chat” notice, matching the observed', + '`Messages: []`. Records the new thread as the caller’s `LatestPartyChat` player', + 'setting, which is where `GET /thread/party` looks for it. Answers the bare PascalCase', + '`{ ChatThread, ChatResult }` wrapper, whose thread is a projection of its own — not the', + 'camelCase shape the other thread routes serve.', + ].join(' '), + security: AUTHED, + responses: { + 200: json(CreatePartyChatResponse, 'The new party thread, empty, with ChatResult 0'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return c.body(null, 401) + + const chatThreadId = await createThread(c.env.DB, [id], null, undefined, ChatThreadType.Party) + // This is what makes the party findable: the GET resolves the caller's current + // party through this key and nothing else. Written before the response, so a + // client that opens the party and immediately re-reads it can't miss it. + await mergePlayerSettings(c.env, id, { [LATEST_PARTY_CHAT_KEY]: String(chatThreadId) }) + + const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id) + if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after creation`) + // Read the messages back rather than assuming []: the party is empty as it is + // created, but the projection shouldn't be the thing that says so. + const messages = await getThreadMessages(c.env.DB, chatThreadId, { + limit: DEFAULT_THREAD_MESSAGE_COUNT, + }) + return c.json({ + ChatThread: toPartyChatThread(thread, messages), + ChatResult: CHAT_SUCCESS, + }) } ) @@ -887,7 +1178,11 @@ const app = new Hono() // rather than a lonely thread. if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400) - const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id) + const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id) + // The reported bug: this opened the thread silently, so the other player saw + // nothing until the first message landed. The opening notice is what tells them. + if (created) await pushThreadLatestMessage(c, chatThreadId) + const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT) const thread = await threadWithMessages(c, chatThreadId, id, limit) if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`) @@ -1102,7 +1397,10 @@ const app = new Hono() 'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference', 'does: 3 when the caller isn’t a member (which doubles as "no such thread", keeping a', 'thread’s existence private), 4 when the target is already on it, 0 on success.', - 'Idempotent — re-adding an existing member changes nothing.', + 'Idempotent — re-adding an existing member changes nothing. On success a', + '"Player <@U…> joined" system notice is posted and pushed to the whole thread: the', + 'existing members because the roster changed, the new one because that push is what', + 'puts the conversation on their screen.', ].join(' '), { parameters: [ @@ -1132,6 +1430,9 @@ const app = new Hono() } await addThreadMember(c.env.DB, chatThreadId, playerId) + // Everyone hears about it — the existing members because the roster changed under + // them, the new one because this is what puts the conversation on their screen. + await announceJoin(c, chatThreadId, playerId) return c.json(CHAT_SUCCESS) } ) diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts index 92dcd94..794d265 100644 --- a/apps/chat/src/openapi.ts +++ b/apps/chat/src/openapi.ts @@ -86,7 +86,9 @@ const threadBase = { chatThreadName: z .string() .describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'), - chatThreadType: z.int().describe('Always 0 — the only type the reference serves'), + chatThreadType: z + .int() + .describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'), snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'), isFavorited: z.boolean(), } @@ -233,11 +235,56 @@ export const ChatPrivacySettings = z.object({ }) /** - * `GET /thread/party` — STUB. The real shape hasn't been observed off a live client, so - * the route answers an empty object and this schema says so rather than guessing at - * fields. Fill both in together once the real response is captured. + * A thread in the PascalCase spelling the two `/thread/party` routes serve — the client's + * CreatePartyChat and GetPartyChat — the THIRD projection of a thread in this worker, and deliberately not + * unified with the two camelCase ones ({@link ChatThreadDto}, {@link + * ChatThreadWithMessagesDto}): the client has a separate formatter for this response, and + * a camelCase body decodes to a thread with every field at its default. + * + * Ten wire keys, off the client's own formatter. Its CLR type declares thirteen fields: + * two are `[IgnoreDataMember]` and one is a plain field rather than an auto-property, so + * none of the three ever serialises — don't add them back. + * + * Differences from the camelCase DTOs beyond the casing: + * - `Messages` and `LatestMessage` are BOTH present, where the camelCase pair carries one + * or the other. A party opens empty, so they come back `[]` and null. + * - `ChatThreadName` is NULL for an unnamed thread, not the empty string the camelCase + * projections have to send (the client dereferences that one unchecked; this formatter + * takes the null). + * - `ClubId` exists only here — null for a party, and for everything this worker serves: + * club chat lives in the `clubs` worker and nothing on this table carries a club. + * + * `GET /thread/party` serves this BARE; the POST wraps it in {@link + * CreatePartyChatResponse}. The GET also answers `{}` for a caller with no party, which + * decodes to a thread with every field at its default — the client reads that as no party, + * where a 404 or a null body would fail its deserializer. */ -export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown') +export const PartyChatThread = z.object({ + ChatThreadId: z.int(), + ChatThreadType: z.int().describe('The ChatThreadType enum: 0 Player · 1 Club · 2 Party'), + LastReadMessageId: z.int().describe('0 for a party that was just opened'), + Messages: z + .array(SentChatMessage) + .describe('Empty for a party just opened — nothing is posted into it'), + LatestMessage: SentChatMessage.nullable().describe('Null while the thread has no messages'), + PlayerIds: z.array(z.int()).describe('Just the caller, until players are invited on'), + ChatThreadName: z.string().nullable().describe('NULL when unnamed — not the empty string'), + SnoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'), + IsFavorited: z.boolean(), + ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'), +}) + +/** + * `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase + * like the thread inside it, with no `{ success, error, value }` envelope around it. + * + * `ChatResult` is the same twenty-member enum {@link ChatResult} records, served + * numerically; the create either works or fails the request, so it is always 0 here. + */ +export const CreatePartyChatResponse = z.object({ + ChatThread: PartyChatThread, + ChatResult: ChatResult, +}) /** `GET /` — the liveness probe. */ export const ServiceStatus = z.object({ diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index 980a9d0..a2df8b0 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -12,7 +12,10 @@ import { SCHEMA_DDL, } from '../../message-db' import { + addThreadMember, + ChatThreadType, createThread, + joinedChatContents, findThreadWithMembers, getThreadForPlayer, getThreadsForPlayer, @@ -394,8 +397,107 @@ describe('GET /settings/partyinvite', () => { }) }) +// The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat` +// player setting — as the BARE thread, where the POST wraps the same projection. describe('GET /thread/party', () => { - it('answers an empty object', async () => { + async function settings(playerId: number): Promise> { + return ( + (await env.RECFLARE_PLAYER_SETTINGS.get>( + `player:${playerId}`, + 'json' + )) ?? {} + ) + } + + it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => { + const caller = 883201 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + + const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, { + headers: await bearer(caller), + }) + expect(res.status).toBe(200) + // The whole body is the thread: no `ChatThread`/`ChatResult` keys around it. + expect(await res.json()).toEqual({ + ChatThreadId: ChatThread.ChatThreadId, + ChatThreadType: ChatThreadType.Party, + LastReadMessageId: 0, + Messages: [], + LatestMessage: null, + PlayerIds: [caller], + ChatThreadName: null, + SnoozedUntil: null, + IsFavorited: false, + ClubId: null, + }) + }) + + it('POST records the thread id in the caller’s LatestPartyChat setting', async () => { + const caller = 883202 + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${caller}`, + JSON.stringify({ OobeState: 'Complete' }) + ) + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + + // Merged, not overwritten: the bag holds every other setting the player has. + expect(await settings(caller)).toEqual({ + OobeState: 'Complete', + LatestPartyChat: String(ChatThread.ChatThreadId), + }) + }) + + it('follows the setting to the newest party after a second one is opened', async () => { + const caller = 883203 + await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) + const second = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } } + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + const body = (await res.json()) as { ChatThreadId: number } + expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId) + }) + + it('carries the party’s members and messages once it has them', async () => { + const caller = 883204 + const guest = 883205 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await addThreadMember(env.DB, ChatThread.ChatThreadId, guest) + const posted = await postMessage(env.DB, { + chatThreadId: ChatThread.ChatThreadId, + senderPlayerId: caller, + contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}', + }) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + const body = (await res.json()) as { + PlayerIds: number[] + Messages: Array<{ ChatMessageId: number; Contents: string }> + LatestMessage: { ChatMessageId: number } | null + } + expect(body.PlayerIds).toEqual([caller, guest]) + // PascalCase messages here too — the camelCase spelling is the other projections'. + expect(body.Messages).toHaveLength(1) + expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId) + expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId) + }) + + it('answers {} for a player who has never opened one', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, { headers: await bearer(883001), }) @@ -403,12 +505,339 @@ describe('GET /thread/party', () => { expect(await res.json()).toEqual({}) }) + it('JOINS a caller who holds the key but isn’t on the thread yet', async () => { + // How a player enters someone else's party: the client points their + // LatestPartyChat at it, and they have no membership row until this read. + const host = 883210 + const guest = 883211 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${guest}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) + const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] } + + // They're on the thread now, and the body they get back says so. + expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true) + expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId) + expect(body.PlayerIds).toEqual([host, guest]) + // The host sees them too — one thread, one roster. + expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([ + host, + guest, + ]) + }) + + it('re-joining is a no-op — the roster doesn’t grow on every read', async () => { + const caller = 883212 + await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) + + for (let i = 0; i < 3; i++) { + await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + } + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller]) + }) + + it('re-joins a caller who left, while their key still names the party', async () => { + // Leaving doesn't clear the key, so the next read walks them back in. Ending a + // party is what drops the key (`match`'s POST /player/logout). + const caller = 883206 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( + ChatThread.ChatThreadId + ) + expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true) + }) + + it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => { + // The fast path: a player already on a party is answered from the membership join, + // so the settings KV is never read. Proven by deleting the key the POST wrote. + const caller = 883230 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( + ChatThread.ChatThreadId + ) + }) + + it('serves the NEWEST party when the caller is a member of several', async () => { + // Membership outlives a party — nothing removes the row when one ends — so the + // party you are in is the most recent one you are on. + const caller = 883231 + await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) }) + const second = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } } + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( + ChatThread.ChatThreadId + ) + }) + + it('prefers the party the caller is ON over one their key merely names', async () => { + // The consequence of checking D1 first: while a membership row survives, a key + // pointing somewhere else is not consulted. Switching parties means leaving the old + // one (DELETE /thread/{id}/leave), not just repointing the key. + const caller = 883232 + const host = 883233 + const mine = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const other = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } } + const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } } + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${caller}`, + JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) }) + ) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( + own.ChatThread.ChatThreadId + ) + expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false) + }) + + /** Age a party by rewriting its `created_at` — what the join window is measured from. */ + async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise { + await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1') + .bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString()) + .run() + } + + it('refuses to join a party older than the 60-minute invite lifetime', async () => { + const host = 883220 + const latecomer = 883221 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await openedMinutesAgo(ChatThread.ChatThreadId, 61) + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${latecomer}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) }) + expect(await res.json()).toEqual({}) + // Refused, not quietly joined: no membership row was written. + expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false) + }) + + it('still joins a party inside the window', async () => { + const host = 883222 + const guest = 883223 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await openedMinutesAgo(ChatThread.ChatThreadId, 59) + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${guest}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) + expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest]) + }) + + it('keeps serving an aged party to the players already on it', async () => { + // The window gates JOINING only — a party doesn't go dark on its own members. + const host = 883224 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await openedMinutesAgo(ChatThread.ChatThreadId, 240) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) }) + expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe( + ChatThread.ChatThreadId + ) + }) + + it('refuses to join a party whose created_at won’t parse', async () => { + // Fails closed: no timestamp, no join. + const host = 883225 + const stranger = 883226 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1') + .bind(ChatThread.ChatThreadId, 'not-a-timestamp') + .run() + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${stranger}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) }) + expect(await res.json()).toEqual({}) + expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false) + }) + + it('answers {} when the setting names a thread that isn’t a party, joining nobody', async () => { + // The type check runs BEFORE the join, so a key pointed at someone else's DM can't + // put the caller in it. + const caller = 883207 + const dm = await createThread(env.DB, [883208, 883213]) + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${caller}`, + JSON.stringify({ LatestPartyChat: String(dm) }) + ) + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(await res.json()).toEqual({}) + expect(await isThreadMember(env.DB, dm, caller)).toBe(false) + }) + + it('answers {} when the setting names a thread that doesn’t exist', async () => { + const caller = 883214 + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${caller}`, + JSON.stringify({ LatestPartyChat: '99999' }) + ) + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(await res.json()).toEqual({}) + expect(await isThreadMember(env.DB, 99999, caller)).toBe(false) + }) + + it('answers {} for an unparseable stored id', async () => { + const caller = 883209 + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${caller}`, + JSON.stringify({ LatestPartyChat: 'not-an-id' }) + ) + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) }) + expect(await res.json()).toEqual({}) + }) + it('401s without a token', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`) expect(res.status).toBe(401) }) }) +// The POST on the same path is not a stub: it opens a real thread, of type Party, with +// only the caller on it — and answers the client's own PascalCase CreatePartyChat shape, +// which is neither of the two camelCase thread projections. +describe('POST /thread/party', () => { + it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => { + const caller = 883101 + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number } + // Every wire key, exactly — a party opens empty, unnamed and with no club, and the + // client reads none of the camelCase spellings the other thread routes serve. + expect(body).toEqual({ + ChatThread: { + ChatThreadId: body.ChatThread.ChatThreadId, + ChatThreadType: ChatThreadType.Party, + LastReadMessageId: 0, + Messages: [], + LatestMessage: null, + PlayerIds: [caller], + ChatThreadName: null, + SnoozedUntil: null, + IsFavorited: false, + ClubId: null, + }, + ChatResult: 0, + }) + + // And it's a real thread: it reads back through the normal thread routes. + const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller) + expect(stored?.chatThreadType).toBe(ChatThreadType.Party) + expect(stored?.playerIds).toEqual([caller]) + }) + + it('posts no “started a chat” notice into the party', async () => { + const caller = 883105 + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } } + expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([]) + }) + + it('opens a NEW party every call rather than resolving to the last one', async () => { + const caller = 883102 + const first = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const second = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const a = (await first.json()) as { ChatThread: { ChatThreadId: number } } + const b = (await second.json()) as { ChatThread: { ChatThreadId: number } } + expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId) + }) + + it('keeps party threads out of the DM fetch-or-create', async () => { + // A party the caller invited someone onto has the same roster as their DM would. + const caller = 883103 + const other = 883104 + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(caller), + }) + const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } } + await addThreadMember(env.DB, ChatThread.ChatThreadId, other) + + const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, { + method: 'POST', + headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ ids: String(other) }), + }) + const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number } + expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId) + expect(opened.chatThreadType).toBe(ChatThreadType.Player) + }) + + it('401s without a token', async () => { + const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' }) + expect(res.status).toBe(401) + }) +}) + describe('GET /thread/chatPrivacySetting', () => { it('reports Friends for both settings by default, keyed to the caller', async () => { const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, { @@ -991,11 +1420,28 @@ describe('ChatMessageReceived push', () => { } const sent = await hub.getByName('global').takeSent() - expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003]) expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( true ) - expect(sent[0]!.data).toEqual({ + // TWO waves over one channel, because that channel is all the client has: the + // thread's opening notice, which is what makes the new conversation appear at all, + // and then the message itself. Each goes to all three members. + const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5) + const message = sent.filter( + (n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller + ) + for (const wave of [notice, message]) { + expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003]) + } + expect(notice[0]!.data).toEqual({ + chatMessageId: expect.any(Number), + chatThreadId: chatThread.chatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + timeSent: expect.any(String), + contents: startedChatContents(caller), + moderationState: 0, + }) + expect(message[0]!.data).toEqual({ chatMessageId: chatThread.latestMessage.chatMessageId, chatThreadId: chatThread.chatThreadId, senderPlayerId: caller, @@ -1005,8 +1451,144 @@ describe('ChatMessageReceived push', () => { }) }) - it('pushes nothing when there is no message to push', async () => { - await send(886004, 'ids=886005&messageContents=') + it('pushes the opening notice even when no message is sent', async () => { + // The thread is real whether or not anything was said in it, and a thread nobody + // was told about is one nobody sees — the client has no "thread opened" channel. + const caller = 886004 + await send(caller, 'ids=886005&messageContents=') + const sent = await hub.getByName('global').takeSent() + expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005]) + expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID) + expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller)) + }) + + it('pushes nothing when the thread already existed and nothing was said', async () => { + // Second call on the same pair: no new thread, no message — nothing to announce. + await send(886006, 'ids=886007&messageContents=') + await hub.getByName('global').takeSent() + await send(886006, 'ids=886007&messageContents=') + expect(await hub.getByName('global').takeSent()).toEqual([]) + }) + + async function withMembers(caller: number, body: string): Promise { + return SELF.fetch(`${ORIGIN}/thread/withmembers`, { + method: 'POST', + headers: { + ...(await bearer(caller)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + } + + it('POST /thread/withmembers announces the thread it opens', async () => { + // The reported bug: this opened the conversation silently, so the other player saw + // nothing until the first message arrived. + const caller = 886010 + const other = 886011 + const res = await withMembers(caller, `ids=${other}`) + const { chatThreadId } = (await res.json()) as { chatThreadId: number } + + const sent = await hub.getByName('global').takeSent() + expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other]) + expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( + true + ) + expect(sent[0]!.data).toEqual({ + chatMessageId: expect.any(Number), + chatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + timeSent: expect.any(String), + contents: startedChatContents(caller), + moderationState: 0, + }) + }) + + it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => { + // Fetch-or-create: the second call opens nothing, so there is nothing to announce + // — re-announcing would ping both players every time the screen is opened. + const caller = 886012 + await withMembers(caller, 'ids=886013') + await hub.getByName('global').takeSent() + await withMembers(caller, 'ids=886013') + expect(await hub.getByName('global').takeSent()).toEqual([]) + }) + + it('adding a member announces the join to the WHOLE thread', async () => { + const caller = 886014 + const existing = 886015 + const added = 886016 + const res = await withMembers(caller, `ids=${existing}`) + const { chatThreadId } = (await res.json()) as { chatThreadId: number } + await hub.getByName('global').takeSent() + + await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, { + method: 'POST', + headers: await bearer(caller), + }) + + // Everyone: the two who were already there because the roster changed under them, + // and the new member because this is what puts the thread on their screen. + const sent = await hub.getByName('global').takeSent() + expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added]) + expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe( + true + ) + expect(sent[0]!.data).toMatchObject({ + chatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + contents: joinedChatContents(added), + }) + + // The notice is a real message on the thread, not just a frame — the mirror of the + // "left" one, and it is the thread's newest. + const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 }) + expect(newest?.contents).toBe(joinedChatContents(added)) + }) + + it('joining a party announces it to the party', async () => { + const host = 886017 + const guest = 886018 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${guest}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + await hub.getByName('global').takeSent() + + await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) + + // The host hears about it too — that is the point of announcing a join. + const sent = await hub.getByName('global').takeSent() + expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest]) + expect(sent[0]!.data).toMatchObject({ + chatThreadId: ChatThread.ChatThreadId, + senderPlayerId: SYSTEM_SENDER_ID, + contents: joinedChatContents(guest), + }) + }) + + it('announces a party join once, not on every subsequent read', async () => { + // The join happens once; reading your own party afterwards is not a roster change. + const host = 886019 + const guest = 886020 + const created = await SELF.fetch(`${ORIGIN}/thread/party`, { + method: 'POST', + headers: await bearer(host), + }) + const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } } + await env.RECFLARE_PLAYER_SETTINGS.put( + `player:${guest}`, + JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) }) + ) + + await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) + await hub.getByName('global').takeSent() + await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) }) expect(await hub.getByName('global').takeSent()).toEqual([]) }) }) @@ -1629,6 +2211,7 @@ describe('openapi', () => { 'GET /thread/{id}', 'GET /thread/{id}/message', 'POST /thread', + 'POST /thread/party', 'POST /thread/withmembers', 'POST /thread/{id}', 'POST /thread/{id}/favorite', diff --git a/apps/chat/src/thread-db.ts b/apps/chat/src/thread-db.ts index 1bbe0f0..f59bdbd 100644 --- a/apps/chat/src/thread-db.ts +++ b/apps/chat/src/thread-db.ts @@ -31,6 +31,7 @@ export const THREAD_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS message_thread ( chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT, chat_thread_name TEXT, + chat_thread_type INTEGER NOT NULL DEFAULT 0, latest_message_id INTEGER, created_at TEXT NOT NULL )`, @@ -70,23 +71,36 @@ export interface ChatThread { */ chatThreadName: string /** - * Which kind of conversation this is. Every thread the reference serves here comes back - * as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant — - * but the field itself has to be present: the client deserializes it as a non-nullable - * int and drops the whole response when it's missing. + * Which kind of conversation this is — the client's `ChatThreadType`: 0 Player, + * 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes + * the two. The field has to be present whatever its value: the client deserializes it + * as a non-nullable int and drops the whole response when it's missing. */ - chatThreadType: number + chatThreadType: ChatThreadTypeValue snoozedUntil: string | null isFavorited: boolean } -/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */ -const CHAT_THREAD_TYPE_DEFAULT = 0 +/** + * The client's `ChatThreadType` enum, stored on the thread and served numerically like + * every other enum on this build. A plain conversation — DM or group — is `Player`; + * `Party` is what `POST /thread/party` opens. Nothing here serves `Club` yet: club chat + * lives in the `clubs` worker, and the member is here so a stored 1 renders as itself + * rather than being read as a player thread. + */ +export const ChatThreadType = { + Player: 0, + Club: 1, + Party: 2, +} as const + +export type ChatThreadTypeValue = (typeof ChatThreadType)[keyof typeof ChatThreadType] /** The joined row backing a rendered thread, before it's shaped for the client. */ interface ThreadRow { chat_thread_id: number chat_thread_name: string | null + chat_thread_type: number player_ids: string | null last_read_message_id: number | null snoozed_until: string | null @@ -119,30 +133,24 @@ function toThread(row: ThreadRow): ChatThread { lastReadMessageId: row.last_read_message_id ?? 0, // Null in the column means "unnamed"; the client dereferences it unchecked. chatThreadName: row.chat_thread_name ?? '', - chatThreadType: CHAT_THREAD_TYPE_DEFAULT, + chatThreadType: row.chat_thread_type as ChatThreadTypeValue, snoozedUntil: row.snoozed_until, isFavorited: row.is_favorited !== 0, } } /** - * The thread list as it renders for one player, newest conversation first — the - * `?MessageCount=N` page of the thread endpoint. - * - * Reads only threads the player is a member of, so the membership join is the - * authorization check as well as the query. The inner ordered subquery around + * The shared projection behind every rendered thread — the list, the single read and the + * party read. Membership LEADS the join, so it is the authorization check as well as the + * query: a row only exists for a thread `me` is in. The inner ordered subquery around * group_concat is what makes `playerIds` come back sorted rather than in row order. + * + * Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there. */ -export async function getThreadsForPlayer( - db: D1Database, - playerId: number, - { limit = 50 }: { limit?: number } = {} -): Promise { - const { results } = await db - .prepare( - `SELECT +const THREAD_SELECT = `SELECT t.chat_thread_id, t.chat_thread_name, + t.chat_thread_type, (SELECT group_concat(player_id) FROM (SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id ORDER BY player_id)) AS player_ids, @@ -157,7 +165,22 @@ export async function getThreadsForPlayer( msg.moderation_state AS msg_moderation_state FROM thread_member me JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id - LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id + LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id` + +/** + * The thread list as it renders for one player, newest conversation first — the + * `?MessageCount=N` page of the thread endpoint. + * + * Membership scopes the query — see {@link THREAD_SELECT}. + */ +export async function getThreadsForPlayer( + db: D1Database, + playerId: number, + { limit = 50 }: { limit?: number } = {} +): Promise { + const { results } = await db + .prepare( + `${THREAD_SELECT} WHERE me.player_id = ?1 ORDER BY t.latest_message_id DESC LIMIT ?2` @@ -175,24 +198,7 @@ export async function getThreadForPlayer( ): Promise { const row = await db .prepare( - `SELECT - t.chat_thread_id, - t.chat_thread_name, - (SELECT group_concat(player_id) FROM - (SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id - ORDER BY player_id)) AS player_ids, - me.last_read_message_id, - me.snoozed_until, - me.is_favorited, - msg.chat_message_id AS msg_chat_message_id, - msg.chat_thread_id AS msg_chat_thread_id, - msg.sender_player_id AS msg_sender_player_id, - msg.time_sent AS msg_time_sent, - msg.contents AS msg_contents, - msg.moderation_state AS msg_moderation_state - FROM thread_member me - JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id - LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id + `${THREAD_SELECT} WHERE me.chat_thread_id = ?1 AND me.player_id = ?2` ) .bind(chatThreadId, playerId) @@ -200,6 +206,65 @@ export async function getThreadForPlayer( return row === null ? null : toThread(row) } +/** + * The party a player is already in, or null — the newest party thread carrying a + * membership row for them, rendered exactly as {@link getThreadForPlayer} renders one. + * + * The fast path behind `GET /thread/party`: a player already on a party is answered from + * ONE D1 query, with no player-settings read at all. `LatestPartyChat` is consulted only + * when this comes back null — that is, only for a player who has yet to JOIN a party. + * + * Newest first (by thread id, which is monotonic) because a player can hold membership in + * parties they never formally left: the one they are in is the most recent one they are on. + */ +export async function getPartyThreadForPlayer( + db: D1Database, + playerId: number +): Promise { + const row = await db + .prepare( + `${THREAD_SELECT} + WHERE me.player_id = ?1 AND t.chat_thread_type = ?2 + ORDER BY t.chat_thread_id DESC + LIMIT 1` + ) + .bind(playerId, ChatThreadType.Party) + .first() + return row === null ? null : toThread(row) +} + +/** What a thread IS, without any of what's in it. See {@link getThreadMeta}. */ +export interface ThreadMeta { + chatThreadType: ChatThreadTypeValue + /** ISO-8601 UTC, as `created_at` stores it. */ + createdAt: string +} + +/** + * A thread's kind and age, or null when there is no such thread — the one read here that + * does NOT go through membership. + * + * It exists for the party join (`GET /thread/party`), which has to know a thread is real, + * is a party, and is still young enough to join BEFORE it puts the caller on it; every + * other read is membership-scoped, and a caller joining a party is by definition not a + * member yet. It answers these two fields and nothing else — no name, no roster, no + * messages — precisely so it can't become a way to read a thread you aren't in. + */ +export async function getThreadMeta( + db: D1Database, + chatThreadId: number +): Promise { + const row = await db + .prepare( + 'SELECT chat_thread_type, created_at FROM message_thread WHERE chat_thread_id = ?1' + ) + .bind(chatThreadId) + .first<{ chat_thread_type: number; created_at: string }>() + return row === null + ? null + : { chatThreadType: row.chat_thread_type as ChatThreadTypeValue, createdAt: row.created_at } +} + /** * Whether a player may read or post to a thread. Every thread-scoped route gates on * this before touching messages. @@ -243,6 +308,19 @@ export function leftChatContents(playerId: number): string { return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` }) } +/** + * The counterpart notice when someone is pulled onto a thread or walks into a party: + * `Player <@U14922080> joined`. Same `<@U…>` mention token as the other two. + * + * It carries the roster change as a MESSAGE because that is the only way to carry one: the + * client has no join/leave channel, only `ChatMessageReceived` and `PlayerLeftChat`, both + * of which take a message. So the notice is both what the thread shows and what tells + * everyone — the new member's client included — that the roster moved. + */ +export function joinedChatContents(playerId: number): string { + return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> joined` }) +} + /** * Rename a thread. An empty name clears it back to unnamed, which renders as the member * list rather than a blank title. @@ -269,19 +347,24 @@ export async function setThreadName( * * Every call opens a *distinct* thread, even for a member set that already has one — * threads are not keyed by their membership, and the same pair may hold several. + * + * `type` is the thread's kind and defaults to `Player`, which covers DMs and group chats + * alike; a party opens as `Party`. */ export async function createThread( db: D1Database, playerIds: number[], name: string | null = null, - startedBy?: number + startedBy?: number, + type: ChatThreadTypeValue = ChatThreadType.Player ): Promise { const row = await db .prepare( - `INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2) + `INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at) + VALUES (?1, ?2, ?3) RETURNING chat_thread_id` ) - .bind(name, new Date().toISOString()) + .bind(name, type, new Date().toISOString()) .first<{ chat_thread_id: number }>() if (row === null) throw new Error('failed to create chat thread') @@ -322,27 +405,33 @@ export async function createThread( * thread is gone are ignored rather than resolved to: matching one would hand back an id * that nothing else in the worker can render, and — since the oldest match wins — it * would keep winning on every subsequent call. + * + * Matching is also scoped to one `type`: a party whose roster happens to be the people + * you are opening a DM with is a different conversation, and handing it back would drop + * the DM into the party. */ export async function findThreadWithMembers( db: D1Database, - playerIds: number[] + playerIds: number[], + type: ChatThreadTypeValue = ChatThreadType.Player ): Promise { const members = [...new Set(playerIds)] if (members.length === 0) return null - // ?1 is the member count; ?2… are the ids themselves. - const placeholders = members.map((_, i) => `?${i + 2}`).join(', ') + // ?1 is the member count, ?2 the thread type; ?3… are the ids themselves. + const placeholders = members.map((_, i) => `?${i + 3}`).join(', ') const row = await db .prepare( `SELECT m.chat_thread_id FROM thread_member m JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id + WHERE t.chat_thread_type = ?2 GROUP BY m.chat_thread_id HAVING COUNT(*) = ?1 AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1 ORDER BY m.chat_thread_id LIMIT 1` ) - .bind(members.length, ...members) + .bind(members.length, type, ...members) .first<{ chat_thread_id: number }>() return row?.chat_thread_id ?? null } @@ -352,16 +441,20 @@ export async function findThreadWithMembers( * simultaneous first-messages to the same set can still race into two threads; the * oldest-match rule in `findThreadWithMembers` means both parties converge on one of * them afterwards. + * + * `created` says which happened. The caller needs it: a thread that was just opened has to + * be PUSHED to its members, or it sits on the server unseen until somebody posts to it — + * the client has no "you were added to a thread" channel, so the opening notice going out + * over the socket is the only thing that makes a new conversation appear. */ export async function getOrCreateThreadWithMembers( db: D1Database, playerIds: number[], startedBy: number -): Promise { - return ( - (await findThreadWithMembers(db, playerIds)) ?? - (await createThread(db, playerIds, null, startedBy)) - ) +): Promise<{ chatThreadId: number; created: boolean }> { + const existing = await findThreadWithMembers(db, playerIds) + if (existing !== null) return { chatThreadId: existing, created: false } + return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true } } /** Everyone in a thread, ordered by id — the fan-out list for a push notification. */