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 index 14d921356..c9ff21490 100644 --- a/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts +++ b/src/api/routes/channels/#channel_id/messages/#message_id/threads.ts @@ -92,6 +92,16 @@ router.post( }, author_id: user.id, }); + sendMessage({ + channel_id: channel.id, + type: MessageType.THREAD_CREATED, + content: thread.name, + message_reference: { + channel_id: channel.id, + guild_id: channel.guild_id, + }, + author_id: user.id, + }); await Promise.all([ emitEvent({ event: "THREAD_CREATE", diff --git a/src/api/routes/channels/#channel_id/messages/index.ts b/src/api/routes/channels/#channel_id/messages/index.ts index a5913e52c..25c542f73 100644 --- a/src/api/routes/channels/#channel_id/messages/index.ts +++ b/src/api/routes/channels/#channel_id/messages/index.ts @@ -270,10 +270,10 @@ router.get( // polyfill message references for old messages await Promise.all( ret - .filter((msg) => msg.message_reference && !msg.referenced_message?.id) + .filter((msg) => msg.message_reference && !msg.referenced_message?.id && msg.message_reference.message_id) .map(async (msg) => { const whereOptions: { id: string; guild_id?: string; channel_id?: string } = { - id: msg.message_reference!.message_id, + id: msg.message_reference!.message_id as string, }; if (msg.message_reference!.guild_id) whereOptions.guild_id = msg.message_reference!.guild_id; if (msg.message_reference!.channel_id) whereOptions.channel_id = msg.message_reference!.channel_id; @@ -290,7 +290,7 @@ router.get( ); // TODO: config max upload size -const messageUpload = multer({ +export const messageUpload = multer({ limits: { fileSize: Config.get().limits.message.maxAttachmentSize, fields: 10, diff --git a/src/api/routes/channels/#channel_id/threads.ts b/src/api/routes/channels/#channel_id/threads.ts new file mode 100644 index 000000000..430716b51 --- /dev/null +++ b/src/api/routes/channels/#channel_id/threads.ts @@ -0,0 +1,173 @@ +/* + 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 { handleMessage, postHandleMessage, route, sendMessage } from "@spacebar/api"; +import { Message, Channel, emitEvent, User, MessageUpdateEvent, Recipient, uploadFile, Attachment, Member, ReadState, MessageCreateEvent } from "@spacebar/util"; +import { MessageThreadCreationSchema, ChannelType, MessageType, ThreadCreationSchema, MessageCreateAttachment, MessageCreateCloudAttachment } from "@spacebar/schemas"; + +import { Request, Response, Router } from "express"; +import { messageUpload } from "./messages"; + +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( + "/", + messageUpload.any(), + (req, res, next) => { + if (req.body.payload_json) { + req.body = JSON.parse(req.body.payload_json); + } + + next(); + }, + route({ + requestBody: "ThreadCreationSchema", + permission: "CREATE_PUBLIC_THREADS", + responses: { + 200: {}, + 403: {}, + }, + }), + async (req: Request, res: Response) => { + // TODO: check for differences with https://github.com/spacebarchat/server/pull/876/files#diff-95be9c4cdfd8ba6f67361cd40b9abc8226b35d83e2bb44bf5b4682f1d66155e9 + const { channel_id } = req.params; + const body = req.body as ThreadCreationSchema; + + const channel = await Channel.findOneOrFail({ + where: { id: channel_id }, + }); + const user = await User.findOneOrFail({ where: { id: req.user_id } }); + + const thread = await Channel.createChannel( + { + owner: user, + parent: channel, + guild: channel.guild, + member_count: 1, + message_count: body.message ? 1 : 0, + total_message_sent: body.message ? 1 : 0, + name: body.name, + guild_id: channel.guild_id, + rate_limit_per_user: body.rate_limit_per_user, + type: body.type, + 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 }, + ); + const recipient = Recipient.create({ channel_id: channel.id, user }); + + await recipient.save(); + + await Promise.all([ + emitEvent({ + event: "THREAD_CREATE", + channel_id, + data: { + ...thread.toJSON(), + newly_created: true, + }, + }), + ]); + if (body.type !== ChannelType.GUILD_PRIVATE_THREAD) + sendMessage({ + channel_id: channel.id, + type: MessageType.THREAD_CREATED, + content: thread.name, + message_reference: { + channel_id: channel.id, + guild_id: channel.guild_id, + }, + author_id: user.id, + }); + if (body.message) { + const files = (req.files as Express.Multer.File[]) ?? []; + const attachments: (Attachment | MessageCreateAttachment | MessageCreateCloudAttachment)[] = body.message.attachments ?? []; + for (const currFile of files) { + try { + const file = await uploadFile(`/attachments/${channel.id}`, currFile); + attachments.push(Attachment.create({ ...file, proxy_url: file.url })); + } catch (error) { + return res.status(400).json({ message: error?.toString() }); + } + } + const embeds = body.message.embeds || []; + const message = await handleMessage({ + ...body, + type: 0, + pinned: false, + author_id: req.user_id, + embeds, + channel_id, + attachments, + timestamp: new Date(), + }); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore wrong type but idk why it's mad + message.edited_timestamp = null; + if (message.guild_id) { + // handleMessage will fetch the Member, but only if they are not guild owner. + // have to fetch ourselves otherwise. + if (!message.member) { + message.member = await Member.findOneOrFail({ + where: { id: req.user_id, guild_id: message.guild_id }, + relations: { roles: true }, + }); + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + message.member.roles = message.member.roles.filter((x) => x.id != x.guild_id).map((x) => x.id); + } + let read_state = await ReadState.findOne({ + where: { user_id: req.user_id, channel_id }, + }); + if (!read_state) read_state = ReadState.create({ user_id: req.user_id, channel_id }); + read_state.last_message_id = message.id; + //It's a little more complicated than this but this'll do + read_state.mention_count = 0; + + await Promise.all([ + read_state.save(), + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: channel_id, + data: message, + } as MessageCreateEvent), + message.guild_id ? Member.update({ id: req.user_id, guild_id: message.guild_id }, { last_message_id: message.id }) : null, + ]); + postHandleMessage(message).catch((e) => console.error("[Message] post-message handler failed", e)); + } + + return res.json(thread.toJSON()); + }, +); + +export default router; diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index c81de300d..22ebf60ed 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -248,38 +248,40 @@ export async function handleMessage(opts: MessageOptions): Promise { } message.message_reference = opts.message_reference; - message.referenced_message = await Message.findOneOrFail({ - where: { - id: opts.message_reference.message_id, - }, - relations: { - author: true, - webhook: true, - application: true, - mentions: true, - mention_roles: true, - mention_channels: true, - sticker_items: true, - attachments: true, - }, - }); + if (message.message_reference.message_id) { + message.referenced_message = await Message.findOneOrFail({ + where: { + id: opts.message_reference.message_id, + }, + relations: { + author: true, + webhook: true, + application: true, + mentions: true, + mention_roles: true, + mention_channels: true, + sticker_items: true, + attachments: true, + }, + }); - 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 && - opts.type !== MessageType.THREAD_STARTER_MESSAGE - ) - throw new HTTPError("Referenced message not found in the specified channel", 404); + 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 && + 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 **/ - if (MessageType.THREAD_STARTER_MESSAGE !== message.type) message.type = MessageType.REPLY; + if (MessageType.THREAD_STARTER_MESSAGE !== message.type && MessageType.THREAD_CREATED !== message.type) message.type = MessageType.REPLY; } } diff --git a/src/schemas/uncategorised/MessageActivity.ts b/src/schemas/uncategorised/MessageActivity.ts new file mode 100644 index 000000000..bebd29702 --- /dev/null +++ b/src/schemas/uncategorised/MessageActivity.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 MessageActivity { + type: 1 | 3 | 5; + session_id?: string; + party_id?: string; + name_override?: string; + icon_override?: string; +} diff --git a/src/schemas/uncategorised/MessageCreateSchema.ts b/src/schemas/uncategorised/MessageCreateSchema.ts index 345be32cf..7c0d0728b 100644 --- a/src/schemas/uncategorised/MessageCreateSchema.ts +++ b/src/schemas/uncategorised/MessageCreateSchema.ts @@ -49,7 +49,7 @@ export interface MessageCreateSchema { replied_user?: boolean; }; message_reference?: { - message_id: string; + message_id?: string; channel_id?: string; guild_id?: string; fail_if_not_exists?: boolean; diff --git a/src/schemas/uncategorised/MessageThreadCreationSchema.ts b/src/schemas/uncategorised/MessageThreadCreationSchema.ts index 0c39adc29..2d0044dbe 100644 --- a/src/schemas/uncategorised/MessageThreadCreationSchema.ts +++ b/src/schemas/uncategorised/MessageThreadCreationSchema.ts @@ -20,4 +20,6 @@ export interface MessageThreadCreationSchema { auto_archive_duration?: number; rate_limit_per_user?: number; name: string; + location?: string; //ignore it + type?: number; } diff --git a/src/schemas/uncategorised/ThreadCreationSchema.ts b/src/schemas/uncategorised/ThreadCreationSchema.ts new file mode 100644 index 000000000..ca1f25e28 --- /dev/null +++ b/src/schemas/uncategorised/ThreadCreationSchema.ts @@ -0,0 +1,47 @@ +/* + 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 { ActionRowComponent, ChannelType, Embed } from "#schemas/api"; +import { MessageActivity } from "./MessageActivity"; +import { MessageCreateAttachment, MessageCreateCloudAttachment } from "./MessageCreateSchema"; + +export interface ThreadCreationSchema { + auto_archive_duration?: number; + rate_limit_per_user?: number; + name: string; + type: ChannelType.GUILD_PUBLIC_THREAD | ChannelType.GUILD_PRIVATE_THREAD; + invitable?: boolean; + applied_tags?: string[]; + location?: string; //Ignore it + message?: { + content?: string; + embeds?: Embed[]; + allowed_mentions?: { + parse?: string[]; + roles?: string[]; + users?: string[]; + replied_user?: boolean; + }; + components?: ActionRowComponent[] | null; + sticker_ids?: string[]; + activity?: MessageActivity; + application_id?: string; + flags?: number; + attachments?: (MessageCreateAttachment | MessageCreateCloudAttachment)[]; + }; +} diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts index 545e3e554..5a0d9ebda 100644 --- a/src/schemas/uncategorised/index.ts +++ b/src/schemas/uncategorised/index.ts @@ -92,3 +92,5 @@ export * from "./WebhookExecuteSchema"; export * from "./WebhookUpdateSchema"; export * from "./WidgetModifySchema"; export * from "./MessageThreadCreationSchema"; +export * from "./ThreadCreationSchema"; +export * from "./MessageActivity"; diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index cd18fb6b7..620f20fc5 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -173,7 +173,7 @@ export class Message extends BaseClass { @Column({ type: "simple-json", nullable: true }) message_reference?: { - message_id: string; + message_id?: string; channel_id?: string; guild_id?: string; type?: number; // 0 = DEFAULT, 1 = FORWARD