mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
[chat] parties WIP
This commit is contained in:
@@ -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
@@ -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 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<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 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<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>()
|
||||
'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<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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 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<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 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<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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user