diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs index 965f2f05f..edd5f7af9 100644 --- a/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs +++ b/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs @@ -1,4 +1,5 @@ -using System.Text.Json.Nodes; +using System.Net.Http.Json; +using System.Text.Json.Nodes; using Spacebar.Models.Generic; using Spacebar.Sdk.Core; using Spacebar.Tests.Abstractions; @@ -19,6 +20,7 @@ public class WebhookTests(ITestOutputHelper testOutputHelper, TestFixture fixtur private static SpacebarClientChannel? Channel = null!; private static Webhook? Webhook = null!; + private static Message? WebhookMessage = null!; public async ValueTask InitializeAsync() { Client = await _userAbstraction.GetSharedUser(); @@ -48,6 +50,12 @@ public class WebhookTests(ITestOutputHelper testOutputHelper, TestFixture fixtur Assert.StringNotNullOrWhitespace(w.Result.Url); Webhook = w.Result; }); + + + if (WebhookMessage is null) + WebhookMessage = await (await Assert.SuccessfullyHttpPostAsJsonAsync(Webhook.Url + "?wait=true", new JsonObject() { + { "content", "meow" } + })).Content.ReadFromJsonAsync(); } [Fact] @@ -160,4 +168,23 @@ public class WebhookTests(ITestOutputHelper testOutputHelper, TestFixture fixtur { "avatar_url", Client.ClientWellKnown.Api.BaseUrl + "/static/logo.png" } }); } + + [Fact] + public async Task GetWebhookMessageById() { + var msg = await Assert.SuccessfullyHttpGetAsync(Webhook.Url + "/messages/" + WebhookMessage.Id); + } + + [Theory] + [MemberData(nameof(WebhookExecuteCombinations))] + public async Task EditWebhookMessageByIdWithData(string content, string? username, string? avatarUrl, bool? tts, int? flags) { + var payload = new JsonObject() { + { "content", content } + }; + if (username != null) payload.Add("username", username); + if (avatarUrl != null) payload.Add("avatar_url", avatarUrl); + if (tts != null) payload.Add("tts", tts); + if (flags != null) payload.Add("flags", flags); + + await Assert.SuccessfullyHttpPostAsJsonAsync(Webhook.Url + "?wait=true", payload); + } } \ No newline at end of file diff --git a/src/api/routes/webhooks/#webhook_id/#webhook_token/messages/#message_id.ts b/src/api/routes/webhooks/#webhook_id/#webhook_token/messages/#message_id.ts new file mode 100644 index 000000000..0902765cf --- /dev/null +++ b/src/api/routes/webhooks/#webhook_id/#webhook_token/messages/#message_id.ts @@ -0,0 +1,212 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2026 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 { Request, Response, Router } from "express"; +import { HTTPError } from "lambert-server/HTTPError"; +import multer from "multer"; +import { handleMessage, postHandleMessage, route } from "@spacebar/api/util"; +import { Attachment, Channel, Message, Webhook } from "@spacebar/database"; +import { + MessageCreateEvent, + MessageDeleteEvent, + MessageUpdateEvent, + Snowflake, + SpacebarApiErrors, + emitEvent, + getPermission, + getRights, + uploadFile, + NewUrlUserSignatureData, + DiscordApiErrors, +} from "@spacebar/util"; +import { MessageCreateAttachment, MessageCreateCloudAttachment, MessageCreateSchema, MessageEditSchema, ChannelType } from "@spacebar/schemas"; + +const router = Router({ mergeParams: true }); +// TODO: message content/embed string length limit + +async function assertValidWebhookAuth(webhookId: string, webhookToken: string, messageId: string) { + const webhook = await Webhook.findOne({ where: { id: webhookId } }); + if (!webhook) throw DiscordApiErrors.UNKNOWN_WEBHOOK; + if (webhook.token != webhookToken) throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED; + + // TODO: fix error responses + const message = await Message.findOne({ where: { id: messageId } }); + if (!message) throw new HTTPError(`No message found with ID ${messageId}`, 404); + if (webhook.id != message?.webhook_id) throw new HTTPError(`Message does not belong to webhook ${message.webhook_id}`, 401); + if (webhook.channel_id != message?.channel_id) throw new HTTPError(`Message does not belong to webhook channel ${message.channel_id}`, 401); +} + +const messageUpload = multer({ + limits: { + fileSize: 1024 * 1024 * 100, + fields: 10, + files: 1, + }, + storage: multer.memoryStorage(), +}); // max upload 50 mb + +router.patch( + "/", + route({ + requestBody: "MessageEditSchema", + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, webhook_token, message_id } = req.params as { [key: string]: string }; + const body = req.body as MessageEditSchema; + + await assertValidWebhookAuth(webhook_id, webhook_token, message_id); + + const message = await Message.findOneOrFail({ + where: { id: message_id, webhook_id: webhook_id }, + relations: { attachments: true }, + }); + + const new_message = await handleMessage({ + ...message, + // TODO: should message_reference be overridable? + message_reference: message.message_reference, + ...body, + author_id: message.author_id, + webhook_id: message.webhook_id, + channel_id: message.channel_id, + id: message_id, + edited_timestamp: new Date(), + }); + + await new_message.save(); + await emitEvent({ + event: "MESSAGE_UPDATE", + channel_id: message.channel_id, + data: { + ...new_message.toJSON(), + nonce: undefined, + }, + } satisfies MessageUpdateEvent); + + postHandleMessage(new_message).catch((e) => console.error("[Message] post-message handler failed", e)); + + // TODO: a DTO? + return res.json({ + ...new_message.toJSON(), + id: new_message.id, + type: new_message.type, + channel_id: new_message.channel_id, + member: new_message.member?.toPublicMember(), + author: new_message.author?.toPublicUser(), + attachments: new_message.attachments, + embeds: new_message.embeds, + mentions: new_message.embeds, + mention_roles: new_message.mention_roles, + mention_everyone: new_message.mention_everyone, + pinned: new_message.pinned, + timestamp: new_message.timestamp, + edited_timestamp: new_message.edited_timestamp, + + // these are not in the Discord.com response + mention_channels: new_message.mention_channels, + }); + }, +); + +router.get( + "/", + route({ + responses: { + 200: { + body: "Message", + }, + 400: { + body: "APIErrorResponse", + }, + 403: {}, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, webhook_token, message_id } = req.params as { [key: string]: string }; + + await assertValidWebhookAuth(webhook_id, webhook_token, message_id); + + const message = await Message.findOneOrFail({ + where: { id: message_id, webhook_id: webhook_id }, + relations: { + attachments: true, + author: true, + }, + }); + + return res.json(message); + }, +); + +router.delete( + "/", + route({ + responses: { + 204: {}, + 400: { + body: "APIErrorResponse", + }, + 404: {}, + }, + }), + async (req: Request, res: Response) => { + const { webhook_id, webhook_token, message_id } = req.params as { [key: string]: string }; + + await assertValidWebhookAuth(webhook_id, webhook_token, message_id); + + const message = await Message.findOneOrFail({ + where: { id: message_id, webhook_id: webhook_id }, + }); + + const channel = await Channel.findOneOrFail({ + where: { id: message.channel_id }, + }); + + if (channel.type === ChannelType.GUILD_PUBLIC_THREAD) { + if (channel.message_count !== undefined) channel.message_count--; + await channel.save(); + } + + await Message.delete({ id: message_id, webhook_id: webhook_id }); + + await emitEvent({ + event: "MESSAGE_DELETE", + channel_id: message.channel_id, + data: { + id: message_id, + channel_id: message.channel_id!, + guild_id: channel.guild_id, + }, + } satisfies MessageDeleteEvent); + + res.sendStatus(204); + }, +); + +export default router;