Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
table:
name: chat_message_edits
schema: public
object_relationships:
- name: author
using:
foreign_key_constraint_on: author_steam_id
select_permissions:
- role: match_organizer
permission:
columns:
- id
- message_id
- room_type
- room_id
- author_steam_id
- previous_message
- new_message
- message_created_at
- edited_at
filter: {}
allow_aggregations: true
comment: What website chat said before its author edited it. Written only by the API.
- role: moderator
permission:
columns:
- id
- message_id
- room_type
- room_id
- author_steam_id
- previous_message
- new_message
- message_created_at
- edited_at
filter:
room_type:
_neq: organizers
allow_aggregations: true
comment: The organizers' room is closed to moderators, and so is its evidence.
1 change: 1 addition & 0 deletions hasura/metadata/databases/default/tables/tables.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- "!include public_awards.yaml"
- "!include public_broadcast_huds.yaml"
- "!include public_chat_message_deletions.yaml"
- "!include public_chat_message_edits.yaml"
- "!include public_chat_read_state.yaml"
- "!include public_clip_render_jobs.yaml"
- "!include public_custom_pages.yaml"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.direct_messages
DROP COLUMN IF EXISTS edited_at;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.direct_messages
ADD COLUMN IF NOT EXISTS edited_at timestamptz;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.chat_message_edits;
20 changes: 20 additions & 0 deletions hasura/migrations/default/1888000000250_chat_message_edits/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS public.chat_message_edits (
id uuid NOT NULL DEFAULT gen_random_uuid(),
message_id uuid NOT NULL,
room_type text NOT NULL,
room_id text NOT NULL,
author_steam_id bigint REFERENCES public.players (steam_id)
ON UPDATE CASCADE ON DELETE SET NULL,
previous_message text NOT NULL,
new_message text NOT NULL,
message_created_at timestamptz,
edited_at timestamptz NOT NULL DEFAULT now(),

PRIMARY KEY (id)
);

CREATE INDEX IF NOT EXISTS chat_message_edits_message_id_idx
ON public.chat_message_edits (message_id);

CREATE INDEX IF NOT EXISTS chat_message_edits_author_idx
ON public.chat_message_edits (author_steam_id, edited_at DESC);
176 changes: 176 additions & 0 deletions src/chat/chat.gateway.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,3 +461,179 @@ describe("ChatGateway lobby:delete", () => {
]);
});
});

