summary refs log tree commit diff
diff options
context:
space:
mode:
authorMathMan05 <mathmanrm@gmail.com>2025-12-01 15:49:48 -0600
committerRory& <root@rory.gay>2026-01-28 21:46:24 +0100
commit00b591abf047c1665a6ea75c20bd449d605a1a84 (patch)
tree41155f0143f43236533e64d9eafd75f3e4463394
parentsechema changes+migrations (diff)
downloadserver-ts-00b591abf047c1665a6ea75c20bd449d605a1a84.tar.xz
thread API start
-rw-r--r--assets/openapi.json74
-rw-r--r--assets/schemas.json26
-rw-r--r--src/api/routes/channels/#channel_id/messages/#message_id/threads.ts108
-rw-r--r--src/api/routes/channels/#channel_id/messages/index.ts1
-rw-r--r--src/api/util/handlers/Message.ts20
-rw-r--r--src/gateway/opcodes/Identify.ts5
-rw-r--r--src/schemas/api/messages/Message.ts1
-rw-r--r--src/schemas/uncategorised/MessageThreadCreationSchema.ts25
-rw-r--r--src/schemas/uncategorised/index.ts1
-rw-r--r--src/util/entities/Channel.ts1
-rw-r--r--src/util/entities/Message.ts2
-rw-r--r--src/util/interfaces/Event.ts1
-rw-r--r--src/util/migration/postgres/1764612754204-threads.ts21
-rw-r--r--src/util/migration/postgres/1764622231800-threadGoof.ts15
14 files changed, 294 insertions, 7 deletions
diff --git a/assets/openapi.json b/assets/openapi.json

