[chat] parties WIP

This commit is contained in:
Devin Zuczek
2026-09-09 15:47:34 -04:00
parent cdb17f5284
commit f681af34fa
5 changed files with 1118 additions and 82 deletions
+12
View File
@@ -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;
+321 -20
View File
@@ -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<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
const patch: Record<string, string> = {}
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<string, string>
): Promise<void> {
const merged: Record<string, string> = { ...(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<number | null> {
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<App>, chatThreadId: number): Promise<void> {
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<App>,
chatThreadId: number,
playerId: number
): Promise<void> {
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<App>()
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<App>()
}
)
// 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 callers party thread (stub)',
summary: 'The callers 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 clients',
'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 elses.',
'',
'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 elses',
'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<App>()
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 callers 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 clients 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 callers `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<App>()
// 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<App>()
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads 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<App>()
}
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)
}
)
+52 -5
View File
@@ -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({
+588 -5
View File
@@ -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<Record<string, string>> {
return (
(await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`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 callers 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 partys 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 isnt 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 doesnt 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<void> {
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 wont 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 isnt 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 doesnt 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<Response> {
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',
+145 -52
View File
@@ -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<ChatThread[]> {
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<ChatThread[]> {
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<ChatThread | null> {
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<ChatThread | null> {
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<ThreadRow>()
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<ThreadMeta | null> {
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<number> {
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<number | null> {
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<number> {
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. */