diff --git a/assets/openapi.json b/assets/openapi.json index d641fe628..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 46a36939b..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/index.ts b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts index 934efc316..7058bd3ce 100644 --- a/src/api/routes/channels/#channel_id/messages/#message_id/index.ts +++ b/src/api/routes/channels/#channel_id/messages/#message_id/index.ts @@ -35,7 +35,7 @@ import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; import multer from "multer"; import { handleMessage, postHandleMessage, route } from "../../../../../util"; -import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema } from "@spacebar/schemas"; +import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema, ChannelType } from "@spacebar/schemas"; const router = Router({ mergeParams: true }); // TODO: message content/embed string length limit @@ -293,6 +293,10 @@ router.delete( const channel = await Channel.findOneOrFail({ where: { id: channel_id }, }); + if (channel.type === ChannelType.GUILD_PUBLIC_THREAD) { + if (channel.message_count !== undefined) channel.message_count--; + channel.save(); //Save async, it's fine + } const message = await Message.findOneOrFail({ where: { id: message_id }, }); 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..44e9fc357 --- /dev/null +++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts @@ -0,0 +1,114 @@ +/* + 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, Recipient } 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 recipient = Recipient.create({ channel_id: message.id, user }); + + 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, + recipients: [], + 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 }, + ); + + recipient.save(); + + 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..00241149e 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -41,6 +41,7 @@ import { stringGlobToRegexp, uploadFile, User, + Recipient, } from "@spacebar/util"; import { Request, Response, Router } from "express"; import { HTTPError } from "lambert-server"; @@ -59,6 +60,7 @@ import { Reaction, ReadStateType, RelationshipType, + ChannelType, } from "@spacebar/schemas"; const router: Router = Router({ mergeParams: true }); @@ -137,6 +139,11 @@ router.get( sticker_items: true, attachments: true, }, + thread: { + recipients: { + user: true, + }, + }, }, }; @@ -246,6 +253,7 @@ router.get( return x; }); + //console.log(ret); await Promise.all( ret @@ -409,8 +417,6 @@ router.post( //@ts-ignore dont care2 message.edited_timestamp = null; - channel.last_message_id = message.id; - if (channel.isDm()) { const channel_dto = await DmChannelDTO.from(channel); @@ -464,11 +470,20 @@ router.post( data: message, } as MessageCreateEvent), message.guild_id ? Member.update({ id: req.user_id, guild_id: message.guild_id }, { last_message_id: message.id }) : null, - channel.save(), ]); // no await as it shouldnt block the message send function and silently catch error postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); + if (channel.type === ChannelType.GUILD_PUBLIC_THREAD && channel.recipients && !channel.recipients.find((_) => _.user.id === req.user_id)) { + const rec = Recipient.create({ + channel_id: channel.id, + user_id: req.user_id, + }); + channel.recipients.push(rec); + rec.save().then(() => { + channel.save(); + }); + } return res.json( message.withSignedAttachments( diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 766abd13f..c81de300d 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -106,6 +106,15 @@ export async function handleMessage(opts: MessageOptions): Promise { components: opts.components ?? undefined, // Fix Discord-Go? }); const ephermal = (message.flags & (1 << 6)) !== 0; + if (!ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) { + const rep = Channel.getRepository(); + console.log(channel.id); + await rep.increment({ id: channel.id }, "message_count", 1); + await rep.increment({ id: channel.id }, "total_message_sent", 1); + } + if (!ephermal) { + channel.last_message_id = message.id; + } if (cloudAttachments && cloudAttachments.length > 0) { console.log("[Message] Processing attachments for message", message.id, ":", message.attachments); @@ -234,7 +243,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 +264,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 +292,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/channels/Channel.ts b/src/schemas/api/channels/Channel.ts index 2d11bf803..4bfe3b2f4 100644 --- a/src/schemas/api/channels/Channel.ts +++ b/src/schemas/api/channels/Channel.ts @@ -36,6 +36,14 @@ export enum ChannelPermissionOverwriteType { member = 1, group = 2, } +export interface threadMetadata { + archived: boolean; + auto_archive_duration: number; + archive_timestamp: string; + locked: boolean; + invitable?: boolean; + create_timestamp: string; //Discord docs say this is optional, but it's only for after a certain date so it's not +} export interface DMChannel extends Omit { type: ChannelType.DM | ChannelType.GROUP_DM; 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 e978a9c6d..5ec84acfa 100644 --- a/src/util/entities/Channel.ts +++ b/src/util/entities/Channel.ts @@ -31,7 +31,7 @@ import { User } from "./User"; import { VoiceState } from "./VoiceState"; import { Webhook } from "./Webhook"; import { Member } from "./Member"; -import { ChannelPermissionOverwrite, ChannelPermissionOverwriteType, ChannelType, PublicUserProjection } from "@spacebar/schemas"; +import { ChannelPermissionOverwrite, ChannelPermissionOverwriteType, ChannelType, PublicUserProjection, threadMetadata } from "@spacebar/schemas"; @Entity({ name: "channels", @@ -152,6 +152,18 @@ export class Channel extends BaseClass { @Column({ nullable: true }) default_thread_rate_limit_per_user?: number = 0; + @Column({ type: "simple-json", nullable: true }) + thread_metadata?: threadMetadata; + + @Column({ nullable: true }) + member_count?: number; + + @Column({ nullable: true }) + message_count?: number; + + @Column({ nullable: true }) + total_message_sent?: number; + /** Must be calculated Channel.calculatePosition */ position: number; @@ -203,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: @@ -532,6 +545,7 @@ export class Channel extends BaseClass { user_limit: this.user_limit || undefined, rate_limit_per_user: this.rate_limit_per_user || undefined, owner_id: this.owner_id || undefined, + ...(this.type === ChannelType.GUILD_PUBLIC_THREAD && this.recipients ? { member_ids_preview: this.recipients.map((_) => _.user.id) } : {}), }; } } diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index 508ffd8ca..cd18fb6b7 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -48,6 +48,16 @@ export class Message extends BaseClass { }) channel: Channel; + @Column({ nullable: true }) + @RelationId((message: Message) => message.thread) + thread_id?: string; + + @JoinColumn({ name: "thread_id" }) + @ManyToOne(() => Channel, { + onDelete: "CASCADE", + }) + thread?: Channel; + @Column({ nullable: true }) @RelationId((message: Message) => message.guild) guild_id?: string; @@ -234,6 +244,7 @@ export class Message extends BaseClass { poll: this.poll ?? undefined, content: this.content ?? "", pinned: this.pinned, + thread: this.thread ? this.thread.toJSON() : this.thread, }; } 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"`); + } +} diff --git a/src/util/migrations/postgres/1764609924756-threads.ts b/src/util/migrations/postgres/1764609924756-threads.ts new file mode 100644 index 000000000..efa2ce7f4 --- /dev/null +++ b/src/util/migrations/postgres/1764609924756-threads.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Threads1764609924756 implements MigrationInterface { + name = "Threads1764609924756"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_bb3af7f695d50083e6523290d41"`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "total_message_sent"`); + await queryRunner.query(`ALTER TABLE "channels" ADD "total_messsage_sent" integer`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "thread_metadata" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "member_count" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "message_count" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "message_count" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "member_count" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "thread_metadata" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "total_messsage_sent"`); + await queryRunner.query(`ALTER TABLE "channels" ADD "total_message_sent" integer NOT NULL`); + await queryRunner.query( + `ALTER TABLE "messages" ADD CONSTRAINT "FK_bb3af7f695d50083e6523290d41" FOREIGN KEY ("thread_id") REFERENCES "channels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/src/util/migrations/postgres/1764610059742-threadtypo.ts b/src/util/migrations/postgres/1764610059742-threadtypo.ts new file mode 100644 index 000000000..2fe2a985a --- /dev/null +++ b/src/util/migrations/postgres/1764610059742-threadtypo.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Threadtypo1764610059742 implements MigrationInterface { + name = "Threadtypo1764610059742"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "messages" DROP CONSTRAINT "FK_bb3af7f695d50083e6523290d41"`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "total_message_sent"`); + await queryRunner.query(`ALTER TABLE "channels" ADD "total_messsage_sent" integer`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "thread_metadata" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "member_count" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "message_count" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "message_count" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "member_count" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" ALTER COLUMN "thread_metadata" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "channels" DROP COLUMN "total_messsage_sent"`); + await queryRunner.query(`ALTER TABLE "channels" ADD "total_message_sent" integer NOT NULL`); + await queryRunner.query( + `ALTER TABLE "messages" ADD CONSTRAINT "FK_bb3af7f695d50083e6523290d41" FOREIGN KEY ("thread_id") REFERENCES "channels"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +}