Files
recflare/apps/chat/src/chat.app.ts
T
2026-07-22 11:43:30 -04:00

842 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db'
import {
AUTHED,
ChatMessageDto,
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
form,
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
ServiceStatus,
SnoozeThreadRequest,
THREAD_ID_PARAM,
UNAUTHORIZED_RESPONSE,
WithMembersRequest,
} from './openapi'
import {
addThreadMember,
getOrCreateThreadWithMembers,
getThreadForPlayer,
getThreadMemberIds,
getThreadsForPlayer,
isThreadMember,
leftChatContents,
markThreadRead,
postMessage,
removeThreadMember,
setThreadFavorited,
setThreadName,
setThreadSnoozed,
SYSTEM_SENDER_ID,
} from './thread-db'
import type { Context } from 'hono'
import type { App } from './context'
import type { ChatMessage } from './message-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
* missing, the token is invalid, or the `sub` claim isn't an integer.
*/
async function authedId(c: Context<App>): Promise<number | null> {
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}
/**
* How many items a `MessageCount` query param asks for. The client sends 16; anything
* missing, unparseable, or out of range falls back to the default rather than 400ing,
* and the cap keeps a hand-written request from pulling a whole thread history.
*/
const DEFAULT_MESSAGE_COUNT = 16
const MAX_MESSAGE_COUNT = 100
/** What the client asks for when opening a thread (`messageCount=50`). */
const DEFAULT_THREAD_MESSAGE_COUNT = 50
function messageCount(c: Context<App>, fallback = DEFAULT_MESSAGE_COUNT): number {
// The GET routes spell it `MessageCount` in the query; the POST forms spell it
// `messageCount` in the body. Accept either, wherever it turns up.
const raw = Number.parseInt(c.req.query('MessageCount') ?? c.req.query('messageCount') ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return fallback
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/** The page size a POST form asks for, which may also arrive in the body. */
async function formMessageCount(c: Context<App>, fallback: number): Promise<number> {
const raw = Number.parseInt((await formField(c, 'messageCount')) ?? '', 10)
if (Number.isNaN(raw) || raw <= 0) return messageCount(c, fallback)
return Math.min(raw, MAX_MESSAGE_COUNT)
}
/**
* What a chat action reports back to the client alongside its payload — the reference's
* ChatResult. Only success and "bad arguments" are reachable here.
*/
const CHAT_SUCCESS = 0
const CHAT_INVALID_ARGUMENTS = 1
const CHAT_MEMBERSHIP_NOT_FOUND = 3
const CHAT_PLAYER_ALREADY_ON_THREAD = 4
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
*
* The sender is notified too, deliberately: the client doesn't fold the HTTP response
* into its local thread cache, so without a self-targeted push its own outgoing message
* doesn't appear until the thread is refetched.
*
* Best-effort — a hub failure is logged and swallowed, since the message has already
* committed and the client will still see it on the next fetch.
*/
async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<void> {
try {
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
const members = await getThreadMemberIds(c.env.DB, message.chatThreadId)
await Promise.all(
members.map((playerId) =>
hub.notifyPlayer(playerId, NotificationType.ChatMessageReceived, { ...message })
)
)
} catch (err) {
logger.error('failed to push ChatMessageReceived notification', {
chatThreadId: message.chatThreadId,
chatMessageId: message.chatMessageId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 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
* the same call under the reference's other spelling, so both routes land here.
*
* Answers `{chatResult, chatThread}` — the whole thread with its messages, not just the
* message that was sent, so the client re-renders the conversation from one response.
* Blank or missing contents stores nothing and reports invalid-arguments, still with the
* thread attached, rather than an error status.
*/
async function sendToThread(c: Context<App>) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
// Sending is reading: the reference answers with `lastReadMessageId` already at the
// message just posted, so the sender's own thread doesn't come back unread.
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await threadWithMessages(c, chatThreadId, id, DEFAULT_THREAD_MESSAGE_COUNT)
return c.json({
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
chatThread: thread,
})
}
/**
* Move the caller's read pointer on a thread, to `chatMessageId` or (undefined) to the
* thread's latest message. Answers the bare ChatResult integer the reference sends.
*/
async function markRead(c: Context<App>, chatMessageId?: number) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
// Every route reaching here constrains `:id` to digits, so the parse can't fail.
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
await markThreadRead(c.env.DB, chatThreadId, id, chatMessageId)
return c.json(CHAT_SUCCESS)
}
/**
* A thread rendered for opening a conversation: the thread's own fields plus a page of
* its messages, newest first. `latestMessage` gives way to the full page — the client is
* sent one or the other, never both — and `messages` is always present, empty for a
* thread with nothing in it yet.
*
* Null when the caller isn't a member (or the thread doesn't exist); membership is the
* gate, so the two cases are indistinguishable from outside.
*/
async function threadWithMessages(
c: Context<App>,
chatThreadId: number,
playerId: number,
limit: number
) {
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, playerId)
if (thread === null) return null
const messages = await getThreadMessages(c.env.DB, chatThreadId, { limit })
const { latestMessage: _latest, ...rest } = thread
return { ...rest, messages }
}
/** Ceiling on a new thread's roster, counting the caller. */
const MAX_THREAD_MEMBERS = 50
/** Longest a thread name may be; anything beyond is truncated, not rejected. */
const MAX_THREAD_NAME_LENGTH = 128
/**
* What `snooze=True` stores in `snoozedUntil`. The client sends a boolean but reads back
* an instant, so "snoozed" is expressed as a time far enough out to mean indefinitely.
*/
const SNOOZED_INDEFINITELY = '9999-12-31T23:59:59Z'
/**
* The repeated `ids` fields naming a new thread's members (`ids=2&ids=155`). The client
* sends them as a urlencoded body, but they're read from the query string too, since
* the same call is easy to hand-write that way. Values that aren't integers are dropped.
*/
async function memberIds(c: Context<App>): Promise<number[]> {
const raw = [...(c.req.queries('ids') ?? [])]
const form = await c.req.formData().catch(() => null)
if (form !== null) raw.push(...form.getAll('ids').map(String))
return raw.map((value) => Number.parseInt(value, 10)).filter((id) => Number.isInteger(id))
}
/** A form boolean as the client spells it (`True`/`False`), tolerant of the variants. */
async function formBool(c: Context<App>, name: string): Promise<boolean> {
const value = (await formField(c, name))?.trim().toLowerCase()
return value === 'true' || value === '1' || value === 'yes'
}
/** A single form field, or the query param of the same name. Hono caches the body, so
* this is safe to call alongside `memberIds`. */
async function formField(c: Context<App>, name: string): Promise<string | undefined> {
const form = await c.req.formData().catch(() => null)
const value = form?.get(name)
return typeof value === 'string' ? value : c.req.query(name)
}
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
function chatResultRoute(
summary: string,
description: string,
extra: {
requestBody?: ReturnType<typeof form>
parameters?: unknown[]
successDescription?: string
/** Set for the read-pointer routes, which 404 a non-member instead of answering 3. */
notFound?: boolean
} = {}
) {
return describeRoute({
tags: ['Chat'],
summary,
description,
security: AUTHED,
parameters: [THREAD_ID_PARAM, ...((extra.parameters ?? []) as never[])],
...(extra.requestBody === undefined ? {} : { requestBody: extra.requestBody }),
responses: {
200: json(
ChatResult,
extra.successDescription ??
'The ChatResult (0 on success, 3 when the caller isnt on the thread)'
),
401: UNAUTHORIZED_RESPONSE,
...(extra.notFound === true ? { 404: NOT_A_MEMBER_RESPONSE } : {}),
},
})
}
/**
* The `describeRoute` spec shared by the two spellings of "send to an existing thread".
* `/thread/{id}` is what the client posts; `/thread/{id}/message` is the same call under
* the reference's other spelling, and both land in `sendToThread`.
*/
function sendToThreadRoute(spelling: string) {
return describeRoute({
tags: ['Messages'],
summary: `Send a message to an existing thread (${spelling})`,
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
'ChatMessageReceived to every member, the sender included — the client doesnt fold the',
'HTTP response into its local cache, so without a self-targeted push its own outgoing',
'message doesnt appear until the thread is refetched. Note the hub frames `Id` is a',
'STRING: the client dispatches on it and silently drops a numeric one.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(SendMessageRequest, 'The message envelope'),
responses: {
200: json(SendMessageResponse, 'The ChatResult plus the whole thread with its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
})
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.get(
'/',
describeRoute({
tags: ['Service'],
summary: 'Service liveness',
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
responses: { 200: json(ServiceStatus, 'Always `{ service: "chat", status: "ok" }`') },
}),
(c) => c.json({ service: 'chat', status: 'ok' })
)
// The player's own thread list, newest conversation first — each thread carrying its
// latest message and the caller's own read/snooze/favorite state. `MessageCount` is
// the page size (of threads, despite the name). Membership scopes the query, so a
// player only ever sees their own threads.
.get(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'The callers thread list',
description: [
'Every thread the caller is a member of, newest conversation first — each carrying its',
'`latestMessage` and the callers own read/snooze/favorite state. `MessageCount` is the',
'page size (of THREADS, despite the name). Membership scopes the query, so a player',
'only ever sees their own threads.',
].join(' '),
security: AUTHED,
parameters: [messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadDto.array(), 'The callers threads, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json(await getThreadsForPlayer(c.env.DB, id, { limit: messageCount(c) }))
}
)
// Send to a set of players (`ids=155&ids=2&messageContents=…`) — the client's
// create-thread-and-post-first-message call, in one. Resolves to the thread those
// players already share rather than opening a second one.
//
// `messageContents` is the same envelope a message carries
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does.
.post(
'/thread',
describeRoute({
tags: ['Threads'],
summary: 'Open a thread with a set of players and post the first message',
description: [
'The clients create-thread-and-post-first-message call, in one. Resolves to the thread',
'those players already share rather than opening a second one. `messageContents` is the',
'same envelope a message carries and is stored verbatim, unparsed; the client also sends',
'it blank right after `/thread/withmembers`, which opens the thread without posting and',
'reports invalid-arguments. Answers a `{ chatThread, chatResult }` wrapper, not a bare',
'thread. Pushes ChatMessageReceived to every member (including the sender).',
].join(' '),
security: AUTHED,
requestBody: form(CreateThreadRequest, 'The member ids and the first message'),
responses: {
200: json(CreateThreadResponse, 'The thread plus the result of the first message'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
if (posted !== null) {
await pushChatMessage(c, posted)
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
}
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
// The reference answers a wrapper here, not a bare thread.
return c.json({
chatThread: thread,
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
})
}
)
// "Open the chat with these people" — the client's GetChatBetweenPlayers. Fetch or
// create: the thread whose membership is exactly `ids` plus the caller, opened only
// if they don't already share one. Returning a fresh empty thread each call would
// bury the real conversation and hand the client a thread with no messages.
//
// Answers the thread with a `messages` array (what `messageCount` sizes) rather than
// the list's single `latestMessage`, so the client can open straight into the
// conversation. The array is always present, empty for a brand-new thread.
.post(
'/thread/withmembers',
describeRoute({
tags: ['Threads'],
summary: 'Fetch or open the thread with exactly these members',
description: [
'The clients GetChatBetweenPlayers. Fetch-or-create: the thread whose membership is',
'exactly `ids` plus the caller, opened only if they dont already share one (returning a',
'fresh empty thread each call would bury the real conversation). Answers the thread with',
'a `messages` array — what `messageCount` sizes — rather than the lists single',
'`latestMessage`, so the client can open straight into the conversation. The array is',
'always present, empty for a brand-new thread.',
].join(' '),
security: AUTHED,
requestBody: form(WithMembersRequest, 'The member ids and the page size'),
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
400: {
description: [
'Fewer than 2 members (naming only yourself) or more than 50, counting the caller',
'(empty body)',
].join(' '),
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const members = [...new Set([id, ...(await memberIds(c))])]
// A thread needs someone else in it; naming only yourself is a bad request
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
return c.json(thread)
}
)
// A page of one thread's messages, newest first — a bare array, not a thread object.
// The client reads a conversation through either spelling: `/thread/2?messageCount=50`
// and `/thread/2/message?MessageCount=16` answer the same thing, so they share a
// handler; only the default page size differs, matching what each caller sends.
//
// 404 rather than 403 for a thread the caller isn't in: whether a thread exists is
// itself private, so a non-member gets the same answer as for a thread that's gone.
// An empty thread is still a 200 with `[]` — a conversation just opened with someone
// has no messages yet and still has to open.
// One thread with its recent messages — what the client opens a conversation with
// (`/thread/13?messageCount=50`). An OBJECT, the same shape /thread/withmembers
// answers: the client parses this one as a thread and rejects a bare array
// ("expected '{', actual '['"). Only /thread/:id/message below serves an array.
//
// 404s only for a thread the caller isn't in, not for one that's simply empty: a
// thread just opened with someone has no messages yet and still has to open.
.get(
'/thread/:id{[0-9]+}',
describeRoute({
tags: ['Threads'],
summary: 'One thread with its recent messages',
description: [
'What the client opens a conversation with (`/thread/13?messageCount=50`). An OBJECT —',
'the same shape `/thread/withmembers` answers: the client parses this one as a thread',
"and rejects a bare array (\"expected '{', actual '['\"). Only `/thread/{id}/message`",
'serves an array. 404s only for a thread the caller isnt in, not for one thats simply',
'empty — a thread just opened with someone has no messages yet and still has to open.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_THREAD_MESSAGE_COUNT)],
responses: {
200: json(ChatThreadWithMessagesDto, 'The thread with a page of its messages'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
const limit = messageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
return thread === null ? c.notFound() : c.json(thread)
}
)
// 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 the same call under the reference's other spelling.
//
// Answers the SendMessageResponse wrapper (`{chatMessage, chatResult}`), not a bare
// message. Blank or missing contents is invalid-arguments with no message attached,
// rather than an error status.
.post('/thread/:id{[0-9]+}', sendToThreadRoute('`/thread/{id}`'), (c) => sendToThread(c))
.post('/thread/:id{[0-9]+}/message', sendToThreadRoute('`/thread/{id}/message`'), (c) =>
sendToThread(c)
)
// Rename a thread (`name=my chat`). Any member may rename — there's no owner — and an
// empty name clears it back to unnamed, which renders as the member list. Answers a
// bare ChatResult: 3 when the caller isn't on the thread, 0 on success.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/rename',
chatResultRoute(
'Rename a thread',
[
'Any member may rename — there is no owner — and an empty name clears it back to unnamed,',
'which renders as the member list. The name is truncated to 128 characters rather than',
'rejected. Answers a bare ChatResult: 3 when the caller isnt on the thread, 0 on success.',
].join(' '),
{ requestBody: form(RenameThreadRequest, 'The new name') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const name = ((await formField(c, 'name')) ?? '').trim().slice(0, MAX_THREAD_NAME_LENGTH)
await setThreadName(c.env.DB, chatThreadId, name)
return c.json(CHAT_SUCCESS)
}
)
// Leave a thread. The thread and its history survive — only the caller's membership
// goes, so they stop seeing it and the remaining members keep the conversation.
//
// A "Player <@U…> left" notice is posted first, so the others see why the roster
// changed; the leaver is still a member at that moment and gets the push too, which
// is what tells their client the thread is gone.
.on(
['POST', 'DELETE'],
'/thread/:id{[0-9]+}/leave',
chatResultRoute(
'Leave a thread',
[
'The thread and its history survive — only the callers membership goes, so they stop',
'seeing it and the remaining members keep the conversation. A "Player <@U…> left" system',
'notice is posted first so the others see why the roster changed; the leaver is still a',
'member at that moment and gets the push too, which is what tells their client the thread',
'is gone.',
].join(' ')
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: leftChatContents(id),
})
await pushChatMessage(c, notice)
await removeThreadMember(c.env.DB, chatThreadId, id)
return c.json(CHAT_SUCCESS)
}
)
// Snooze or unsnooze a thread (`snooze=True`), for the caller alone — snoozing is a
// per-member setting, so it never affects what anyone else sees.
//
// The client sends a boolean while the field it reads back is `snoozedUntil`, a time.
// `True` is therefore stored as a far-future instant meaning "muted indefinitely", and
// `False` clears it. If the real server instead snoozes for a fixed window, this is
// the one line to change.
.on(
['POST', 'PUT'],
'/thread/:id{[0-9]+}/snooze',
chatResultRoute(
'Snooze or unsnooze a thread',
[
'Per-member, for the caller alone — it never affects what anyone else sees. The client',
'sends a boolean while the field it reads back (`snoozedUntil`) is a time, so `True` is',
'stored as a far-future instant (9999-12-31T23:59:59Z) meaning "muted indefinitely" and',
'`False` clears it.',
].join(' '),
{ requestBody: form(SnoozeThreadRequest, 'The snooze flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const on = await formBool(c, 'snooze')
await setThreadSnoozed(c.env.DB, chatThreadId, id, on ? SNOOZED_INDEFINITELY : null)
return c.json(CHAT_SUCCESS)
}
)
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
'Like snoozing, a per-member flag that pins the thread in the callers own inbox and',
'leaves everyone elses untouched.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS)
}
)
// Add a player to a thread (`/thread/20/member/2`). Gated on the caller already being
// in it — you can only pull someone into a conversation you're part of.
//
// Answers a bare ChatResult rather than an HTTP status, as the reference does: 3 when
// the caller isn't a member (which doubles as "no such thread", keeping a thread's
// existence private), 4 when the target is already on it, 0 on success. Idempotent —
// re-adding an existing member changes nothing.
.post(
'/thread/:id{[0-9]+}/member/:playerId{[0-9]+}',
chatResultRoute(
'Add a player to a thread',
[
'Gated on the caller already being in it — you can only pull someone into a conversation',
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
].join(' '),
{
parameters: [
{
name: 'playerId',
in: 'path',
required: true,
description: 'The account id to add (digits only)',
schema: { type: 'string' },
},
],
successDescription: '0 success · 3 caller not a member · 4 target already on the thread',
}
),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
}
const playerId = Number.parseInt(c.req.param('playerId'), 10)
if (await isThreadMember(c.env.DB, chatThreadId, playerId)) {
return c.json(CHAT_PLAYER_ALREADY_ON_THREAD)
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
// Move the caller's read pointer — `/thread/15/read` for the whole thread, or
// `/thread/15/message/:messageId/read` for a specific message, which the client uses
// when the view sits on a message rather than the bottom. Both verbs, as the client
// sends either. Answers the bare ChatResult integer the reference does.
//
// The pointer only moves forward, and never past the thread's real latest message: an
// id the client made up (or one it read from a synthetic message) can't strand the
// thread as permanently read.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/read',
chatResultRoute(
'Mark a whole thread read',
[
'Moves the callers read pointer to the threads latest message. The pointer only moves',
'forward and never past the threads real latest message, so an id the client made up',
'cant strand the thread as permanently read. 404s for a thread the caller isnt on.',
].join(' '),
{ successDescription: 'Always 0 (success)', notFound: true }
),
(c) => markRead(c)
)
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/message/:messageId{[0-9]+}/read',
chatResultRoute(
'Mark read up to a specific message',
[
'What the client sends when the view sits on a message rather than the bottom. Same',
'forward-only, clamped pointer as the whole-thread form. 404s for a thread the caller',
'isnt on.',
].join(' '),
{
parameters: [
{
name: 'messageId',
in: 'path',
required: true,
description: 'The message to read up to (digits only)',
schema: { type: 'string' },
},
],
successDescription: 'Always 0 (success)',
notFound: true,
}
),
(c) => markRead(c, Number.parseInt(c.req.param('messageId'), 10))
)
// A page of one thread's messages, newest first — a bare array, unlike /thread/:id.
// `MessageCount` is the page size. 404 rather than 403 for a thread the caller isn't
// in: whether a thread exists is itself private, so a non-member gets the same answer
// as for a thread that's gone.
.get(
'/thread/:id{[0-9]+}/message',
describeRoute({
tags: ['Messages'],
summary: 'A page of one threads messages',
description: [
'Newest first — a bare ARRAY, unlike `/thread/{id}`, which serves the thread object.',
'`MessageCount` is the page size. 404 rather than 403 for a thread the caller isnt in:',
'whether a thread exists is itself private, so a non-member gets the same answer as for a',
'thread thats gone. An empty thread is still a 200 with `[]`.',
].join(' '),
security: AUTHED,
parameters: [THREAD_ID_PARAM, messageCountParam(DEFAULT_MESSAGE_COUNT)],
responses: {
200: json(ChatMessageDto.array(), 'The page of messages, newest first (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
404: NOT_A_MEMBER_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
return c.json(await getThreadMessages(c.env.DB, chatThreadId, { limit: messageCount(c) }))
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare chat',
version: '1.0.0',
description: [
'Chat threads and messages for recflare, a private-server reimplementation of the Rec',
'Room backend. A thread is a conversation — a DM pair, a named group, or a system',
'thread — and membership is both the authorization gate and the `playerIds` the client',
'renders. Threads, membership and messages are D1-backed; every message also fans out',
'over the `notify` hub Durable Object as a ChatMessageReceived frame, so a conversation',
'updates live instead of on the next poll. (The hub frame carries a STRING `Id` — the',
'client dispatches on it and silently drops a numeric one.)',
].join('\n'),
},
servers: [{ url: 'https://chat.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app