diff --git a/assets/openapi.json b/assets/openapi.json index 230af20d7..9cf9ac7b7 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index d1017ec4a..71631da3f 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ 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 000000000..1fc91b081 --- /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 . +*/ + +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 6b18e2c98..d8ff0f102 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 766abd13f..5d1b9fed9 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 { 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 { }, }); - 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 { !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 34732c381..be52450a8 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 a46c8f10f..b6144f7f8 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 000000000..729922240 --- /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 . +*/ + +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 603151e5e..545e3e554 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 1d52fa016..13ce278e9 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 c38f60abd..5dea9bcfd 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 d72a8e213..4e25e4cbd 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 000000000..e3690dacc --- /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 { + 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 { + 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 000000000..3a2737eac --- /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 { + 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 { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_bb3af7f695d50083e6523290d41"`); + } +}