mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -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,
|
ChatResult,
|
||||||
ChatThreadDto,
|
ChatThreadDto,
|
||||||
ChatThreadWithMessagesDto,
|
ChatThreadWithMessagesDto,
|
||||||
|
CreatePartyChatResponse,
|
||||||
CreateThreadRequest,
|
CreateThreadRequest,
|
||||||
CreateThreadResponse,
|
CreateThreadResponse,
|
||||||
FavoriteThreadRequest,
|
FavoriteThreadRequest,
|
||||||
@@ -23,8 +24,8 @@ import {
|
|||||||
json,
|
json,
|
||||||
messageCountParam,
|
messageCountParam,
|
||||||
NOT_A_MEMBER_RESPONSE,
|
NOT_A_MEMBER_RESPONSE,
|
||||||
|
PartyChatThread,
|
||||||
PartyInviteSettings,
|
PartyInviteSettings,
|
||||||
PartyThread,
|
|
||||||
RenameThreadRequest,
|
RenameThreadRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResponse,
|
SendMessageResponse,
|
||||||
@@ -36,11 +37,16 @@ import {
|
|||||||
} from './openapi'
|
} from './openapi'
|
||||||
import {
|
import {
|
||||||
addThreadMember,
|
addThreadMember,
|
||||||
|
ChatThreadType,
|
||||||
|
createThread,
|
||||||
getOrCreateThreadWithMembers,
|
getOrCreateThreadWithMembers,
|
||||||
getThreadForPlayer,
|
getThreadForPlayer,
|
||||||
getThreadMemberIds,
|
getThreadMemberIds,
|
||||||
|
getPartyThreadForPlayer,
|
||||||
|
getThreadMeta,
|
||||||
getThreadsForPlayer,
|
getThreadsForPlayer,
|
||||||
isThreadMember,
|
isThreadMember,
|
||||||
|
joinedChatContents,
|
||||||
leftChatContents,
|
leftChatContents,
|
||||||
markThreadRead,
|
markThreadRead,
|
||||||
postMessage,
|
postMessage,
|
||||||
@@ -54,6 +60,7 @@ import {
|
|||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App, Env } from './context'
|
import type { App, Env } from './context'
|
||||||
import type { ChatMessage } from './message-db'
|
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
|
* 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
|
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
|
* 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
|
* 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 DM_PRIVACY_KEY = 'directMessagePrivacySetting'
|
||||||
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
|
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
|
* 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
|
* 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,
|
accountId: number,
|
||||||
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
|
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
|
const patch: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
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))
|
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. */
|
/** The hub is a single global Durable Object instance, as every worker addresses it. */
|
||||||
const HUB_INSTANCE = 'global'
|
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
|
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
|
||||||
* conversation updates live instead of on the next poll.
|
* 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
|
* 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
|
* 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))])]
|
const members = [...new Set([id, ...(await memberIds(c))])]
|
||||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
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 contents = (await formField(c, 'messageContents'))?.trim()
|
||||||
const posted =
|
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
|
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
|
||||||
// unknown — it hasn't been observed off a live client — so this answers an empty
|
// GetPartyChat.
|
||||||
// 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.
|
// TWO paths, cheapest first:
|
||||||
// Replace the body once the real shape is captured.
|
//
|
||||||
|
// 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(
|
.get(
|
||||||
'/thread/party',
|
'/thread/party',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Threads'],
|
tags: ['Threads'],
|
||||||
summary: 'The caller’s party thread (stub)',
|
summary: 'The caller’s current party thread (GetPartyChat)',
|
||||||
description: [
|
description: [
|
||||||
'STUB — the response shape has not been observed off a live client, so this answers an',
|
'The party the caller is currently in: the newest party thread they are a member of,',
|
||||||
'empty object `{}`, which parses as "no party" rather than failing the client’s',
|
'answered from a single query. Failing that, the thread named by their own',
|
||||||
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
|
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
|
||||||
'ignored. Replace the body once the real shape is captured.',
|
'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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -685,26 +881,121 @@ const app = new Hono<App>()
|
|||||||
name: 'maxCount',
|
name: 'maxCount',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: false,
|
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' },
|
schema: { type: 'integer' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'mode',
|
name: 'mode',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
required: false,
|
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' },
|
schema: { type: 'integer' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
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,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return c.body(null, 401)
|
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.
|
// rather than a lonely thread.
|
||||||
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
|
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 limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
|
||||||
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
const thread = await threadWithMessages(c, chatThreadId, id, limit)
|
||||||
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
|
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',
|
'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',
|
'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.',
|
'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(' '),
|
].join(' '),
|
||||||
{
|
{
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -1132,6 +1430,9 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
|
|
||||||
await addThreadMember(c.env.DB, chatThreadId, playerId)
|
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)
|
return c.json(CHAT_SUCCESS)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ const threadBase = {
|
|||||||
chatThreadName: z
|
chatThreadName: z
|
||||||
.string()
|
.string()
|
||||||
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
|
.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'),
|
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
|
||||||
isFavorited: z.boolean(),
|
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
|
* A thread in the PascalCase spelling the two `/thread/party` routes serve — the client's
|
||||||
* the route answers an empty object and this schema says so rather than guessing at
|
* CreatePartyChat and GetPartyChat — the THIRD projection of a thread in this worker, and deliberately not
|
||||||
* fields. Fill both in together once the real response is captured.
|
* 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. */
|
/** `GET /` — the liveness probe. */
|
||||||
export const ServiceStatus = z.object({
|
export const ServiceStatus = z.object({
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
SCHEMA_DDL,
|
SCHEMA_DDL,
|
||||||
} from '../../message-db'
|
} from '../../message-db'
|
||||||
import {
|
import {
|
||||||
|
addThreadMember,
|
||||||
|
ChatThreadType,
|
||||||
createThread,
|
createThread,
|
||||||
|
joinedChatContents,
|
||||||
findThreadWithMembers,
|
findThreadWithMembers,
|
||||||
getThreadForPlayer,
|
getThreadForPlayer,
|
||||||
getThreadsForPlayer,
|
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', () => {
|
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`, {
|
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
|
||||||
headers: await bearer(883001),
|
headers: await bearer(883001),
|
||||||
})
|
})
|
||||||
@@ -403,12 +505,339 @@ describe('GET /thread/party', () => {
|
|||||||
expect(await res.json()).toEqual({})
|
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 () => {
|
it('401s without a token', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
|
||||||
expect(res.status).toBe(401)
|
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', () => {
|
describe('GET /thread/chatPrivacySetting', () => {
|
||||||
it('reports Friends for both settings by default, keyed to the caller', async () => {
|
it('reports Friends for both settings by default, keyed to the caller', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
|
||||||
@@ -991,11 +1420,28 @@ describe('ChatMessageReceived push', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sent = await hub.getByName('global').takeSent()
|
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(
|
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
|
||||||
true
|
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,
|
chatMessageId: chatThread.latestMessage.chatMessageId,
|
||||||
chatThreadId: chatThread.chatThreadId,
|
chatThreadId: chatThread.chatThreadId,
|
||||||
senderPlayerId: caller,
|
senderPlayerId: caller,
|
||||||
@@ -1005,8 +1451,144 @@ describe('ChatMessageReceived push', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pushes nothing when there is no message to push', async () => {
|
it('pushes the opening notice even when no message is sent', async () => {
|
||||||
await send(886004, 'ids=886005&messageContents=')
|
// 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([])
|
expect(await hub.getByName('global').takeSent()).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1629,6 +2211,7 @@ describe('openapi', () => {
|
|||||||
'GET /thread/{id}',
|
'GET /thread/{id}',
|
||||||
'GET /thread/{id}/message',
|
'GET /thread/{id}/message',
|
||||||
'POST /thread',
|
'POST /thread',
|
||||||
|
'POST /thread/party',
|
||||||
'POST /thread/withmembers',
|
'POST /thread/withmembers',
|
||||||
'POST /thread/{id}',
|
'POST /thread/{id}',
|
||||||
'POST /thread/{id}/favorite',
|
'POST /thread/{id}/favorite',
|
||||||
|
|||||||
+145
-52
@@ -31,6 +31,7 @@ export const THREAD_SCHEMA_DDL: string[] = [
|
|||||||
`CREATE TABLE IF NOT EXISTS message_thread (
|
`CREATE TABLE IF NOT EXISTS message_thread (
|
||||||
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
chat_thread_name TEXT,
|
chat_thread_name TEXT,
|
||||||
|
chat_thread_type INTEGER NOT NULL DEFAULT 0,
|
||||||
latest_message_id INTEGER,
|
latest_message_id INTEGER,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
@@ -70,23 +71,36 @@ export interface ChatThread {
|
|||||||
*/
|
*/
|
||||||
chatThreadName: string
|
chatThreadName: string
|
||||||
/**
|
/**
|
||||||
* Which kind of conversation this is. Every thread the reference serves here comes back
|
* Which kind of conversation this is — the client's `ChatThreadType`: 0 Player,
|
||||||
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant —
|
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
|
||||||
* but the field itself has to be present: the client deserializes it as a non-nullable
|
* the two. The field has to be present whatever its value: the client deserializes it
|
||||||
* int and drops the whole response when it's missing.
|
* as a non-nullable int and drops the whole response when it's missing.
|
||||||
*/
|
*/
|
||||||
chatThreadType: number
|
chatThreadType: ChatThreadTypeValue
|
||||||
snoozedUntil: string | null
|
snoozedUntil: string | null
|
||||||
isFavorited: boolean
|
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. */
|
/** The joined row backing a rendered thread, before it's shaped for the client. */
|
||||||
interface ThreadRow {
|
interface ThreadRow {
|
||||||
chat_thread_id: number
|
chat_thread_id: number
|
||||||
chat_thread_name: string | null
|
chat_thread_name: string | null
|
||||||
|
chat_thread_type: number
|
||||||
player_ids: string | null
|
player_ids: string | null
|
||||||
last_read_message_id: number | null
|
last_read_message_id: number | null
|
||||||
snoozed_until: string | null
|
snoozed_until: string | null
|
||||||
@@ -119,30 +133,24 @@ function toThread(row: ThreadRow): ChatThread {
|
|||||||
lastReadMessageId: row.last_read_message_id ?? 0,
|
lastReadMessageId: row.last_read_message_id ?? 0,
|
||||||
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
// Null in the column means "unnamed"; the client dereferences it unchecked.
|
||||||
chatThreadName: row.chat_thread_name ?? '',
|
chatThreadName: row.chat_thread_name ?? '',
|
||||||
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
|
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
|
||||||
snoozedUntil: row.snoozed_until,
|
snoozedUntil: row.snoozed_until,
|
||||||
isFavorited: row.is_favorited !== 0,
|
isFavorited: row.is_favorited !== 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The thread list as it renders for one player, newest conversation first — the
|
* The shared projection behind every rendered thread — the list, the single read and the
|
||||||
* `?MessageCount=N` page of the thread endpoint.
|
* 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
|
||||||
* 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
|
|
||||||
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
* 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(
|
const THREAD_SELECT = `SELECT
|
||||||
db: D1Database,
|
|
||||||
playerId: number,
|
|
||||||
{ limit = 50 }: { limit?: number } = {}
|
|
||||||
): Promise<ChatThread[]> {
|
|
||||||
const { results } = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT
|
|
||||||
t.chat_thread_id,
|
t.chat_thread_id,
|
||||||
t.chat_thread_name,
|
t.chat_thread_name,
|
||||||
|
t.chat_thread_type,
|
||||||
(SELECT group_concat(player_id) FROM
|
(SELECT group_concat(player_id) FROM
|
||||||
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
|
||||||
ORDER BY player_id)) AS player_ids,
|
ORDER BY player_id)) AS player_ids,
|
||||||
@@ -157,7 +165,22 @@ export async function getThreadsForPlayer(
|
|||||||
msg.moderation_state AS msg_moderation_state
|
msg.moderation_state AS msg_moderation_state
|
||||||
FROM thread_member me
|
FROM thread_member me
|
||||||
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
|
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
|
WHERE me.player_id = ?1
|
||||||
ORDER BY t.latest_message_id DESC
|
ORDER BY t.latest_message_id DESC
|
||||||
LIMIT ?2`
|
LIMIT ?2`
|
||||||
@@ -175,24 +198,7 @@ export async function getThreadForPlayer(
|
|||||||
): Promise<ChatThread | null> {
|
): Promise<ChatThread | null> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT
|
`${THREAD_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
|
|
||||||
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
||||||
)
|
)
|
||||||
.bind(chatThreadId, playerId)
|
.bind(chatThreadId, playerId)
|
||||||
@@ -200,6 +206,65 @@ export async function getThreadForPlayer(
|
|||||||
return row === null ? null : toThread(row)
|
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
|
* Whether a player may read or post to a thread. Every thread-scoped route gates on
|
||||||
* this before touching messages.
|
* 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` })
|
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
|
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
|
||||||
* list rather than a blank title.
|
* 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 —
|
* 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.
|
* 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(
|
export async function createThread(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[],
|
playerIds: number[],
|
||||||
name: string | null = null,
|
name: string | null = null,
|
||||||
startedBy?: number
|
startedBy?: number,
|
||||||
|
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.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`
|
RETURNING chat_thread_id`
|
||||||
)
|
)
|
||||||
.bind(name, new Date().toISOString())
|
.bind(name, type, new Date().toISOString())
|
||||||
.first<{ chat_thread_id: number }>()
|
.first<{ chat_thread_id: number }>()
|
||||||
if (row === null) throw new Error('failed to create chat thread')
|
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
|
* 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
|
* that nothing else in the worker can render, and — since the oldest match wins — it
|
||||||
* would keep winning on every subsequent call.
|
* 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(
|
export async function findThreadWithMembers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[]
|
playerIds: number[],
|
||||||
|
type: ChatThreadTypeValue = ChatThreadType.Player
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const members = [...new Set(playerIds)]
|
const members = [...new Set(playerIds)]
|
||||||
if (members.length === 0) return null
|
if (members.length === 0) return null
|
||||||
|
|
||||||
// ?1 is the member count; ?2… are the ids themselves.
|
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
|
||||||
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
|
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT m.chat_thread_id FROM thread_member m
|
`SELECT m.chat_thread_id FROM thread_member m
|
||||||
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
|
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
|
GROUP BY m.chat_thread_id
|
||||||
HAVING COUNT(*) = ?1
|
HAVING COUNT(*) = ?1
|
||||||
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
|
||||||
ORDER BY m.chat_thread_id
|
ORDER BY m.chat_thread_id
|
||||||
LIMIT 1`
|
LIMIT 1`
|
||||||
)
|
)
|
||||||
.bind(members.length, ...members)
|
.bind(members.length, type, ...members)
|
||||||
.first<{ chat_thread_id: number }>()
|
.first<{ chat_thread_id: number }>()
|
||||||
return row?.chat_thread_id ?? null
|
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
|
* 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
|
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
|
||||||
* them afterwards.
|
* 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(
|
export async function getOrCreateThreadWithMembers(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
playerIds: number[],
|
playerIds: number[],
|
||||||
startedBy: number
|
startedBy: number
|
||||||
): Promise<number> {
|
): Promise<{ chatThreadId: number; created: boolean }> {
|
||||||
return (
|
const existing = await findThreadWithMembers(db, playerIds)
|
||||||
(await findThreadWithMembers(db, playerIds)) ??
|
if (existing !== null) return { chatThreadId: existing, created: false }
|
||||||
(await createThread(db, playerIds, null, startedBy))
|
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. */
|
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
||||||
|
|||||||
Reference in New Issue
Block a user