diff --git a/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs b/extra/admin-api/Tests/Spacebar.Tests/Tests/WebhookTests.cs
index 965f2f05..edd5f7af 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<Message>();
}
[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 00000000..0902765c
--- /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 <https://www.gnu.org/licenses/>.
+*/
+
+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;
|