describe("ChatGateway lobby:edit", () => {
const MESSAGE_ID = "3f0c1d2e-4b5a-4c6d-8e7f-9a0b1c2d3e4f";

let chat: { editMessage: jest.Mock; sendChatToServer: jest.Mock };
let gateway: ChatGateway;

const client = (user: any = { steam_id: "1", name: "Luke", role: "user" }) =>
({ id: "client-1", user, send: jest.fn() }) as any;

const sent = (socket: { send: jest.Mock }) =>
socket.send.mock.calls.map(([raw]) => JSON.parse(raw));

const edit = (overrides: Record<string, unknown> = {}) => ({
id: "m-1",
type: ChatLobbyType.Match,
messageId: MESSAGE_ID,
message: "fixed",
...overrides,
});

beforeEach(() => {
chat = {
editMessage: jest.fn().mockResolvedValue({
edited: true,
message: "fixed",
edited_at: "2026-01-01T00:00:00.000Z",
}),
sendChatToServer: jest.fn(),
};
gateway = new ChatGateway(chat as any);
});

it("ignores a socket that has not signed in", async () => {
const socket = client(null);

await gateway.editMessage(edit({ requestId: "r-1" }) as any, socket);

expect(chat.editMessage).not.toHaveBeenCalled();
expect(socket.send).not.toHaveBeenCalled();
});

it.each([
["an unknown lobby type", { type: "global" }],
["a room id that is not a string", { id: 1 }],
["a missing message id", { messageId: undefined }],
])("ignores %s", async (_, overrides) => {
const socket = client();

await gateway.editMessage(edit(overrides) as any, socket);

expect(chat.editMessage).not.toHaveBeenCalled();
expect(socket.send).not.toHaveBeenCalled();
});

it.each([
["only whitespace", " \n "],
["not a string", 5],
])("answers an edit that is %s with invalid", async (_, message) => {
const socket = client();

await gateway.editMessage(
edit({ message, requestId: "r-4" }) as any,
socket,
);

expect(chat.editMessage).not.toHaveBeenCalled();
expect(sent(socket)).toEqual([
{
event: "chat:error",
data: { code: ChatErrorCode.Invalid, action: "edit", requestId: "r-4" },
},
]);
});

it("ignores a missing payload", async () => {
await gateway.editMessage(undefined as any, client());

expect(chat.editMessage).not.toHaveBeenCalled();
});

it("asks the service to edit as the signed in player, with the trimmed text", async () => {
await gateway.editMessage(edit({ message: " fixed " }) as any, client());

expect(chat.editMessage).toHaveBeenCalledWith(
ChatLobbyType.Match,
"m-1",
MESSAGE_ID,
expect.objectContaining({ steam_id: "1" }),
"fixed",
);
});

it("refuses an edit over the limit without asking the service", async () => {
const socket = client();

await gateway.editMessage(
edit({
message: "a".repeat(ChatService.MAX_MESSAGE_LENGTH + 1),
requestId: "r-1",
}) as any,
socket,
);

expect(chat.editMessage).not.toHaveBeenCalled();
expect(sent(socket)).toEqual([
{
event: "chat:error",
data: {
code: ChatErrorCode.TooLong,
action: "edit",
max: 2000,
requestId: "r-1",
},
},
]);
});

it("acks an edit under the requestId it came with, with what the server stored", async () => {
chat.editMessage.mockResolvedValue({
edited: true,
message: "fixed as stored",
edited_at: "2026-02-03T04:05:06.789Z",
});
const socket = client();

await gateway.editMessage(edit({ requestId: "r-2" }) as any, socket);

expect(sent(socket)).toEqual([
{
event: "chat:ack",
data: {
requestId: "r-2",
messageId: MESSAGE_ID,
action: "edit",
message: "fixed as stored",
edited_at: "2026-02-03T04:05:06.789Z",
},
},
]);
});

it("stays quiet for an edit without a requestId", async () => {
const socket = client();

await gateway.editMessage(edit() as any, socket);

expect(socket.send).not.toHaveBeenCalled();
});

it.each([
ChatErrorCode.NotAllowed,
ChatErrorCode.NotFound,
ChatErrorCode.WindowClosed,
ChatErrorCode.Gagged,
])("reports %s under the requestId it came with", async (code) => {
chat.editMessage.mockResolvedValue({ edited: false, code });
const socket = client();

await gateway.editMessage(edit({ requestId: "r-3" }) as any, socket);

expect(sent(socket)).toEqual([
{
event: "chat:error",
data: { code, action: "edit", requestId: "r-3" },
},
]);
});

it("never relays an edit to the game server", async () => {
await gateway.editMessage(edit() as any, client());

expect(chat.editMessage).toHaveBeenCalled();
expect(chat.sendChatToServer).not.toHaveBeenCalled();
});
});
60 changes: 59 additions & 1 deletion src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,63 @@ export class ChatGateway {
}
}

@SubscribeMessage("lobby:edit")
async editMessage(
@MessageBody()
data: {
id: string;
type: ChatLobbyType;
messageId: string;
message: unknown;
requestId?: string;
},
@ConnectedSocket() client: FiveStackWebSocketClient,
) {
if (!client.user) {
return;
}

if (
!ChatGateway.isLobbyType(data?.type) ||
typeof data.id !== "string" ||
typeof data.messageId !== "string"
) {
return;
}

const requestId =
typeof data.requestId === "string" ? data.requestId : undefined;

const parsed = ChatService.messageText(data.message);

// Unlike a send, clearing the box is an ordinary thing to do to an edit,
// so the client is told rather than left waiting.
if ("error" in parsed) {
this.sendError(client, "edit", parsed.error, requestId);
return;
}

const result = await this.chat.editMessage(
data.type,
data.id,
data.messageId,
client.user,
parsed.text,
);

if (result.edited === false) {
this.sendError(client, "edit", result.code, requestId);
return;
}

if (requestId) {
this.sendAck(client, "edit", requestId, data.messageId, {
message: result.message,
edited_at: result.edited_at,
});
}
}

private static isLobbyType(value: unknown): value is ChatLobbyType {
return Object.values(ChatLobbyType).includes(value as ChatLobbyType);
}
Expand Down Expand Up @@ -225,11 +282,12 @@ export class ChatGateway {
action: ChatAction,
requestId: string,
messageId: string,
extra: Record<string, string> = {},
) {
client.send(
JSON.stringify({
event: "chat:ack",
data: { requestId, messageId, action },
data: { ...extra, requestId, messageId, action },
}),
);
}
Expand Down
Loading
Loading