diff --git a/FOLLOW_UPS.md b/FOLLOW_UPS.md index 83e2254..39c845d 100644 --- a/FOLLOW_UPS.md +++ b/FOLLOW_UPS.md @@ -4,6 +4,17 @@ The first bot pass adds scripted server-authoritative room occupants with movement and chat broadcasts. Before enabling AI-backed conversations, add provider configuration, request timeouts, moderation and rate-limit boundaries, fallback canned responses, and tests that prove rooms continue to work when the provider is unavailable. -## Direct Friend Messaging +## Direct Friend Messaging — implemented -The first friends pass supports persisted friendships, online/offline presence, avatar display, removal, and joining a friend's current room. Before adding a message action, define whether Tilezo wants direct messages or room-scoped whispers, then add privacy rules, persistence expectations, rate limits, and client/server tests. +Tilezo ships **friend-gated direct messages** (not room-scoped whispers): a `dm.send` +WebSocket message routes through `DirectMessageService`, which checks that the two users +are mutual friends, persists the message (`direct_messages` table), and publishes a +`dm.message` to the recipient's and sender's per-user topics (`user:`) for realtime +delivery to every open tab. History is available via `GET /friends/:id/messages` and is +also friend-gated. Sends are rate-limited (`dm` token bucket) and the text is sanitized and +length-bounded by the protocol schema. Covered by protocol/service/store/router unit tests, +a real-Postgres integration test, and a two-user browser test. + +Not yet built: read receipts/unread badges, typing indicators in DMs, message deletion or +editing, and blocking. A blocked-users list would gate `DirectMessageService.send` the same +way the friendship check does today. diff --git a/apps/client/src/app/createApp.ts b/apps/client/src/app/createApp.ts index 10d5647..b3b2727 100644 --- a/apps/client/src/app/createApp.ts +++ b/apps/client/src/app/createApp.ts @@ -7,13 +7,16 @@ import { logout as requestLogout, updateAppearance, } from "../auth/AuthClient"; +import type { FriendSummary } from "../friends/FriendClient"; import { addFriend, listFriends, removeFriend } from "../friends/FriendClient"; import type { Game } from "../game/Game"; +import { loadConversation } from "../messaging/DirectMessageClient"; import { createRoom, listRoomTemplates } from "../rooms/RoomClient"; import { ClientLogger } from "../telemetry/ClientLogger"; import { CharacterEditor } from "../ui/CharacterEditor"; import { ChatPanel } from "../ui/ChatPanel"; import { CreateRoomDialog } from "../ui/CreateRoomDialog"; +import { DirectMessagePanel } from "../ui/DirectMessagePanel"; import { DisconnectedDialog } from "../ui/DisconnectedDialog"; import { FriendsPanel } from "../ui/FriendsPanel"; import { LoginForm } from "../ui/LoginForm"; @@ -109,6 +112,9 @@ export function createApp(root: HTMLElement): void { status.textContent = "joining friend"; game?.joinRoom(roomId); }, + onMessage(friend) { + void openConversation(friend); + }, onRefresh() { void refreshFriends(); }, @@ -152,6 +158,11 @@ export function createApp(root: HTMLElement): void { roomBrowser.setCurrentRoom(snapshot.roomId); roomBrowser.hide(); }, + onDirectMessage(message) { + if (!directMessagePanel.append(message) && message.fromUserId !== user?.id) { + status.textContent = "new direct message"; + } + }, onDisconnected() { if (!user || !gameStarted) { return; @@ -165,6 +176,12 @@ export function createApp(root: HTMLElement): void { return game; } + const directMessagePanel = new DirectMessagePanel({ + onSend(friendId, text) { + game?.sendDirectMessage(friendId, text); + }, + }); + const disconnectedDialog = new DisconnectedDialog({ onRetry() { void reconnectAfterDisconnect("resume"); @@ -321,6 +338,7 @@ export function createApp(root: HTMLElement): void { createRoomDialog.element, roomBrowser.element, friendsPanel.element, + directMessagePanel.element, chat.element, disconnectedDialog.element, ); @@ -432,6 +450,23 @@ export function createApp(root: HTMLElement): void { } } + async function openConversation(friend: FriendSummary): Promise { + if (!user) { + return; + } + + status.textContent = "loading messages"; + + try { + const history = await loadConversation(friend.id); + directMessagePanel.open(friend, history, user.id); + friendsPanel.hide(); + status.textContent = `messaging ${friend.username}`; + } catch (error) { + status.textContent = error instanceof Error ? error.message : "Could not open messages"; + } + } + async function refreshFriends(): Promise { if (!user) { return; diff --git a/apps/client/src/game/Game.ts b/apps/client/src/game/Game.ts index 516cb37..5681f91 100644 --- a/apps/client/src/game/Game.ts +++ b/apps/client/src/game/Game.ts @@ -1,6 +1,7 @@ import type { AvatarAppearance } from "@tilezo/protocol/appearance"; import type { ClientMessage, + DirectMessage, PublicRoomSummary, RoomSnapshotMessage, } from "@tilezo/protocol/messages"; @@ -15,6 +16,7 @@ type GameOptions = { setStatus: (status: string) => void; setRooms: (rooms: PublicRoomSummary[]) => void; onRoomJoined: (snapshot: RoomSnapshotMessage) => void; + onDirectMessage: (message: DirectMessage) => void; onDisconnected: () => void; }; @@ -74,6 +76,10 @@ export class Game { this.options.chat.addMessage(message.username, message.text, message.sentAt); } + if (message.type === "dm.message") { + this.options.onDirectMessage(message); + } + if (message.type === "error") { this.options.setStatus(`${message.code}: ${message.message}`); } @@ -126,6 +132,10 @@ export class Game { this.sendIfConnected({ type: "avatar.appearance.update", appearance }); } + sendDirectMessage(toUserId: string, text: string): boolean { + return this.sendIfConnected({ type: "dm.send", toUserId, text }); + } + async reconnect(): Promise { // Drop stale avatars before reconnecting so the player does not see a frozen copy of // the previous room while the server re-sends a snapshot (or, in edge cases where no diff --git a/apps/client/src/messaging/DirectMessageClient.test.ts b/apps/client/src/messaging/DirectMessageClient.test.ts new file mode 100644 index 0000000..2870306 --- /dev/null +++ b/apps/client/src/messaging/DirectMessageClient.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { DirectMessage } from "@tilezo/protocol/messages"; +import { DEFAULT_API_URL } from "../assets"; +import { loadConversation } from "./DirectMessageClient"; + +const originalFetch = globalThis.fetch; +type FetchArgs = Parameters; + +describe("loadConversation", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("loads conversation history with the session cookie", async () => { + const messages: DirectMessage[] = [ + { + type: "dm.message", + id: "dm_1", + fromUserId: "user_1", + toUserId: "user_2", + text: "hi", + sentAt: "2026-06-13T00:00:00.000Z", + }, + ]; + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { + requests.push({ url: String(url), init }); + return Response.json({ messages }); + }) as unknown as typeof fetch; + + await expect(loadConversation("user_2")).resolves.toEqual(messages); + expect(requests).toEqual([ + { + url: `${DEFAULT_API_URL}/friends/user_2/messages`, + init: { credentials: "include" }, + }, + ]); + }); + + test("throws the server error message on failure", async () => { + globalThis.fetch = (async () => + Response.json( + { error: { message: "You can only message your friends" } }, + { status: 400 }, + )) as unknown as typeof fetch; + + await expect(loadConversation("user_2")).rejects.toThrow("only message your friends"); + }); +}); diff --git a/apps/client/src/messaging/DirectMessageClient.ts b/apps/client/src/messaging/DirectMessageClient.ts new file mode 100644 index 0000000..b53a624 --- /dev/null +++ b/apps/client/src/messaging/DirectMessageClient.ts @@ -0,0 +1,36 @@ +import type { DirectMessage } from "@tilezo/protocol/messages"; +import { DEFAULT_API_URL } from "../assets"; + +export type { DirectMessage }; + +export async function loadConversation(friendId: string): Promise { + const response = await fetch(`${getApiUrl()}/friends/${encodeURIComponent(friendId)}/messages`, { + credentials: "include", + }); + const body = await readJson<{ messages?: DirectMessage[] } | { error?: { message?: string } }>( + response, + ); + + if (!response.ok) { + throw new Error(body && "error" in body ? body.error?.message : "Could not load messages"); + } + + return Array.isArray((body as { messages?: unknown }).messages) + ? (body as { messages: DirectMessage[] }).messages + : []; +} + +function getApiUrl(): string { + const runtimeConfigured = + typeof window === "undefined" ? undefined : window.TILEZO_CONFIG?.PUBLIC_API_URL; + const buildConfigured = typeof process === "undefined" ? undefined : process.env.PUBLIC_API_URL; + return runtimeConfigured ?? buildConfigured ?? DEFAULT_API_URL; +} + +async function readJson(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return undefined; + } +} diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 03837ea..343ce1f 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -524,6 +524,66 @@ select { right: 20px; } +.dm-panel { + position: absolute; + top: 86px; + right: 20px; + z-index: 6; + display: grid; + width: min(380px, calc(100vw - 40px)); + max-height: calc(100vh - 118px); + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 2px solid #1d2324; + border-radius: 8px; + background: #eeeade; + box-shadow: + inset 0 0 0 2px #ffffff, + 7px 7px 0 rgba(31, 45, 47, 0.32); + pointer-events: auto; +} + +.dm-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px; + overflow-y: auto; +} + +.dm-message { + max-width: 78%; + padding: 6px 10px; + border-radius: 10px; + font-size: 13px; + line-height: 1.35; + word-break: break-word; +} + +.dm-message-theirs { + align-self: flex-start; + background: #d7d2c4; + color: #1f2d2f; +} + +.dm-message-mine { + align-self: flex-end; + background: #2f6f5f; + color: #fafaf5; +} + +.dm-form { + display: flex; + gap: 8px; + padding: 12px; + border-top: 2px solid #d2ccbd; +} + +.dm-form input { + flex: 1; + min-width: 0; +} + .room-browser-header { display: flex; align-items: center; diff --git a/apps/client/src/ui/DirectMessagePanel.test.ts b/apps/client/src/ui/DirectMessagePanel.test.ts new file mode 100644 index 0000000..fabb782 --- /dev/null +++ b/apps/client/src/ui/DirectMessagePanel.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { DirectMessage } from "@tilezo/protocol/messages"; +import { DirectMessagePanel } from "./DirectMessagePanel"; + +const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); + +function dm(over: Partial): DirectMessage { + return { + type: "dm.message", + id: "dm_1", + fromUserId: "user_2", + toUserId: "user_1", + text: "hi", + sentAt: "2026-06-13T00:00:00.000Z", + ...over, + }; +} + +describe("DirectMessagePanel", () => { + afterEach(() => restoreDocument()); + + test("opens a conversation, renders history, and aligns own messages", () => { + installDocument(); + const panel = new DirectMessagePanel({ onSend() {} }); + + panel.open( + { id: "user_2", username: "Kai" }, + [dm({ text: "hello" }), dm({ fromUserId: "user_1", text: "hey" })], + "user_1", + ); + + expect(panel.element.classList.contains("hidden")).toBe(false); + expect(panel.isOpenFor("user_2")).toBe(true); + const list = panel.element.children[1] as unknown as FakeElement; + expect(list.children.map((c) => c.textContent)).toEqual(["hello", "hey"]); + expect(list.children[0]?.className).toBe("dm-message dm-message-theirs"); + expect(list.children[1]?.className).toBe("dm-message dm-message-mine"); + }); + + test("appends only messages that belong to the open conversation", () => { + installDocument(); + const panel = new DirectMessagePanel({ onSend() {} }); + panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); + const list = panel.element.children[1] as unknown as FakeElement; + + expect(panel.append(dm({ text: "live" }))).toBe(true); + expect(panel.append(dm({ fromUserId: "user_3", toUserId: "user_1", text: "other" }))).toBe( + false, + ); + expect(list.children.map((c) => c.textContent)).toEqual(["live"]); + }); + + test("sends the typed message for the open friend and clears the input", () => { + installDocument(); + const sent: Array<{ friendId: string; text: string }> = []; + const panel = new DirectMessagePanel({ + onSend(friendId, text) { + sent.push({ friendId, text }); + }, + }); + panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); + + const form = panel.element.children[2] as unknown as FakeElement; + const input = form.children[0] as FakeElement; + input.value = " yo "; + form.dispatch("submit", { preventDefault() {} }); + + expect(sent).toEqual([{ friendId: "user_2", text: "yo" }]); + expect(input.value).toBe(""); + }); +}); + +function installDocument() { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + createElement(tagName: string) { + return new FakeElement(tagName); + }, + } as unknown as Document, + }); +} + +function restoreDocument() { + if (originalDocument) { + Object.defineProperty(globalThis, "document", originalDocument); + } else { + Reflect.deleteProperty(globalThis, "document"); + } +} + +type FakeEvent = { preventDefault?: () => void; target?: FakeElement }; + +class FakeElement { + readonly children: FakeElement[] = []; + readonly classList = new FakeClassList(this); + readonly listeners = new Map void>>(); + autocomplete = ""; + className = ""; + maxLength = 0; + name = ""; + parentElement?: FakeElement; + placeholder = ""; + scrollHeight = 0; + scrollTop = 0; + textContent = ""; + type = ""; + value = ""; + + constructor(readonly tagName: string) {} + + append(...children: FakeElement[]): void { + for (const child of children) { + child.parentElement = this; + } + this.children.push(...children); + } + + replaceChildren(...children: FakeElement[]): void { + for (const child of children) { + child.parentElement = this; + } + this.children.splice(0, this.children.length, ...children); + } + + addEventListener(type: string, listener: (event: FakeEvent) => void): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + dispatch(type: string, event: FakeEvent): void { + event.target ??= this; + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + this.parentElement?.dispatch(type, event); + } + + focus(): void {} +} + +class FakeClassList { + constructor(private readonly element: FakeElement) {} + + add(className: string): void { + this.setClasses([...this.getClasses(), className]); + } + + remove(className: string): void { + this.setClasses(this.getClasses().filter((value) => value !== className)); + } + + contains(className: string): boolean { + return this.getClasses().includes(className); + } + + private getClasses(): string[] { + return this.element.className.split(" ").filter(Boolean); + } + + private setClasses(classes: string[]): void { + this.element.className = [...new Set(classes)].join(" "); + } +} diff --git a/apps/client/src/ui/DirectMessagePanel.ts b/apps/client/src/ui/DirectMessagePanel.ts new file mode 100644 index 0000000..7c67646 --- /dev/null +++ b/apps/client/src/ui/DirectMessagePanel.ts @@ -0,0 +1,128 @@ +import type { DirectMessage } from "@tilezo/protocol/messages"; + +type DirectMessagePanelOptions = { + onSend: (friendId: string, text: string) => void; +}; + +type Conversation = { + friendId: string; + friendName: string; + selfUserId: string; +}; + +export class DirectMessagePanel { + readonly element = document.createElement("section"); + + private readonly title = document.createElement("h2"); + private readonly messageList = document.createElement("div"); + private readonly form = document.createElement("form"); + private readonly input = document.createElement("input"); + private conversation?: Conversation; + + constructor(private readonly options: DirectMessagePanelOptions) { + this.element.className = "dm-panel hidden"; + + const header = document.createElement("header"); + const actions = document.createElement("div"); + const closeButton = document.createElement("button"); + const sendButton = document.createElement("button"); + + header.className = "room-browser-header"; + this.title.textContent = "Messages"; + actions.className = "room-browser-actions"; + closeButton.type = "button"; + closeButton.className = "secondary-button room-browser-close"; + closeButton.textContent = "Close"; + closeButton.addEventListener("click", () => this.hide()); + + this.messageList.className = "dm-list"; + + this.form.className = "dm-form"; + this.input.type = "text"; + this.input.name = "dm-text"; + this.input.placeholder = "Message your friend"; + this.input.autocomplete = "off"; + this.input.maxLength = 600; + sendButton.type = "submit"; + sendButton.className = "primary-button dm-send-button"; + sendButton.textContent = "Send"; + this.form.append(this.input, sendButton); + this.form.addEventListener("submit", (event) => { + event.preventDefault(); + const text = this.input.value.trim(); + + if (!text || !this.conversation) { + return; + } + + this.options.onSend(this.conversation.friendId, text); + this.input.value = ""; + }); + + actions.append(closeButton); + header.append(this.title, actions); + this.element.append(header, this.messageList, this.form); + } + + open( + friend: { id: string; username: string }, + history: DirectMessage[], + selfUserId: string, + ): void { + this.conversation = { friendId: friend.id, friendName: friend.username, selfUserId }; + this.title.textContent = `Chat with ${friend.username}`; + this.messageList.replaceChildren(); + + for (const message of history) { + this.renderMessage(message); + } + + this.element.classList.remove("hidden"); + this.scrollToLatest(); + this.input.focus(); + } + + // Appends a live message if it belongs to the open conversation. Returns whether it did. + append(message: DirectMessage): boolean { + if (!this.conversation || this.isHidden() || !this.belongsToConversation(message)) { + return false; + } + + this.renderMessage(message); + this.scrollToLatest(); + return true; + } + + hide(): void { + this.element.classList.add("hidden"); + this.conversation = undefined; + } + + isOpenFor(friendId: string): boolean { + return !this.isHidden() && this.conversation?.friendId === friendId; + } + + private belongsToConversation(message: DirectMessage): boolean { + const { friendId, selfUserId } = this.conversation ?? { friendId: "", selfUserId: "" }; + return ( + (message.fromUserId === friendId && message.toUserId === selfUserId) || + (message.fromUserId === selfUserId && message.toUserId === friendId) + ); + } + + private renderMessage(message: DirectMessage): void { + const mine = message.fromUserId === this.conversation?.selfUserId; + const item = document.createElement("div"); + item.className = mine ? "dm-message dm-message-mine" : "dm-message dm-message-theirs"; + item.textContent = message.text; + this.messageList.append(item); + } + + private scrollToLatest(): void { + this.messageList.scrollTop = this.messageList.scrollHeight; + } + + private isHidden(): boolean { + return this.element.classList.contains("hidden"); + } +} diff --git a/apps/client/src/ui/FriendsPanel.test.ts b/apps/client/src/ui/FriendsPanel.test.ts index 37282c4..5edef72 100644 --- a/apps/client/src/ui/FriendsPanel.test.ts +++ b/apps/client/src/ui/FriendsPanel.test.ts @@ -18,6 +18,7 @@ describe("FriendsPanel", () => { added.push(username); }, onJoinRoom() {}, + onMessage() {}, onRefresh() { refreshes += 1; }, @@ -36,15 +37,19 @@ describe("FriendsPanel", () => { expect(input.value).toBe(""); }); - test("renders friends and routes join/remove actions", () => { + test("renders friends and routes join/message/remove actions", () => { installDocument(); const joined: string[] = []; + const messaged: string[] = []; const removed: string[] = []; const panel = new FriendsPanel({ onAdd() {}, onJoinRoom(roomId) { joined.push(roomId); }, + onMessage(friend) { + messaged.push(friend.id); + }, onRefresh() {}, onRemove(friendId) { removed.push(friendId); @@ -72,8 +77,10 @@ describe("FriendsPanel", () => { (actions?.children[0] as FakeElement | undefined)?.dispatch("click", {}); (actions?.children[1] as FakeElement | undefined)?.dispatch("click", {}); + (actions?.children[2] as FakeElement | undefined)?.dispatch("click", {}); expect(joined).toEqual(["studio"]); + expect(messaged).toEqual(["user_2"]); expect(removed).toEqual(["user_2"]); }); }); @@ -170,6 +177,14 @@ class FakeElement { return this; } + if ( + selector === "button[data-message-friend-id]" && + this.tagName === "button" && + this.dataset.messageFriendId + ) { + return this; + } + return this.parentElement?.closest(selector); } diff --git a/apps/client/src/ui/FriendsPanel.ts b/apps/client/src/ui/FriendsPanel.ts index ba9ee93..5131b62 100644 --- a/apps/client/src/ui/FriendsPanel.ts +++ b/apps/client/src/ui/FriendsPanel.ts @@ -4,6 +4,7 @@ import { AvatarPreview } from "./AvatarPreview"; type FriendsPanelOptions = { onAdd: (username: string) => void; onJoinRoom: (roomId: string) => void; + onMessage: (friend: FriendSummary) => void; onRefresh: () => void; onRemove: (friendId: string) => void; }; @@ -67,6 +68,7 @@ export class FriendsPanel { this.list.addEventListener("click", (event) => { const target = event.target as Element | null; const joinButton = target?.closest("button[data-room-id]"); + const messageButton = target?.closest("button[data-message-friend-id]"); const removeButton = target?.closest("button[data-friend-id]"); if (joinButton && !joinButton.disabled) { @@ -75,6 +77,15 @@ export class FriendsPanel { return; } + if (messageButton) { + const friend = this.friends.find((f) => f.id === messageButton.dataset.messageFriendId); + + if (friend) { + this.options.onMessage(friend); + } + return; + } + if (removeButton && !removeButton.disabled) { this.options.onRemove(removeButton.dataset.friendId ?? ""); } @@ -131,6 +142,7 @@ export class FriendsPanel { const meta = document.createElement("span"); const actions = document.createElement("div"); const joinButton = document.createElement("button"); + const messageButton = document.createElement("button"); const removeButton = document.createElement("button"); item.className = friend.online ? "friend-item online" : "friend-item"; @@ -144,6 +156,10 @@ export class FriendsPanel { joinButton.disabled = !friend.canJoinRoom || !friend.roomId; joinButton.dataset.roomId = friend.roomId ?? ""; joinButton.textContent = friend.roomId ? "Join" : "Away"; + messageButton.type = "button"; + messageButton.className = "secondary-button friend-message-button"; + messageButton.dataset.messageFriendId = friend.id; + messageButton.textContent = "Message"; removeButton.type = "button"; removeButton.className = "secondary-button friend-remove-button"; removeButton.dataset.friendId = friend.id; @@ -157,7 +173,7 @@ export class FriendsPanel { : "offline"; details.append(name, meta); - actions.append(joinButton, removeButton); + actions.append(joinButton, messageButton, removeButton); item.append(preview.element, details, actions); return item; } diff --git a/apps/server/src/db/integration.test.ts b/apps/server/src/db/integration.test.ts index 99f41f7..15e1f3f 100644 --- a/apps/server/src/db/integration.test.ts +++ b/apps/server/src/db/integration.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol"; import { sql } from "drizzle-orm"; import { DrizzleAuthStore, UsernameTakenError } from "../auth/auth"; import { DrizzleFriendStore } from "../friends/friends"; +import { DrizzleDirectMessageStore } from "../messaging/messaging"; import { createDatabase } from "./db"; import { DrizzlePersistenceStore } from "./persistence"; @@ -25,11 +26,12 @@ describe("database integration", () => { const authStore = new DrizzleAuthStore(database); const friendStore = new DrizzleFriendStore(database); + const directMessageStore = new DrizzleDirectMessageStore(database); const persistence = new DrizzlePersistenceStore(database); beforeEach(async () => { await database.execute( - sql`TRUNCATE TABLE users, rooms, friendships, user_room_sessions, room_items RESTART IDENTITY CASCADE`, + sql`TRUNCATE TABLE users, rooms, friendships, user_room_sessions, room_items, direct_messages RESTART IDENTITY CASCADE`, ); }); @@ -93,6 +95,44 @@ describe("database integration", () => { await expect(friendStore.addFriend(dan.id, dan.id)).rejects.toThrow(); }); + test("persists direct messages and lists a conversation in both directions", async () => { + const dan = await seedUser("Dan"); + const kai = await seedUser("Kai"); + + const sent = await directMessageStore.save({ + id: "dm_1", + senderUserId: dan.id, + recipientUserId: kai.id, + body: "hey Kai", + }); + expect(sent).toMatchObject({ fromUserId: dan.id, toUserId: kai.id, text: "hey Kai" }); + + await directMessageStore.save({ + id: "dm_2", + senderUserId: kai.id, + recipientUserId: dan.id, + body: "hi Dan", + }); + + // Both participants see the same conversation, oldest-first. + expect( + (await directMessageStore.listConversation(dan.id, kai.id, 50)).map((m) => m.text), + ).toEqual(["hey Kai", "hi Dan"]); + expect( + (await directMessageStore.listConversation(kai.id, dan.id, 50)).map((m) => m.text), + ).toEqual(["hey Kai", "hi Dan"]); + + // The no-self CHECK constraint blocks a self-message. + await expect( + directMessageStore.save({ + id: "dm_3", + senderUserId: dan.id, + recipientUserId: dan.id, + body: "note to self", + }), + ).rejects.toThrow(); + }); + test("seeds rooms, lists by visibility and owner, and tracks the last room", async () => { const owner = await seedUser("Dan"); const publicLayout = createRectRoomLayout("lobby", "Lobby", 3, 3, { x: 1, y: 1 }); diff --git a/apps/server/src/db/migrations/0009_direct_messages.sql b/apps/server/src/db/migrations/0009_direct_messages.sql new file mode 100644 index 0000000..92c4653 --- /dev/null +++ b/apps/server/src/db/migrations/0009_direct_messages.sql @@ -0,0 +1,16 @@ +CREATE TABLE "direct_messages" ( + "id" text PRIMARY KEY NOT NULL, + "sender_user_id" text NOT NULL, + "recipient_user_id" text NOT NULL, + "body" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "direct_messages_no_self_check" CHECK ("sender_user_id" <> "recipient_user_id") +); +--> statement-breakpoint +ALTER TABLE "direct_messages" ADD CONSTRAINT "direct_messages_sender_user_id_users_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "direct_messages" ADD CONSTRAINT "direct_messages_recipient_user_id_users_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "direct_messages_pair_idx" ON "direct_messages" USING btree ("sender_user_id","recipient_user_id","created_at"); +--> statement-breakpoint +CREATE INDEX "direct_messages_recipient_idx" ON "direct_messages" USING btree ("recipient_user_id","created_at"); diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 288881a..9367578 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1778900000000, "tag": "0008_user_token_version", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1779000000000, + "tag": "0009_direct_messages", + "breakpoints": true } ] } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 40cdb51..b5bfefc 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -92,3 +92,26 @@ export const friendships = pgTable( index("friendships_friend_user_id_idx").on(table.friendUserId), ], ); + +export const directMessages = pgTable( + "direct_messages", + { + id: text("id").primaryKey(), + senderUserId: text("sender_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + recipientUserId: text("recipient_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + body: text("body").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("direct_messages_pair_idx").on( + table.senderUserId, + table.recipientUserId, + table.createdAt, + ), + index("direct_messages_recipient_idx").on(table.recipientUserId, table.createdAt), + ], +); diff --git a/apps/server/src/friends/friends.test.ts b/apps/server/src/friends/friends.test.ts index 16da827..7403327 100644 --- a/apps/server/src/friends/friends.test.ts +++ b/apps/server/src/friends/friends.test.ts @@ -156,6 +156,9 @@ function createStore(users: FriendUser[]): FriendStore { async addFriend(userId, friendUserId) { friendships.add(friendshipKey(userId, friendUserId)); }, + async areFriends(userId, friendUserId) { + return friendships.has(friendshipKey(userId, friendUserId)); + }, async countFriends(userId) { return [...friendships] .map((key) => key.split(":")) diff --git a/apps/server/src/friends/friends.ts b/apps/server/src/friends/friends.ts index 3fb20db..9889ff5 100644 --- a/apps/server/src/friends/friends.ts +++ b/apps/server/src/friends/friends.ts @@ -23,6 +23,7 @@ export type FriendSummary = FriendUser & export type FriendStore = { addFriend(userId: string, friendUserId: string): Promise; + areFriends(userId: string, friendUserId: string): Promise; countFriends(userId: string): Promise; findUserByUsername(username: string): Promise; listFriends(userId: string): Promise; @@ -75,6 +76,10 @@ export class FriendService { await this.store.removeFriend(userId, friendUserId); } + areFriends(userId: string, friendUserId: string): Promise { + return this.store.areFriends(userId, friendUserId); + } + private summarize(friend: FriendUser): FriendSummary { const presence = this.presence(friend.id); return { @@ -120,6 +125,16 @@ export class DrizzleFriendStore implements FriendStore { return row?.value ?? 0; } + async areFriends(userId: string, friendUserId: string): Promise { + const [leftUserId, rightUserId] = friendshipPair(userId, friendUserId); + const [row] = await this.db + .select({ userId: friendships.userId }) + .from(friendships) + .where(and(eq(friendships.userId, leftUserId), eq(friendships.friendUserId, rightUserId))) + .limit(1); + return Boolean(row); + } + async removeFriend(userId: string, friendUserId: string): Promise { const [leftUserId, rightUserId] = friendshipPair(userId, friendUserId); diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 11e148a..6cd01fb 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -5,6 +5,7 @@ import { FixedWindowRateLimiter } from "../auth/rateLimit"; import { getConfig } from "../config"; import type { PersistenceStore } from "../db/persistence"; import { FriendError, type FriendService } from "../friends/friends"; +import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import type { Logger } from "../observability/logger"; import { Metrics } from "../observability/metrics"; import type { RoomManager } from "../rooms/RoomManager"; @@ -41,6 +42,17 @@ function makeDeps(overrides: Partial = {}): RouterDeps { add: async () => ({ ...authUser, online: false, canJoinRoom: false }), remove: async () => {}, } as unknown as FriendService, + directMessages: { + history: async () => [ + { + id: "dm_1", + fromUserId: "user_1", + toUserId: "user_2", + text: "hi", + sentAt: "2026-06-13T00:00:00.000Z", + }, + ], + } as unknown as DirectMessageService, persistence: { listOwnedRooms: async () => [], seedRoom: async () => {}, @@ -335,6 +347,45 @@ describe("createHttpRouter", () => { }); }); + describe("direct messages", () => { + test("returns conversation history for the authenticated user", async () => { + const route = createHttpRouter(makeDeps()); + + const response = await route( + request("/friends/user_2/messages", { method: "GET", token: "good-token" }), + "ip", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ messages: [{ id: "dm_1", text: "hi" }] }); + }); + + test("rejects unauthenticated history requests", async () => { + const route = createHttpRouter(makeDeps()); + const response = await route(request("/friends/user_2/messages", { method: "GET" }), "ip"); + expect(response.status).toBe(401); + }); + + test("surfaces a non-friend history rejection", async () => { + const route = createHttpRouter( + makeDeps({ + directMessages: { + history: async () => { + throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); + }, + } as unknown as DirectMessageService, + }), + ); + + const response = await route( + request("/friends/user_2/messages", { method: "GET", token: "good-token" }), + "ip", + ); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "NOT_FRIENDS" } }); + }); + }); + describe("rooms", () => { test("lists templates and creates a room", async () => { const route = createHttpRouter(makeDeps()); diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index 876393f..b73c4e9 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -4,6 +4,7 @@ import type { FixedWindowRateLimiter } from "../auth/rateLimit"; import type { ServerConfig } from "../config"; import type { PersistenceStore } from "../db/persistence"; import { FriendError, type FriendService } from "../friends/friends"; +import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import type { Logger, LogLevel } from "../observability/logger"; import type { Metrics } from "../observability/metrics"; import type { RoomManager } from "../rooms/RoomManager"; @@ -20,6 +21,7 @@ export type RouterDeps = { metrics: Metrics; auth?: AuthService; friends?: FriendService; + directMessages?: DirectMessageService; persistence?: PersistenceStore; rooms: RoomManager; registerRateLimiter: FixedWindowRateLimiter; @@ -117,6 +119,14 @@ async function dispatch(ctx: RouteContext): Promise { return handleClientEventRequest(ctx); } + if ( + url.pathname.startsWith("/friends/") && + url.pathname.endsWith("/messages") && + request.method === "GET" + ) { + return handleDirectMessageHistoryRequest(ctx); + } + if (url.pathname === "/friends" || url.pathname.startsWith("/friends/")) { return handleFriendsRequest(ctx); } @@ -456,6 +466,49 @@ async function handleFriendsRequest(ctx: RouteContext): Promise { } } +async function handleDirectMessageHistoryRequest(ctx: RouteContext): Promise { + const { auth, directMessages, requestLogger, url } = ctx; + + if (!auth || !directMessages) { + return authJson( + { error: { code: "DATABASE_REQUIRED", message: "Database is required for messages" } }, + 503, + ); + } + + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); + + if (!user) { + return authJson( + { error: { code: "UNAUTHENTICATED", message: "Log in before reading messages" } }, + 401, + ); + } + + const friendId = decodeURIComponent(url.pathname.slice("/friends/".length, -"/messages".length)); + + if (!friendId) { + return authJson({ error: { code: "INVALID_FRIEND", message: "Friend id is required" } }, 400); + } + + try { + const requestedLimit = Number(url.searchParams.get("limit")); + const messages = await directMessages.history( + user.id, + friendId, + Number.isFinite(requestedLimit) && requestedLimit > 0 ? requestedLimit : undefined, + ); + return authJson({ messages }, 200); + } catch (error) { + if (error instanceof DirectMessageError) { + return authJson({ error: { code: error.code, message: error.message } }, 400); + } + + requestLogger.error("dm.history.failed", { userId: user.id, friendId, error }); + return authJson({ error: { code: "DM_FAILED", message: "Could not load messages" } }, 400); + } +} + async function handleCreateRoomRequest(ctx: RouteContext): Promise { const { auth, persistence, rooms, requestLogger } = ctx; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 1d08ac6..3148d04 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,6 +8,7 @@ import { createDatabase } from "./db/db"; import { DrizzlePersistenceStore, type PersistenceStore } from "./db/persistence"; import { DrizzleFriendStore, FriendService } from "./friends/friends"; import { corsHeaders, createHttpRouter, readSessionToken } from "./http/router"; +import { DirectMessageService, DrizzleDirectMessageStore } from "./messaging/messaging"; import { handleClose, handleMessage, handleOpen } from "./net/handleMessage"; import type { SocketData } from "./net/socketTypes"; import { createLogger, parseLogLevel } from "./observability/logger"; @@ -63,6 +64,12 @@ const friends = database maxFriends: config.maxFriendsPerUser, }) : undefined; +const directMessages = + database && friends + ? new DirectMessageService(new DrizzleDirectMessageStore(database), (a, b) => + friends.areFriends(a, b), + ) + : undefined; const auth = database ? new AuthService(new DrizzleAuthStore(database), { secret: config.authSecret, @@ -82,6 +89,7 @@ const router = createHttpRouter({ metrics, auth, friends, + directMessages, persistence, rooms, registerRateLimiter, @@ -111,6 +119,7 @@ const server = Bun.serve({ rooms, publish, persistence, + directMessages, logger, metrics, presence, @@ -122,6 +131,7 @@ const server = Bun.serve({ rooms, publish, persistence, + directMessages, logger, metrics, presence, diff --git a/apps/server/src/messaging/messaging.test.ts b/apps/server/src/messaging/messaging.test.ts new file mode 100644 index 0000000..3e42a77 --- /dev/null +++ b/apps/server/src/messaging/messaging.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { + DirectMessageError, + type DirectMessageRecord, + DirectMessageService, + type DirectMessageStore, + DrizzleDirectMessageStore, +} from "./messaging"; + +function createStore(): DirectMessageStore & { saved: DirectMessageRecord[] } { + const saved: DirectMessageRecord[] = []; + return { + saved, + async save(message) { + const record: DirectMessageRecord = { + id: message.id, + fromUserId: message.senderUserId, + toUserId: message.recipientUserId, + text: message.body, + sentAt: "2026-06-13T00:00:00.000Z", + }; + saved.push(record); + return record; + }, + async listConversation(userId, otherUserId, limit) { + return saved + .filter( + (message) => + (message.fromUserId === userId && message.toUserId === otherUserId) || + (message.fromUserId === otherUserId && message.toUserId === userId), + ) + .slice(-limit); + }, + }; +} + +describe("DirectMessageService", () => { + test("sends a message between friends and persists it", async () => { + const store = createStore(); + const service = new DirectMessageService(store, async () => true); + + const record = await service.send("user_1", "user_2", "hello"); + + expect(record).toMatchObject({ fromUserId: "user_1", toUserId: "user_2", text: "hello" }); + expect(store.saved).toHaveLength(1); + }); + + test("rejects messaging yourself", async () => { + const service = new DirectMessageService(createStore(), async () => true); + await expect(service.send("user_1", "user_1", "hi")).rejects.toBeInstanceOf(DirectMessageError); + }); + + test("rejects messaging non-friends without persisting", async () => { + const store = createStore(); + const service = new DirectMessageService(store, async () => false); + + await expect(service.send("user_1", "user_2", "hi")).rejects.toThrow( + "only message your friends", + ); + expect(store.saved).toHaveLength(0); + }); + + test("returns conversation history for friends and blocks it for non-friends", async () => { + const store = createStore(); + const friendly = new DirectMessageService(store, async () => true); + + await friendly.send("user_1", "user_2", "a"); + await friendly.send("user_2", "user_1", "b"); + + expect((await friendly.history("user_1", "user_2")).map((m) => m.text)).toEqual(["a", "b"]); + + const blocked = new DirectMessageService(store, async () => false); + await expect(blocked.history("user_1", "user_2")).rejects.toBeInstanceOf(DirectMessageError); + }); +}); + +describe("DrizzleDirectMessageStore", () => { + const createdAt = new Date("2026-06-13T10:00:00.000Z"); + + test("saves a message and maps row columns to the wire record", async () => { + const row = { + id: "dm_1", + senderUserId: "user_1", + recipientUserId: "user_2", + body: "hello", + createdAt, + }; + const store = new DrizzleDirectMessageStore(queryDouble([[row]])); + + await expect( + store.save({ id: "dm_1", senderUserId: "user_1", recipientUserId: "user_2", body: "hello" }), + ).resolves.toEqual({ + id: "dm_1", + fromUserId: "user_1", + toUserId: "user_2", + text: "hello", + sentAt: "2026-06-13T10:00:00.000Z", + }); + }); + + test("lists a conversation oldest-first", async () => { + // The store fetches newest-first then reverses, so feed rows newest-first. + const newer = { + id: "dm_2", + senderUserId: "user_2", + recipientUserId: "user_1", + body: "second", + createdAt: new Date("2026-06-13T10:01:00.000Z"), + }; + const older = { + id: "dm_1", + senderUserId: "user_1", + recipientUserId: "user_2", + body: "first", + createdAt, + }; + const store = new DrizzleDirectMessageStore(queryDouble([[newer, older]])); + + const history = await store.listConversation("user_1", "user_2", 50); + expect(history.map((message) => message.text)).toEqual(["first", "second"]); + }); +}); + +// Minimal awaitable/chainable Drizzle query-builder stand-in: each builder method returns +// the same chain, and awaiting it yields the next queued result array. +function queryDouble( + results: unknown[][] = [], + // biome-ignore lint/suspicious/noExplicitAny: a structural stand-in for the Drizzle database. +): any { + let index = 0; + const chain: Record = { + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable and chainable. + then(resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(results[index++] ?? []).then(resolve, reject); + }, + }; + + for (const method of [ + "select", + "from", + "where", + "orderBy", + "limit", + "values", + "returning", + "insert", + ]) { + chain[method] = () => chain; + } + + return chain; +} diff --git a/apps/server/src/messaging/messaging.ts b/apps/server/src/messaging/messaging.ts new file mode 100644 index 0000000..c7aff33 --- /dev/null +++ b/apps/server/src/messaging/messaging.ts @@ -0,0 +1,153 @@ +import { and, desc, eq, or } from "drizzle-orm"; +import type { TilezoDatabase } from "../db/db"; +import { directMessages } from "../db/schema"; +import { createId } from "../util/ids"; + +export type DirectMessageRecord = { + id: string; + fromUserId: string; + toUserId: string; + text: string; + sentAt: string; +}; + +export const DEFAULT_DM_HISTORY_LIMIT = 50; +export const MAX_DM_HISTORY_LIMIT = 100; + +export type DirectMessageStore = { + save(message: { + id: string; + senderUserId: string; + recipientUserId: string; + body: string; + }): Promise; + listConversation( + userId: string, + otherUserId: string, + limit: number, + ): Promise; +}; + +// Friendship gate (injected): direct messages are only allowed between mutual friends. +type FriendshipCheck = (userId: string, otherUserId: string) => Promise; + +export class DirectMessageError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +export class DirectMessageService { + constructor( + private readonly store: DirectMessageStore, + private readonly areFriends: FriendshipCheck, + ) {} + + async send(senderId: string, recipientId: string, text: string): Promise { + if (senderId === recipientId) { + throw new DirectMessageError("INVALID_RECIPIENT", "You cannot message yourself"); + } + + if (!(await this.areFriends(senderId, recipientId))) { + throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); + } + + return this.store.save({ + id: createId("dm"), + senderUserId: senderId, + recipientUserId: recipientId, + body: text, + }); + } + + async history( + userId: string, + otherUserId: string, + limit = DEFAULT_DM_HISTORY_LIMIT, + ): Promise { + if (!(await this.areFriends(userId, otherUserId))) { + throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); + } + + const safeLimit = Math.max( + 1, + Math.min(MAX_DM_HISTORY_LIMIT, Math.trunc(limit) || DEFAULT_DM_HISTORY_LIMIT), + ); + return this.store.listConversation(userId, otherUserId, safeLimit); + } +} + +const DM_COLUMNS = { + id: directMessages.id, + senderUserId: directMessages.senderUserId, + recipientUserId: directMessages.recipientUserId, + body: directMessages.body, + createdAt: directMessages.createdAt, +} as const; + +type DirectMessageRow = { + id: string; + senderUserId: string; + recipientUserId: string; + body: string; + createdAt: Date; +}; + +export class DrizzleDirectMessageStore implements DirectMessageStore { + constructor(private readonly db: TilezoDatabase) {} + + async save(message: { + id: string; + senderUserId: string; + recipientUserId: string; + body: string; + }): Promise { + const [row] = await this.db.insert(directMessages).values(message).returning(DM_COLUMNS); + + if (!row) { + throw new Error("Direct message insert failed"); + } + + return toRecord(row); + } + + async listConversation( + userId: string, + otherUserId: string, + limit: number, + ): Promise { + const rows = await this.db + .select(DM_COLUMNS) + .from(directMessages) + .where( + or( + and( + eq(directMessages.senderUserId, userId), + eq(directMessages.recipientUserId, otherUserId), + ), + and( + eq(directMessages.senderUserId, otherUserId), + eq(directMessages.recipientUserId, userId), + ), + ), + ) + // Fetch the most recent `limit`, then return them oldest-first for display. + .orderBy(desc(directMessages.createdAt)) + .limit(limit); + + return rows.reverse().map(toRecord); + } +} + +function toRecord(row: DirectMessageRow): DirectMessageRecord { + return { + id: row.id, + fromUserId: row.senderUserId, + toUserId: row.recipientUserId, + text: row.body, + sentAt: row.createdAt.toISOString(), + }; +} diff --git a/apps/server/src/net/handleMessage.test.ts b/apps/server/src/net/handleMessage.test.ts index a888b32..9a4b994 100644 --- a/apps/server/src/net/handleMessage.test.ts +++ b/apps/server/src/net/handleMessage.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createRectRoomLayout } from "@tilezo/engine"; import { DEFAULT_AVATAR_APPEARANCE, type ServerMessage } from "@tilezo/protocol"; import type { ServerWebSocket } from "bun"; +import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import { RoomManager } from "../rooms/RoomManager"; import { consumeRateLimit, @@ -551,7 +552,8 @@ describe("handleOpen", () => { }); await Promise.resolve(); - expect(ws.subscribed).toEqual(["room:studio"]); + // The socket subscribes to its per-user DM topic on open, then resumes the room. + expect(ws.subscribed).toEqual(["user:user_db_1", "room:studio"]); expect(ws.sent).toEqual([ { type: "connected", userId: "user_db_1" }, { @@ -682,6 +684,63 @@ describe("duplicate connections and movement guards", () => { }); }); +describe("direct messages", () => { + const sent = { + id: "dm_1", + fromUserId: "user_db_1", + toUserId: "user_db_2", + text: "hello", + sentAt: "2026-06-13T00:00:00.000Z", + }; + + test("delivers a direct message to the recipient and sender topics", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: { topic: string; message: ServerMessage }[] = []; + const directMessages = { + async send(from: string, to: string, text: string) { + return { ...sent, fromUserId: from, toUserId: to, text }; + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.send", toUserId: "user_db_2", text: "hello" }), { + rooms, + publish(topic, message) { + published.push({ topic, message }); + }, + directMessages, + }); + await flushAsyncMessages(); + + const message: ServerMessage = { type: "dm.message", ...sent }; + expect(published).toContainEqual({ topic: "user:user_db_2", message }); + expect(published).toContainEqual({ topic: "user:user_db_1", message }); + }); + + test("surfaces a friendship rejection as an error", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const directMessages = { + async send() { + throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.send", toUserId: "user_db_2", text: "hi" }), { + rooms, + publish() {}, + directMessages, + }); + await flushAsyncMessages(); + + expect(ws.sent).toContainEqual({ + type: "error", + code: "NOT_FRIENDS", + message: "You can only message your friends", + }); + }); +}); + describe("consumeRateLimit", () => { test("exhausts the burst then refills over time", () => { const ws = { data: {} } as unknown as ServerWebSocket; diff --git a/apps/server/src/net/handleMessage.ts b/apps/server/src/net/handleMessage.ts index ebb9721..04b031c 100644 --- a/apps/server/src/net/handleMessage.ts +++ b/apps/server/src/net/handleMessage.ts @@ -5,6 +5,7 @@ import { } from "@tilezo/protocol"; import type { ServerWebSocket } from "bun"; import type { PersistenceStore } from "../db/persistence"; +import { DirectMessageError, type DirectMessageService } from "../messaging/messaging"; import type { Logger } from "../observability/logger"; import type { Metrics } from "../observability/metrics"; import type { PresenceTracker } from "../presence/presence"; @@ -17,6 +18,7 @@ type Context = { rooms: RoomManager; publish: (topic: string, message: ServerMessage) => void; persistence?: PersistenceStore; + directMessages?: DirectMessageService; logger?: Logger; metrics?: Metrics; presence?: PresenceTracker; @@ -231,6 +233,22 @@ export function handleMessage( break; } + case "dm.send": { + if (!consumeRateLimit(ws, "dm")) { + context.metrics?.increment("rate_limited.dm"); + sendError(ws, "RATE_LIMITED", "Slow down before sending another message"); + return; + } + + if (!ws.data.username) { + sendError(ws, "UNAUTHENTICATED", "Log in before sending messages"); + return; + } + + void sendDirectMessage(ws, parsed.value.toUserId, parsed.value.text, context); + break; + } + case "ping": if (!consumeRateLimit(ws, "default")) { context.metrics?.increment("rate_limited.ping"); @@ -258,6 +276,9 @@ export function handleOpen(ws: ServerWebSocket, context: Context): v } context.metrics?.socketOpened(); context.logger?.info("websocket.opened", socketFields(ws)); + // Subscribe to a per-user topic so direct messages can be delivered to this user's + // sockets regardless of which room (if any) they are in. + ws.subscribe(userTopic(ws.data.userId)); send(ws, { type: "connected", userId: ws.data.userId, @@ -532,10 +553,52 @@ function getJoinedRoom(ws: ServerWebSocket, rooms: RoomManager) { return room; } +async function sendDirectMessage( + ws: ServerWebSocket, + toUserId: string, + text: string, + context: Context, +): Promise { + if (!context.directMessages) { + sendError(ws, "DM_UNAVAILABLE", "Direct messages are unavailable"); + return; + } + + try { + const record = await context.directMessages.send(ws.data.userId, toUserId, text); + const message: ServerMessage = { + type: "dm.message", + id: record.id, + fromUserId: record.fromUserId, + toUserId: record.toUserId, + text: record.text, + sentAt: record.sentAt, + }; + // Deliver to the recipient's sockets and echo to the sender's (so every tab and the + // server-assigned id/timestamp stay in sync). + context.publish(userTopic(record.toUserId), message); + context.publish(userTopic(record.fromUserId), message); + context.metrics?.increment("dm.sent"); + } catch (error) { + if (error instanceof DirectMessageError) { + context.metrics?.increment(`dm.rejected.${error.code}`); + sendError(ws, error.code, error.message); + return; + } + + context.logger?.warn("dm.send.failed", { ...socketFields(ws), error }); + sendError(ws, "DM_FAILED", "Could not send your message"); + } +} + function roomTopic(roomId: string): string { return `room:${roomId}`; } +function userTopic(userId: string): string { + return `user:${userId}`; +} + function send(ws: ServerWebSocket, message: ServerMessage): void { const result = ws.send(encodeServerMessage(message)); @@ -565,6 +628,7 @@ export const RATE_LIMITS = { movement: { burst: 12, refillPerSecond: 8 }, chat: { burst: 5, refillPerSecond: 2 }, typing: { burst: 8, refillPerSecond: 4 }, + dm: { burst: 5, refillPerSecond: 1 }, default: { burst: 20, refillPerSecond: 10 }, } satisfies Record; diff --git a/apps/server/src/net/socketTypes.ts b/apps/server/src/net/socketTypes.ts index 847c50c..41589b9 100644 --- a/apps/server/src/net/socketTypes.ts +++ b/apps/server/src/net/socketTypes.ts @@ -11,7 +11,7 @@ export type SocketData = { lastTypingState?: boolean; }; -export type RateLimitedMessageKind = "movement" | "chat" | "typing" | "default"; +export type RateLimitedMessageKind = "movement" | "chat" | "typing" | "dm" | "default"; export type RateLimitState = { tokens: number; diff --git a/docs/overview.md b/docs/overview.md index 4e2c820..3777ee4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -30,6 +30,7 @@ Implemented: token revocation, and rate limiting. - Avatar appearance customization persisted per account. - Multi-user presence and friends (add/remove, online status, join a friend's room). +- Friend-gated direct messages: realtime delivery, persistence, and history. - Public room browser with live room population counts, plus per-user private rooms and player-created rooms persisted to PostgreSQL. - Scripted, server-authoritative room bots (movement and chat). @@ -43,7 +44,6 @@ Not implemented yet: - Room editor UI. - Provider-backed (AI) bot conversations (see [FOLLOW_UPS.md](../FOLLOW_UPS.md)). -- Direct messaging / whispers (see [FOLLOW_UPS.md](../FOLLOW_UPS.md)). - Furniture/items placement (the `room_items` table is reserved but unused). - Inventory, catalogue, economy, moderation dashboard, trading, pets, or quests. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0a1431f..0218d3b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -25,6 +25,12 @@ export type ChatTypingMessage = { isTyping: boolean; }; +export type DirectMessageSendMessage = { + type: "dm.send"; + toUserId: string; + text: string; +}; + export type AvatarAppearanceUpdateMessage = { type: "avatar.appearance.update"; appearance: AvatarAppearance; @@ -41,6 +47,7 @@ export type ClientMessage = | AvatarMoveRequestMessage | ChatSayMessage | ChatTypingMessage + | DirectMessageSendMessage | AvatarAppearanceUpdateMessage | PingMessage; @@ -112,6 +119,15 @@ export type ChatTypingStatusMessage = { isTyping: boolean; }; +export type DirectMessage = { + type: "dm.message"; + id: string; + fromUserId: string; + toUserId: string; + text: string; + sentAt: string; +}; + export type PongMessage = { type: "pong"; sentAt: string; @@ -133,5 +149,6 @@ export type ServerMessage = | AvatarAppearanceUpdatedMessage | ChatMessage | ChatTypingStatusMessage + | DirectMessage | PongMessage | ErrorMessage; diff --git a/packages/protocol/src/protocol.test.ts b/packages/protocol/src/protocol.test.ts index 67c99c8..1a44d81 100644 --- a/packages/protocol/src/protocol.test.ts +++ b/packages/protocol/src/protocol.test.ts @@ -61,6 +61,17 @@ describe("protocol parser", () => { expect(parseClientMessage({ type: "chat.say", text: "\u200B\u202E\t" }).ok).toBe(false); }); + test("accepts and sanitizes direct messages", () => { + expect( + parseClientMessage({ type: "dm.send", toUserId: " user_2 ", text: " hey there " }), + ).toEqual({ + ok: true, + value: { type: "dm.send", toUserId: "user_2", text: "hey there" }, + }); + expect(parseClientMessage({ type: "dm.send", toUserId: "user_2", text: "" }).ok).toBe(false); + expect(parseClientMessage({ type: "dm.send", toUserId: "", text: "hi" }).ok).toBe(false); + }); + test("rejects malformed raw messages", () => { expect(parseRawClientMessage("{bad json").ok).toBe(false); }); diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 51706e3..028ae0a 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -13,8 +13,10 @@ import { export const MAX_RAW_MESSAGE_BYTES = 8 * 1024; export const USERNAME_MAX_LENGTH = 24; +export const USER_ID_MAX_LENGTH = 64; export const ROOM_ID_MAX_LENGTH = 64; export const CHAT_MAX_LENGTH = 240; +export const DIRECT_MESSAGE_MAX_LENGTH = 600; // Tile coordinates are bounded at the trust boundary so untrusted clients cannot // send absurd integers (e.g. near MAX_SAFE_INTEGER). The bound is far larger than // any real room while keeping every value comfortably within safe-integer math. @@ -33,6 +35,11 @@ const chatText = z .transform((value) => sanitizeChatText(value).trim()) .pipe(z.string().min(1).max(CHAT_MAX_LENGTH)); +const directMessageText = z + .string() + .transform((value) => sanitizeChatText(value).trim()) + .pipe(z.string().min(1).max(DIRECT_MESSAGE_MAX_LENGTH)); + const tileCoordinate = z.number().int().min(-MAX_TILE_COORDINATE).max(MAX_TILE_COORDINATE); export const tilePositionSchema = z.object({ @@ -64,6 +71,12 @@ export const chatTypingMessageSchema = z.object({ isTyping: z.boolean(), }); +export const dmSendMessageSchema = z.object({ + type: z.literal("dm.send"), + toUserId: trimmedString(USER_ID_MAX_LENGTH), + text: directMessageText, +}); + export const avatarAppearanceSchema = z.object({ hair: z.enum(AVATAR_HAIR_STYLES), hairColor: z.enum(AVATAR_HAIR_COLORS), @@ -92,6 +105,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ avatarMoveRequestMessageSchema, chatSayMessageSchema, chatTypingMessageSchema, + dmSendMessageSchema, avatarAppearanceUpdateMessageSchema, pingMessageSchema, ]); @@ -148,6 +162,14 @@ export const serverMessageSchema = z.discriminatedUnion("type", [ text: z.string(), sentAt: z.string(), }), + z.object({ + type: z.literal("dm.message"), + id: z.string(), + fromUserId: z.string(), + toUserId: z.string(), + text: z.string(), + sentAt: z.string(), + }), z.object({ type: z.literal("chat.typing"), userId: z.string(),