index 230af20d..9cf9ac7b 100644 --- a/assets/openapi.json +++ b/assets/openapi.json
@@ -6412,6 +6412,29 @@ "enabled" ] }, + "MessageThreadCreationSchema": { + "type": "object", + "properties": { + "auto_archive_duration": { + "type": "integer" + }, + "rate_limit_per_user": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "integer" + }, + "location": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, "VoiceIdentifySchema": { "type": "object", "properties": { @@ -10134,6 +10157,7 @@ 16, 19, 20, + 21, 41, 42, 43, @@ -23238,6 +23262,56 @@ ] } }, + "/channels/{channel_id}/messages/{message_id}/threads/": { + "post": { + "security": [ + { + "bearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageThreadCreationSchema" + } + } + } + }, + "responses": { + "200": { + "description": "No description available" + }, + "403": { + "description": "No description available" + } + }, + "parameters": [ + { + "name": "channel_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "channel_id" + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "message_id" + } + ], + "tags": [ + "channels" + ] + } + }, "/channels/{channel_id}/messages/{message_id}/reactions/": { "delete": { "x-permission-required": "MANAGE_MESSAGES", diff --git a/assets/schemas.json b/assets/schemas.json
index d1017ec4..71631da3 100644 --- a/assets/schemas.json +++ b/assets/schemas.json
@@ -6841,6 +6841,31 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" }, + "MessageThreadCreationSchema": { + "type": "object", + "properties": { + "auto_archive_duration": { + "type": "integer" + }, + "rate_limit_per_user": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "integer" + }, + "location": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "name" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + }, "VoiceIdentifySchema": { "type": "object", "properties": { @@ -10711,6 +10736,7 @@ 16, 19, 20, + 21, 41, 42, 43, diff --git a/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts new file mode 100644
index 00000000..1fc91b08 --- /dev/null +++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts
@@ -0,0 +1,108 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +import { route, sendMessage } from "@spacebar/api"; +import { Message, Channel, emitEvent, User, MessageUpdateEvent } from "@spacebar/util"; +import { MessageThreadCreationSchema, ChannelType, MessageType } from "@spacebar/schemas"; + +import { Request, Response, Router } from "express"; + +const router = Router({ mergeParams: true }); + +// TODO: public read receipts & privacy scoping +// TODO: send read state event to all channel members +// TODO: advance-only notification cursor + +router.post( + "/", + route({ + requestBody: "MessageThreadCreationSchema", + responses: { + 200: {}, + 403: {}, + }, + }), + async (req: Request, res: Response) => { + const { message_id, channel_id } = req.params; + const body = req.body as MessageThreadCreationSchema; + const message = await Message.findOneOrFail({ + where: { id: message_id, channel_id }, + relations: ["guild"], + }); + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + const thread = await Channel.createChannel( + { + id: message.id, + owner: user, + parent: channel, + guild: channel.guild, + member_count: 1, + message_count: 1, + total_message_sent: 1, + name: body.name, + guild_id: channel.guild_id, + rate_limit_per_user: body.rate_limit_per_user, + type: ChannelType.GUILD_PUBLIC_THREAD, + thread_metadata: { + archived: false, + auto_archive_duration: body.auto_archive_duration || channel.default_auto_archive_duration || 4320, + archive_timestamp: new Date().toISOString(), + locked: false, + create_timestamp: new Date().toISOString(), + }, + }, + void 0, + { skipPermissionCheck: true, keepId: true, skipEventEmit: true }, + ); + message.thread = thread; + message.flags ||= 1 << 5; + await sendMessage({ + channel_id: thread.id, + type: MessageType.THREAD_STARTER_MESSAGE, + message_reference: { + message_id: message.id, + channel_id: channel.id, + guild_id: channel.guild_id, + }, + author_id: user.id, + }); + await Promise.all([ + emitEvent({ + event: "THREAD_CREATE", + channel_id, + data: { + ...thread.toJSON(), + newly_created: true, + }, + }), + message.save(), + emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: message.channel_id, + data: message.toJSON(), + } as MessageUpdateEvent), + ]); + + return res.json(thread.toJSON()); + }, +); + +export default router; diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts
index 6b18e2c9..d8ff0f10 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts
@@ -137,6 +137,7 @@ router.get( sticker_items: true, attachments: true, }, + thread: true, }, }; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 766abd13..5d1b9fed 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts
@@ -234,7 +234,8 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> { if (opts.message_reference.type != 1) { if (opts.message_reference.guild_id !== channel.guild_id) throw new HTTPError("You can only reference messages from this guild"); - if (opts.message_reference.channel_id !== opts.channel_id) throw new HTTPError("You can only reference messages from this channel"); + if (opts.message_reference.channel_id !== opts.channel_id && opts.type !== MessageType.THREAD_STARTER_MESSAGE) + throw new HTTPError("You can only reference messages from this channel"); } message.message_reference = opts.message_reference; @@ -254,14 +255,22 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> { }, }); - if (message.referenced_message.channel_id && message.referenced_message.channel_id !== opts.message_reference.channel_id) + if ( + message.referenced_message.channel_id && + message.referenced_message.channel_id !== opts.message_reference.channel_id && + opts.type !== MessageType.THREAD_STARTER_MESSAGE + ) throw new HTTPError("Referenced message not found in the specified channel", 404); - if (message.referenced_message.guild_id && message.referenced_message.guild_id !== opts.message_reference.guild_id) + if ( + message.referenced_message.guild_id && + message.referenced_message.guild_id !== opts.message_reference.guild_id && + opts.type !== MessageType.THREAD_STARTER_MESSAGE + ) throw new HTTPError("Referenced message not found in the specified channel", 404); } /** Q: should be checked if the referenced message exists? ANSWER: NO otherwise backfilling won't work **/ - message.type = MessageType.REPLY; + if (MessageType.THREAD_STARTER_MESSAGE !== message.type) message.type = MessageType.REPLY; } } @@ -274,7 +283,8 @@ export async function handleMessage(opts: MessageOptions): Promise<Message> { !opts.sticker_ids?.length && !opts.poll && !opts.components?.length && - opts.message_reference?.type != 1 + opts.message_reference?.type != 1 && + opts.type !== MessageType.THREAD_STARTER_MESSAGE ) { console.log("[Message] Rejecting empty message:", opts, message); throw new HTTPError("Empty messages are not allowed", 50006); diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index 34732c38..be52450a 100644 --- a/src/gateway/opcodes/Identify.ts +++ b/src/gateway/opcodes/Identify.ts
@@ -296,7 +296,10 @@ export async function onIdentify(this: WebSocket, data: Payload) { ] = await Promise.all([ timePromise(() => Channel.find({ - where: { guild_id: In(guildIds) }, + where: { + guild_id: In(guildIds), + type: Not(ChannelType.GUILD_PUBLIC_THREAD), + }, order: { guild_id: "ASC" }, }), ), diff --git a/src/schemas/api/messages/Message.ts b/src/schemas/api/messages/Message.ts
index a46c8f10..b6144f7f 100644 --- a/src/schemas/api/messages/Message.ts +++ b/src/schemas/api/messages/Message.ts
@@ -39,6 +39,7 @@ export enum MessageType { ENCRYPTED = 16, REPLY = 19, APPLICATION_COMMAND = 20, // application command or self command invocation + THREAD_STARTER_MESSAGE = 21, ROUTE_ADDED = 41, // custom message routing: new route affecting that channel ROUTE_DISABLED = 42, // custom message routing: given route no longer affecting that channel SELF_COMMAND_SCRIPT = 43, // self command scripts diff --git a/src/schemas/uncategorised/MessageThreadCreationSchema.ts b/src/schemas/uncategorised/MessageThreadCreationSchema.ts new file mode 100644
index 00000000..72992224 --- /dev/null +++ b/src/schemas/uncategorised/MessageThreadCreationSchema.ts
@@ -0,0 +1,25 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +*/ + +export interface MessageThreadCreationSchema { + auto_archive_duration?: number; + rate_limit_per_user?: number; + name: string; + type?: number; + location?: string; //0 clue what this means lol +} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts
index 603151e5..545e3e55 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts
@@ -91,3 +91,4 @@ export * from "./WebhookCreateSchema"; export * from "./WebhookExecuteSchema"; export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; +export * from "./MessageThreadCreationSchema"; diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index 1d52fa01..13ce278e 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts
@@ -215,6 +215,7 @@ export class Channel extends BaseClass { } switch (channel.type) { + case ChannelType.GUILD_PUBLIC_THREAD: case ChannelType.GUILD_TEXT: case ChannelType.GUILD_NEWS: case ChannelType.GUILD_VOICE: diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts
index c38f60ab..5dea9bcf 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts
@@ -52,7 +52,7 @@ export class Message extends BaseClass { @RelationId((message: Message) => message.thread) thread_id?: string; - @JoinColumn({ name: "channel_id" }) + @JoinColumn({ name: "thread_id" }) @ManyToOne(() => Channel, { onDelete: "CASCADE", }) diff --git a/src/util/interfaces/Event.ts b/src/util/interfaces/Event.ts
index d72a8e21..4e25e4cb 100644 --- a/src/util/interfaces/Event.ts +++ b/src/util/interfaces/Event.ts
@@ -775,6 +775,7 @@ export type EVENT = | "RELATIONSHIP_UPDATE" | "SESSIONS_REPLACE" | "USER_SETTINGS_PROTO_UPDATE" + | "THREAD_CREATE" | CUSTOMEVENTS; export type CUSTOMEVENTS = "INVALIDATED" | "RATELIMIT"; diff --git a/src/util/migration/postgres/1764612754204-threads.ts b/src/util/migration/postgres/1764612754204-threads.ts new file mode 100644
index 00000000..e3690dac --- /dev/null +++ b/src/util/migration/postgres/1764612754204-threads.ts
@@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Threads1764612754204 implements MigrationInterface { + name = "Threads1764612754204"; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "messages" ADD "thread_id" character varying`); + await queryRunner.query(`ALTER TABLE "channels" ADD "thread_metadata" text`); + await queryRunner.query(`ALTER TABLE "channels" ADD "member_count" integer`); + await queryRunner.query(`ALTER TABLE "channels" ADD "message_count" integer`); + await queryRunner.query(`ALTER TABLE "channels" ADD "total_message_sent" integer`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "total_message_sent"`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "message_count"`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "member_count"`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "thread_metadata"`); + await queryRunner.query(`ALTER TABLE "messages" DROP COLUMN "thread_id"`); + } +} diff --git a/src/util/migration/postgres/1764622231800-threadGoof.ts b/src/util/migration/postgres/1764622231800-threadGoof.ts new file mode 100644
index 00000000..3a2737ea --- /dev/null +++ b/src/util/migration/postgres/1764622231800-threadGoof.ts
@@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ThreadGoof1764622231800 implements MigrationInterface { + name = "ThreadGoof1764622231800"; + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query( + `ALTER TABLE "messages" ADD CONSTRAINT "FK_bb3af7f695d50083e6523290d41" FOREIGN KEY ("thread_id") REFERENCES "channels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_bb3af7f695d50083e6523290d41"`); + } +}