diff --git a/assets/openapi.json b/assets/openapi.json index 19686c59..6860c21b 100644 Binary files a/assets/openapi.json and b/assets/openapi.json differ diff --git a/assets/schemas.json b/assets/schemas.json index 018ddaca..39961d30 100644 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/src/api/routes/webhooks/#webhook_id/#token/index.ts b/src/api/routes/webhooks/#webhook_id/#token/index.ts index b47502b4..538ee181 100644 --- a/src/api/routes/webhooks/#webhook_id/#token/index.ts +++ b/src/api/routes/webhooks/#webhook_id/#token/index.ts @@ -1,12 +1,14 @@ -import { handleMessage, route } from "@spacebar/api"; +import { handleMessage, postHandleMessage, route } from "@spacebar/api"; import { Attachment, Config, DiscordApiErrors, FieldErrors, Message, + MessageCreateEvent, Webhook, WebhookExecuteSchema, + emitEvent, uploadFile, } from "@spacebar/util"; import { Request, Response, Router } from "express"; @@ -93,7 +95,11 @@ router.post( }, }), async (req: Request, res: Response) => { + const { wait, thread_id } = req.query; + if (!wait) return res.status(204).send(); + const { webhook_id, token } = req.params; + const body = req.body as WebhookExecuteSchema; const attachments: Attachment[] = []; @@ -200,6 +206,7 @@ router.post( webhook_id: webhook.id, application_id: webhook.application?.id, embeds, + // TODO: Support thread_id/thread_name once threads are implemented channel_id: webhook.channel_id, attachments, timestamp: new Date(), @@ -209,6 +216,22 @@ router.post( message.edited_timestamp = null; webhook.channel.last_message_id = message.id; + + await Promise.all([ + message.save(), + emitEvent({ + event: "MESSAGE_CREATE", + channel_id: webhook.channel_id, + data: message, + } as MessageCreateEvent), + ]); + + // 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), + ); + + return res.json(message); }, ); diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts index 6172a3d0..18616506 100644 --- a/src/api/util/handlers/Message.ts +++ b/src/api/util/handlers/Message.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -41,11 +41,13 @@ import { Sticker, MessageCreateSchema, EmbedCache, + handleFile, } from "@spacebar/util"; import { HTTPError } from "lambert-server"; import { In } from "typeorm"; import { EmbedHandlers } from "@spacebar/api"; import * as Sentry from "@sentry/node"; +import fetch from "node-fetch"; const allow_empty = false; // TODO: check webhook, application, system author, stickers // TODO: embed gifs/videos/images @@ -92,44 +94,89 @@ export async function handleMessage(opts: MessageOptions): Promise { where: { id: opts.application_id }, }); } + + let permission: any; if (opts.webhook_id) { message.webhook = await Webhook.findOneOrFail({ where: { id: opts.webhook_id }, }); - } - const permission = await getPermission( - opts.author_id, - channel.guild_id, - opts.channel_id, - ); - permission.hasThrow("SEND_MESSAGES"); - if (permission.cache.member) { - message.member = permission.cache.member; - } + message.author = (await User.findOne({ + where: { id: opts.webhook_id }, + })) || undefined; - if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); - if (opts.message_reference) { - permission.hasThrow("READ_MESSAGE_HISTORY"); - // code below has to be redone when we add custom message routing - if (message.guild_id !== null) { - const guild = await Guild.findOneOrFail({ - where: { id: channel.guild_id }, + if (!message.author) { + message.author = User.create({ + id: opts.webhook_id, + username: message.webhook.name, + discriminator: "0000", + avatar: message.webhook.avatar, + public_flags: 0, + premium: false, + premium_type: 0, + bot: true, + created_at: new Date(), + verified: true, + rights: "0", + data: { + valid_tokens_since: new Date(), + }, }); - if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { - 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", - ); - } + + await message.author.save(); + } + + if (opts.username) { + message.username = opts.username; + message.author.username = message.username; + } + if (opts.avatar_url) { + const avatarData = await fetch(opts.avatar_url); + const base64 = await avatarData.buffer().then((x) => x.toString("base64")); + + const dataUri = "data:" + avatarData.headers.get("content-type") + ";base64," + base64; + + message.avatar = await handleFile( + `/avatars/${opts.webhook_id}`, + dataUri as string, + ); + console.log(message.avatar); + message.author.avatar = message.avatar; + } + } else { + permission = await getPermission( + opts.author_id, + channel.guild_id, + opts.channel_id, + ); + permission.hasThrow("SEND_MESSAGES"); + if (permission.cache.member) { + message.member = permission.cache.member; + } + + if (opts.tts) permission.hasThrow("SEND_TTS_MESSAGES"); + if (opts.message_reference) { + permission.hasThrow("READ_MESSAGE_HISTORY"); + // code below has to be redone when we add custom message routing + if (message.guild_id !== null) { + const guild = await Guild.findOneOrFail({ + where: { id: channel.guild_id }, + }); + if (!guild.features.includes("CROSS_CHANNEL_REPLIES")) { + 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", + ); + } + } + /** Q: should be checked if the referenced message exists? ANSWER: NO + otherwise backfilling won't work **/ + message.type = MessageType.REPLY; } - /** Q: should be checked if the referenced message exists? ANSWER: NO - otherwise backfilling won't work **/ - message.type = MessageType.REPLY; } // TODO: stickers/activity @@ -172,14 +219,14 @@ export async function handleMessage(opts: MessageOptions): Promise { const role = await Role.findOneOrFail({ where: { id: mention, guild_id: channel.guild_id }, }); - if (role.mentionable || permission.has("MANAGE_ROLES")) { + if (role.mentionable || (opts.webhook_id || permission.has("MANAGE_ROLES"))) { mention_role_ids.push(mention); } }, ), ); - if (permission.has("MENTION_EVERYONE")) { + if (opts.webhook_id || permission.has("MENTION_EVERYONE")) { mention_everyone = !!content.match(EVERYONE_MENTION) || !!content.match(HERE_MENTION); @@ -302,4 +349,6 @@ interface MessageOptions extends MessageCreateSchema { attachments?: Attachment[]; edited_timestamp?: Date; timestamp?: Date; + username?: string; + avatar_url?: string; } diff --git a/src/util/entities/Message.ts b/src/util/entities/Message.ts index b519099a..86238e53 100644 --- a/src/util/entities/Message.ts +++ b/src/util/entities/Message.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -218,6 +218,12 @@ export class Message extends BaseClass { @Column({ type: "simple-json", nullable: true }) components?: MessageComponent[]; + @Column({ nullable: true }) + username?: string; + + @Column({ nullable: true }) + avatar?: string; + toJSON(): Message { return { ...this, @@ -234,7 +240,12 @@ export class Message extends BaseClass { reactions: this.reactions ?? undefined, sticker_items: this.sticker_items ?? undefined, message_reference: this.message_reference ?? undefined, - author: this.author?.toPublicUser() ?? undefined, + author: { + ...this.author?.toPublicUser() ?? undefined, + // Webhooks + username: this.username ?? this.author?.username, + avatar: this.avatar ?? this.author?.avatar, + }, activity: this.activity ?? undefined, application: this.application ?? undefined, components: this.components ?? undefined, diff --git a/src/util/entities/Webhook.ts b/src/util/entities/Webhook.ts index 91498a22..b7fba53a 100644 --- a/src/util/entities/Webhook.ts +++ b/src/util/entities/Webhook.ts @@ -1,17 +1,17 @@ /* 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 . */ @@ -35,7 +35,7 @@ export class Webhook extends BaseClass { type: WebhookType; @Column({ nullable: true }) - name?: string; + name: string; @Column({ nullable: true }) avatar?: string; diff --git a/src/util/migration/mariadb/1721298824927-webhookMessageProperties.ts b/src/util/migration/mariadb/1721298824927-webhookMessageProperties.ts new file mode 100644 index 00000000..ccbe689a --- /dev/null +++ b/src/util/migration/mariadb/1721298824927-webhookMessageProperties.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class WebhookMessageProperties1721298824927 implements MigrationInterface { + name = "WebhookMessageProperties1721298824927"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `messages` ADD `username` text NULL"); + await queryRunner.query("ALTER TABLE `messages` ADD `avatar` text NULL"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `username`"); + await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `avatar`"); + } +} diff --git a/src/util/migration/mysql/1721298824927-webhookMessageProperties.ts b/src/util/migration/mysql/1721298824927-webhookMessageProperties.ts new file mode 100644 index 00000000..ccbe689a --- /dev/null +++ b/src/util/migration/mysql/1721298824927-webhookMessageProperties.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class WebhookMessageProperties1721298824927 implements MigrationInterface { + name = "WebhookMessageProperties1721298824927"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `messages` ADD `username` text NULL"); + await queryRunner.query("ALTER TABLE `messages` ADD `avatar` text NULL"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `username`"); + await queryRunner.query("ALTER TABLE `messages` DROP COLUMN `avatar`"); + } +} diff --git a/src/util/migration/postgres/1721298824927-webhookMessageProperties.ts b/src/util/migration/postgres/1721298824927-webhookMessageProperties.ts new file mode 100644 index 00000000..46c507d4 --- /dev/null +++ b/src/util/migration/postgres/1721298824927-webhookMessageProperties.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class WebhookMessageProperties1721298824927 implements MigrationInterface { + name = "WebhookMessageProperties1721298824927"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE messages ADD username text NULL"); + await queryRunner.query("ALTER TABLE messages ADD avatar text NULL"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query("ALTER TABLE messages DROP COLUMN username"); + await queryRunner.query("ALTER TABLE messages DROP COLUMN avatar"); + } +}