mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
/**
|
|
* Chat threads and their membership on the shared `recflare` D1 database. A thread is a
|
|
* conversation — a DM pair, a named group chat, or a system thread; the messages in it
|
|
* live in `message` (see message-db.ts).
|
|
*
|
|
* Membership (`thread_member`) does double duty: it is the authorization gate — a
|
|
* player may read or post to a thread only if they hold a row — and it is what renders
|
|
* the `playerIds` array the client shows. Nothing here has a foreign key to accounts,
|
|
* here or on a message's sender: that table belongs to the `auth` worker, and a thread
|
|
* outlives the accounts in it.
|
|
*
|
|
* The thread denormalizes `latest_message_id` so the thread list renders from one
|
|
* indexed row per thread rather than a per-thread MAX() over `message`, and so it can
|
|
* be ordered by recency without a join — message ids are monotonic, so the highest id
|
|
* is the newest thread. `postMessage` keeps it in sync.
|
|
*
|
|
* The per-viewer fields — `lastReadMessageId`, `snoozedUntil`, `isFavorited` — live on
|
|
* the membership row, not the thread: two players in one DM have independent read
|
|
* positions, snoozes, and favorites.
|
|
*
|
|
* The `chat` worker owns this schema/migration (migrations/0002_thread.sql).
|
|
* `THREAD_SCHEMA_DDL` mirrors it so tests can build the tables directly.
|
|
*/
|
|
|
|
import { insertMessage } from './message-db'
|
|
|
|
import type { ChatMessage, NewChatMessage } from './message-db'
|
|
|
|
/** Schema DDL (mirror of migrations/0002_thread.sql). */
|
|
export const THREAD_SCHEMA_DDL: string[] = [
|
|
`CREATE TABLE IF NOT EXISTS message_thread (
|
|
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
chat_thread_name TEXT,
|
|
latest_message_id INTEGER,
|
|
created_at TEXT NOT NULL
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_message_thread_latest ON message_thread (latest_message_id)`,
|
|
`CREATE TABLE IF NOT EXISTS thread_member (
|
|
chat_thread_id INTEGER NOT NULL,
|
|
player_id INTEGER NOT NULL,
|
|
last_read_message_id INTEGER,
|
|
snoozed_until TEXT,
|
|
is_favorited INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY (chat_thread_id, player_id)
|
|
)`,
|
|
// The thread-list query is "every thread this player is in", so player_id leads.
|
|
`CREATE INDEX IF NOT EXISTS idx_thread_member_player ON thread_member (player_id)`,
|
|
]
|
|
|
|
/**
|
|
* A thread as the client receives it: the thread, its members, its most recent message,
|
|
* and the viewing player's own read/snooze/favorite state. This is the element shape of
|
|
* the thread-list response.
|
|
*/
|
|
export interface ChatThread {
|
|
/** Null only for a thread with no messages yet. */
|
|
latestMessage: ChatMessage | null
|
|
chatThreadId: number
|
|
playerIds: number[]
|
|
/**
|
|
* 0 when the player has never read the thread — never null. The client deserializes
|
|
* this into a non-nullable int and fails the whole response on a null ("expected
|
|
* 'Number Token', actual 'null'"), unlike `latestMessage`, which it accepts as null.
|
|
*/
|
|
lastReadMessageId: number
|
|
/**
|
|
* Empty for DMs and unnamed groups — never null. The client dereferences this name
|
|
* without a null check (a null NullReferenceExceptions its way out of
|
|
* GetChatBetweenPlayers) and falls back to naming the members when it's blank.
|
|
*/
|
|
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.
|
|
*/
|
|
chatThreadType: number
|
|
snoozedUntil: string | null
|
|
isFavorited: boolean
|
|
}
|
|
|
|
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
|
|
const CHAT_THREAD_TYPE_DEFAULT = 0
|
|
|
|
/** 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
|
|
player_ids: string | null
|
|
last_read_message_id: number | null
|
|
snoozed_until: string | null
|
|
is_favorited: number
|
|
msg_chat_message_id: number | null
|
|
msg_chat_thread_id: number | null
|
|
msg_sender_player_id: number | null
|
|
msg_time_sent: string | null
|
|
msg_contents: string | null
|
|
msg_moderation_state: number | null
|
|
}
|
|
|
|
function toThread(row: ThreadRow): ChatThread {
|
|
return {
|
|
latestMessage:
|
|
row.msg_chat_message_id === null
|
|
? null
|
|
: {
|
|
chatMessageId: row.msg_chat_message_id,
|
|
chatThreadId: row.msg_chat_thread_id!,
|
|
senderPlayerId: row.msg_sender_player_id!,
|
|
timeSent: row.msg_time_sent!,
|
|
contents: row.msg_contents!,
|
|
moderationState: row.msg_moderation_state!,
|
|
},
|
|
chatThreadId: row.chat_thread_id,
|
|
// group_concat of the membership rows, already ordered by player id.
|
|
playerIds: row.player_ids === null ? [] : row.player_ids.split(',').map(Number),
|
|
// Null in the column means "never read"; the client insists on a number.
|
|
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,
|
|
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
|
|
* group_concat is what makes `playerIds` come back sorted rather than in row order.
|
|
*/
|
|
export async function getThreadsForPlayer(
|
|
db: D1Database,
|
|
playerId: number,
|
|
{ limit = 50 }: { limit?: number } = {}
|
|
): Promise<ChatThread[]> {
|
|
const { results } = 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
|
|
WHERE me.player_id = ?1
|
|
ORDER BY t.latest_message_id DESC
|
|
LIMIT ?2`
|
|
)
|
|
.bind(playerId, limit)
|
|
.all<ThreadRow>()
|
|
return results.map(toThread)
|
|
}
|
|
|
|
/** One thread as it renders for one player, or null if they aren't a member of it. */
|
|
export async function getThreadForPlayer(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number
|
|
): 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
|
|
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
|
|
)
|
|
.bind(chatThreadId, playerId)
|
|
.first<ThreadRow>()
|
|
return row === null ? null : toThread(row)
|
|
}
|
|
|
|
/**
|
|
* Whether a player may read or post to a thread. Every thread-scoped route gates on
|
|
* this before touching messages.
|
|
*/
|
|
export async function isThreadMember(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number
|
|
): Promise<boolean> {
|
|
const row = await db
|
|
.prepare('SELECT 1 AS ok FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
|
|
.bind(chatThreadId, playerId)
|
|
.first<{ ok: number }>()
|
|
return row !== null
|
|
}
|
|
|
|
/**
|
|
* The pseudo-player system messages are sent as. Not a real account — the client renders
|
|
* a message from this sender as a notice rather than as someone speaking, which is why
|
|
* `message.sender_player_id` carries no foreign key and permits negative ids.
|
|
*/
|
|
export const SYSTEM_SENDER_ID = -5
|
|
|
|
/**
|
|
* The notice a thread opens with: `Player <@U10441985> started a chat`. The `<@U…>` token
|
|
* is a mention the client resolves to a display name, so the id goes in raw.
|
|
*/
|
|
export function startedChatContents(playerId: number): string {
|
|
return JSON.stringify({
|
|
Type: 0,
|
|
Version: 1,
|
|
Data: `Player <@U${playerId}> started a chat`,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The notice left behind when someone walks out of a group: `Player <@U14922080> left`.
|
|
* Same `<@U…>` mention token the opening notice uses.
|
|
*/
|
|
export function leftChatContents(playerId: number): string {
|
|
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
|
|
}
|
|
|
|
/**
|
|
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
|
|
* list rather than a blank title.
|
|
*/
|
|
export async function setThreadName(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
name: string
|
|
): Promise<void> {
|
|
await db
|
|
.prepare('UPDATE message_thread SET chat_thread_name = ?2 WHERE chat_thread_id = ?1')
|
|
.bind(chatThreadId, name === '' ? null : name)
|
|
.run()
|
|
}
|
|
|
|
/**
|
|
* Open a thread between a set of players, returning its new id. `name` is null for DMs
|
|
* and unnamed groups. Duplicate player ids collapse, so a caller need not dedupe.
|
|
*
|
|
* Pass `startedBy` to open the thread the way the real server does — with a system
|
|
* "started a chat" notice as its first message. A thread with no messages at all is one
|
|
* the client won't display, so every thread born from a request gets one; the parameter
|
|
* is optional only so tests can build a bare thread directly.
|
|
*
|
|
* 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.
|
|
*/
|
|
export async function createThread(
|
|
db: D1Database,
|
|
playerIds: number[],
|
|
name: string | null = null,
|
|
startedBy?: number
|
|
): Promise<number> {
|
|
const row = await db
|
|
.prepare(
|
|
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
|
|
RETURNING chat_thread_id`
|
|
)
|
|
.bind(name, new Date().toISOString())
|
|
.first<{ chat_thread_id: number }>()
|
|
if (row === null) throw new Error('failed to create chat thread')
|
|
|
|
const members = [...new Set(playerIds)]
|
|
if (members.length > 0) {
|
|
await db.batch(
|
|
members.map((playerId) =>
|
|
db
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id)
|
|
VALUES (?1, ?2)`
|
|
)
|
|
.bind(row.chat_thread_id, playerId)
|
|
)
|
|
)
|
|
}
|
|
|
|
if (startedBy !== undefined) {
|
|
await postMessage(db, {
|
|
chatThreadId: row.chat_thread_id,
|
|
senderPlayerId: SYSTEM_SENDER_ID,
|
|
contents: startedChatContents(startedBy),
|
|
})
|
|
}
|
|
return row.chat_thread_id
|
|
}
|
|
|
|
/**
|
|
* The existing thread whose membership is *exactly* this set of players, or null. The
|
|
* oldest match wins, so a set that somehow accumulated duplicates keeps resolving to the
|
|
* conversation with the history in it.
|
|
*
|
|
* This is what makes "open a chat with these people" reuse the conversation you already
|
|
* have with them rather than starting an empty one each time. Matching is on the whole
|
|
* set: a DM and a group that happens to contain those two people are different threads.
|
|
*
|
|
* Only threads that still have a `message_thread` row can match. Membership rows whose
|
|
* 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.
|
|
*/
|
|
export async function findThreadWithMembers(
|
|
db: D1Database,
|
|
playerIds: number[]
|
|
): 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(', ')
|
|
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
|
|
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)
|
|
.first<{ chat_thread_id: number }>()
|
|
return row?.chat_thread_id ?? null
|
|
}
|
|
|
|
/**
|
|
* The thread with exactly these members, opening one if it doesn't exist yet. Two
|
|
* 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.
|
|
*/
|
|
export async function getOrCreateThreadWithMembers(
|
|
db: D1Database,
|
|
playerIds: number[],
|
|
startedBy: number
|
|
): Promise<number> {
|
|
return (
|
|
(await findThreadWithMembers(db, playerIds)) ??
|
|
(await createThread(db, playerIds, null, startedBy))
|
|
)
|
|
}
|
|
|
|
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
|
|
export async function getThreadMemberIds(db: D1Database, chatThreadId: number): Promise<number[]> {
|
|
const { results } = await db
|
|
.prepare('SELECT player_id FROM thread_member WHERE chat_thread_id = ?1 ORDER BY player_id')
|
|
.bind(chatThreadId)
|
|
.all<{ player_id: number }>()
|
|
return results.map((r) => r.player_id)
|
|
}
|
|
|
|
/** Add a player to an existing thread. A no-op if they're already in it. */
|
|
export async function addThreadMember(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number
|
|
): Promise<void> {
|
|
await db
|
|
.prepare('INSERT OR IGNORE INTO thread_member (chat_thread_id, player_id) VALUES (?1, ?2)')
|
|
.bind(chatThreadId, playerId)
|
|
.run()
|
|
}
|
|
|
|
/** Remove a player from a thread. The thread and its messages outlive the membership. */
|
|
export async function removeThreadMember(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number
|
|
): Promise<void> {
|
|
await db
|
|
.prepare('DELETE FROM thread_member WHERE chat_thread_id = ?1 AND player_id = ?2')
|
|
.bind(chatThreadId, playerId)
|
|
.run()
|
|
}
|
|
|
|
/**
|
|
* Post a message and advance the thread's denormalized `latest_message_id` — the only
|
|
* way messages should be written, so the thread list never goes stale. Two statements
|
|
* rather than a batch, because the update needs the id the insert assigns.
|
|
*/
|
|
export async function postMessage(db: D1Database, message: NewChatMessage): Promise<ChatMessage> {
|
|
const stored = await insertMessage(db, message)
|
|
await db
|
|
.prepare('UPDATE message_thread SET latest_message_id = ?2 WHERE chat_thread_id = ?1')
|
|
.bind(stored.chatThreadId, stored.chatMessageId)
|
|
.run()
|
|
return stored
|
|
}
|
|
|
|
/**
|
|
* Advance a player's read position, to a specific message or (with no id) to the whole
|
|
* thread. Only ever moves forward: an out-of-order ack from a second client can't walk
|
|
* the thread back to unread.
|
|
*
|
|
* The id is also clamped to the thread's real latest message, so a client acking an id
|
|
* that was never stored can't strand the pointer beyond every future message and leave
|
|
* the thread permanently "read".
|
|
*/
|
|
export async function markThreadRead(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number,
|
|
chatMessageId?: number
|
|
): Promise<void> {
|
|
await db
|
|
.prepare(
|
|
`UPDATE thread_member
|
|
SET last_read_message_id = MAX(
|
|
COALESCE(last_read_message_id, 0),
|
|
MIN(
|
|
COALESCE(?3, (SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0),
|
|
COALESCE((SELECT latest_message_id FROM message_thread WHERE chat_thread_id = ?1), 0)
|
|
)
|
|
)
|
|
WHERE chat_thread_id = ?1 AND player_id = ?2`
|
|
)
|
|
.bind(chatThreadId, playerId, chatMessageId ?? null)
|
|
.run()
|
|
}
|
|
|
|
/** Favorite or unfavorite a thread, for one player only. */
|
|
export async function setThreadFavorited(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number,
|
|
isFavorited: boolean
|
|
): Promise<void> {
|
|
await db
|
|
.prepare(
|
|
'UPDATE thread_member SET is_favorited = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
|
|
)
|
|
.bind(chatThreadId, playerId, isFavorited ? 1 : 0)
|
|
.run()
|
|
}
|
|
|
|
/** Snooze a thread's notifications until an instant, or clear the snooze with null. */
|
|
export async function setThreadSnoozed(
|
|
db: D1Database,
|
|
chatThreadId: number,
|
|
playerId: number,
|
|
snoozedUntil: string | null
|
|
): Promise<void> {
|
|
await db
|
|
.prepare(
|
|
'UPDATE thread_member SET snoozed_until = ?3 WHERE chat_thread_id = ?1 AND player_id = ?2'
|
|
)
|
|
.bind(chatThreadId, playerId, snoozedUntil)
|
|
.run()
|
|
}
|