diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs
index aa1b9bea69..7fc5b11e39 100644
--- a/.lintstagedrc.mjs
+++ b/.lintstagedrc.mjs
@@ -1,4 +1,9 @@
export default {
"*.{j,t}s": [() => "npm run build:src:tsgo", "eslint --concurrency 4" /* sweet spot it seems */, "prettier --write"],
- "src/schemas/{*,**/*}.ts": [() => "npm run build:src:tsgo", () => "node scripts/schema.js", () => "node scripts/openapi.js", () => "git add assets/schemas.json assets/openapi.json"],
+ "src/schemas/{*,**/*}.ts": [
+ () => "npm run build:src:tsgo",
+ () => "node scripts/schema.js",
+ () => "node scripts/openapi.js",
+ () => "git add assets/schemas.json assets/openapi.json",
+ ],
};
diff --git a/assets/openapi.json b/assets/openapi.json
index aa965def46..a39dd25fe6 100644
--- a/assets/openapi.json
+++ b/assets/openapi.json
@@ -6930,6 +6930,109 @@
}
}
},
+ "WebhookMessageEditSchema": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "nullable": true
+ },
+ "embeds": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/Embed"
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "allowed_mentions": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/AllowedMentions"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "components": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/BaseMessageComponents"
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "payload_json": {
+ "type": "string"
+ },
+ "attachments": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "filename",
+ "id"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "uploaded_filename": {
+ "type": "string"
+ },
+ "original_content_type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "filename",
+ "uploaded_filename"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "flags": {
+ "type": "integer"
+ }
+ }
+ },
"WebhookUpdateSchema": {
"type": "object",
"properties": {
@@ -14801,6 +14904,201 @@
]
}
},
+ "/webhooks/{webhook_id}/{token}/messages/{message_id}/": {
+ "get": {
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Message"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No description available"
+ }
+ },
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "webhook_id"
+ },
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "token"
+ },
+ {
+ "name": "message_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "message_id"
+ }
+ ],
+ "tags": [
+ "webhooks"
+ ]
+ },
+ "patch": {
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WebhookMessageEditSchema"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Message"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/APIErrorResponse"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No description available"
+ }
+ },
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "webhook_id"
+ },
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "token"
+ },
+ {
+ "name": "message_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "message_id"
+ },
+ {
+ "name": "thread_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Edit a webhook message in the specified thread."
+ }
+ ],
+ "tags": [
+ "webhooks"
+ ]
+ },
+ "delete": {
+ "security": [
+ {
+ "bearer": []
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No description available"
+ },
+ "404": {
+ "description": "No description available"
+ }
+ },
+ "parameters": [
+ {
+ "name": "webhook_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "webhook_id"
+ },
+ {
+ "name": "token",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "token"
+ },
+ {
+ "name": "message_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "message_id"
+ },
+ {
+ "name": "thread_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Delete a webhook message in the specified thread."
+ }
+ ],
+ "tags": [
+ "webhooks"
+ ]
+ }
+ },
"/webhooks/{webhook_id}/{token}/": {
"get": {
"security": [
diff --git a/assets/schemas.json b/assets/schemas.json
index e28447eb84..a42f838680 100644
--- a/assets/schemas.json
+++ b/assets/schemas.json
@@ -7376,6 +7376,113 @@
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
},
+ "WebhookMessageEditSchema": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": [
+ "null",
+ "string"
+ ]
+ },
+ "embeds": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/Embed"
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "allowed_mentions": {
+ "anyOf": [
+ {
+ "$ref": "#/definitions/AllowedMentions"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "components": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/BaseMessageComponents"
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "payload_json": {
+ "type": "string"
+ },
+ "attachments": {
+ "anyOf": [
+ {
+ "type": "array",
+ "items": {
+ "anyOf": [
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "filename",
+ "id"
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "uploaded_filename": {
+ "type": "string"
+ },
+ "original_content_type": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "filename",
+ "uploaded_filename"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "flags": {
+ "type": "integer"
+ }
+ },
+ "additionalProperties": false,
+ "$schema": "http://json-schema.org/draft-07/schema#"
+ },
"WebhookUpdateSchema": {
"type": "object",
"properties": {
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 83ff1e783e..cd8f785e43 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -66,7 +66,7 @@ export default defineConfig([
// "sort-imports": ["error", {}],
"default-case": "error",
"default-case-last": "error",
- "yoda": "error",
+ yoda: "error",
// unsure what the defaults are here, but we want them to error
"for-direction": "error",
"constructor-super": "error",
diff --git a/src/api/middlewares/Authentication.test.ts b/src/api/middlewares/Authentication.test.ts
new file mode 100644
index 0000000000..795db249ab
--- /dev/null
+++ b/src/api/middlewares/Authentication.test.ts
@@ -0,0 +1,50 @@
+/*
+ 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
+ MERCHANTIBILITY 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 assert from "node:assert/strict";
+import { before, describe, it } from "node:test";
+
+let NO_AUTHORIZATION_ROUTES: (typeof import("./Authentication"))["NO_AUTHORIZATION_ROUTES"];
+
+function skipsAuthentication(method: string, url: string) {
+ return NO_AUTHORIZATION_ROUTES.some((route) => {
+ if (typeof route === "string") {
+ return route === `${method} ${url}`;
+ }
+
+ return route.test(`${method} ${url}`);
+ });
+}
+
+before(async () => {
+ process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test";
+ ({ NO_AUTHORIZATION_ROUTES } = await import("./Authentication.js"));
+});
+
+describe("Authentication webhook bypass routes", () => {
+ it("allows unauthenticated webhook requests with base64url tokens that start with a dash", () => {
+ assert.equal(skipsAuthentication("GET", "/webhooks/123/-abc_DEF/messages/456"), true);
+ assert.equal(skipsAuthentication("PATCH", "/webhooks/123/-abc_DEF/messages/456"), true);
+ assert.equal(skipsAuthentication("DELETE", "/webhooks/123/-abc_DEF/messages/456"), true);
+ });
+
+ it("bounds webhook token segments before the next slash or end of URL", () => {
+ assert.equal(skipsAuthentication("GET", "/webhooks/123/abc_DEF"), true);
+ assert.equal(skipsAuthentication("GET", "/webhooks/123/abc.DEF/messages/456"), false);
+ });
+});
diff --git a/src/api/middlewares/Authentication.ts b/src/api/middlewares/Authentication.ts
index a35c5fbca7..6af532fa26 100644
--- a/src/api/middlewares/Authentication.ts
+++ b/src/api/middlewares/Authentication.ts
@@ -32,7 +32,7 @@ export const NO_AUTHORIZATION_ROUTES = [
"POST /auth/fingerprint",
"GET /invites/",
// Routes with a seperate auth system
- /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/\w+\/?/, // no token requires auth
+ /^(POST|HEAD|GET|PATCH|DELETE) \/webhooks\/\d+\/[A-Za-z0-9_-]+(?:\/|$)/, // no token requires auth
/^POST \/interactions\/\d+\/[A-Za-z0-9_-]+\/callback/,
// Public information endpoints
"GET /ping",
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 eed36691d0..768ee23f2e 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
@@ -98,6 +98,7 @@ router.patch(
channel_id,
id: message_id,
edited_timestamp: new Date(),
+ is_edit: true,
});
await new_message.save();
diff --git a/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts b/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts
new file mode 100644
index 0000000000..74e38b65af
--- /dev/null
+++ b/src/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.ts
@@ -0,0 +1,127 @@
+/*
+ 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
+ MERCHANTIBILITY 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 } from "@spacebar/api";
+import { Config } from "@spacebar/util";
+import { WebhookMessageEditSchema } from "@spacebar/schemas";
+import { Request, Response, Router } from "express";
+import multer from "multer";
+import { buildWebhookMessageEditBody, deleteWebhookMessage, editWebhookMessage, getWebhookForToken, getWebhookMessage } from "../../../../../../util/handlers/WebhookMessage";
+
+const router = Router({ mergeParams: true });
+
+const messageUpload = multer({
+ limits: {
+ fileSize: Config.get().limits.message.maxAttachmentSize,
+ fields: 10,
+ },
+ storage: multer.memoryStorage(),
+});
+
+function getThreadId(req: Request) {
+ return typeof req.query.thread_id === "string" ? req.query.thread_id : undefined;
+}
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { webhook_id, token, message_id } = req.params as { [key: string]: string };
+
+ await getWebhookForToken(webhook_id, token);
+ const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req));
+
+ return res.json(message.toJSON());
+ },
+);
+
+router.patch(
+ "/",
+ messageUpload.any(),
+ (req, _res, next) => {
+ if (req.body.payload_json) {
+ req.body = JSON.parse(req.body.payload_json);
+ }
+
+ next();
+ },
+ route({
+ requestBody: "WebhookMessageEditSchema",
+ query: {
+ thread_id: {
+ type: "string",
+ required: false,
+ description: "Edit a webhook message in the specified thread.",
+ },
+ },
+ responses: {
+ 200: {
+ body: "Message",
+ },
+ 400: {
+ body: "APIErrorResponse",
+ },
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { webhook_id, token, message_id } = req.params as { [key: string]: string };
+ const body = req.body as WebhookMessageEditSchema;
+
+ await getWebhookForToken(webhook_id, token);
+ const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req));
+ const updated = await editWebhookMessage(message, await buildWebhookMessageEditBody(message, body, (req.files as Express.Multer.File[]) ?? []));
+
+ return res.json(updated.toJSON());
+ },
+);
+
+router.delete(
+ "/",
+ route({
+ query: {
+ thread_id: {
+ type: "string",
+ required: false,
+ description: "Delete a webhook message in the specified thread.",
+ },
+ },
+ responses: {
+ 204: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const { webhook_id, token, message_id } = req.params as { [key: string]: string };
+
+ await getWebhookForToken(webhook_id, token);
+ const message = await getWebhookMessage(webhook_id, message_id, getThreadId(req));
+ await deleteWebhookMessage(message);
+
+ return res.sendStatus(204);
+ },
+);
+
+export default router;
diff --git a/src/api/util/handlers/Message.ts b/src/api/util/handlers/Message.ts
index 32f1e3d78d..5e7751ac4f 100644
--- a/src/api/util/handlers/Message.ts
+++ b/src/api/util/handlers/Message.ts
@@ -291,9 +291,18 @@ export function handleComps(components: BaseMessageComponents[], flags: number)
(await Promise.all(medias.map((m, index) => processMedia(m, messageId, batchId, user, channel, index + "")))).forEach((_) => _?.());
};
}
+export function isMessageEditOperation(opts: Pick): boolean {
+ return opts.is_edit === true;
+}
+
+export function shouldResolveMessageAuthor(opts: Pick): boolean {
+ return !!opts.author_id && !opts.webhook_id;
+}
+
export async function handleMessage(opts: MessageOptions): Promise {
const conf = Config.get();
const handle = opts.components ? handleComps(opts.components, opts.flags || 0) : undefined;
+ const isEdit = isMessageEditOperation(opts);
const channel = await Channel.findOneOrFail({
where: { id: opts.channel_id },
@@ -304,7 +313,7 @@ export async function handleMessage(opts: MessageOptions): Promise {
let permission: null | Permissions = null;
const limit = channel.rate_limit_per_user;
- if (limit) {
+ if (!isEdit && limit) {
const lastMsgTime = (await Message.findOne({ where: { channel_id: channel.id, author_id: opts.author_id }, select: { timestamp: true }, order: { timestamp: "DESC" } }))
?.timestamp;
if (lastMsgTime && Date.now() - limit * 1000 < +lastMsgTime) {
@@ -335,22 +344,23 @@ export async function handleMessage(opts: MessageOptions): Promise {
message.channel = channel;
await processMessageOptionAttachments(opts, message);
- if (opts.author_id) {
+ if (shouldResolveMessageAuthor(opts)) {
+ const author_id = opts.author_id!;
message.author = await User.findOneOrFail({
- where: { id: opts.author_id },
+ where: { id: author_id },
});
- const rights = await getRights(opts.author_id);
+ const rights = await getRights(author_id);
message.author.clean_data();
rights.hasThrow("SEND_MESSAGES");
}
const ephermal = (message.flags & (1 << 6)) !== 0;
- if (!ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) {
+ if (!isEdit && !ephermal && channel.type === ChannelType.GUILD_PUBLIC_THREAD) {
const rep = Channel.getRepository();
await rep.increment({ id: channel.id }, "message_count", 1);
await rep.increment({ id: channel.id }, "total_message_sent", 1);
}
- if (!ephermal) {
+ if (!isEdit && !ephermal) {
channel.last_message_id = message.id;
await channel.save();
}
@@ -579,7 +589,9 @@ export async function handleMessage(opts: MessageOptions): Promise {
}
return Promise.all([...users].map((user_id) => ReadState.create({ user_id, channel_id: channel.id }).save()));
}
- if (ephermal) {
+ if (isEdit) {
+ // Edits recalculate mentions for serialization but must not create new mention notifications.
+ } else if (ephermal) {
const id = message.interaction_metadata?.user_id;
if (id) {
let pinged = mention_everyone || channel.type === ChannelType.DM || channel.type === ChannelType.GROUP_DM;
@@ -722,6 +734,7 @@ interface MessageOptions extends MessageCreateSchema {
timestamp?: Date;
username?: string;
avatar_url?: string;
+ is_edit?: boolean;
}
// Makes for concise code, inspired by Nix' lib.trace
diff --git a/src/api/util/handlers/Webhook.ts b/src/api/util/handlers/Webhook.ts
index 73e609d853..78c76417bc 100644
--- a/src/api/util/handlers/Webhook.ts
+++ b/src/api/util/handlers/Webhook.ts
@@ -1,9 +1,10 @@
import { handleMessage, postHandleMessage } from "@spacebar/api";
-import { Attachment, Channel, Config, DiscordApiErrors, emitEvent, FieldErrors, Message, MessageCreateEvent, Snowflake, uploadFile, ValidateName, Webhook } from "@spacebar/util";
+import { Attachment, Channel, Config, DiscordApiErrors, emitEvent, FieldErrors, Message, MessageCreateEvent, Snowflake, ValidateName } from "@spacebar/util";
import { Request, Response } from "express";
import { HTTPError } from "lambert-server";
import { MoreThan } from "typeorm";
import { WebhookExecuteSchema } from "@spacebar/schemas";
+import { getWebhookForToken, uploadWebhookMessageFiles } from "./WebhookMessage";
export const executeWebhook = async (req: Request, res: Response) => {
const body = req.body as WebhookExecuteSchema;
@@ -11,20 +12,7 @@ export const executeWebhook = async (req: Request, res: Response) => {
const { webhook_id, token } = req.params as { [key: string]: string };
- const webhook = await Webhook.findOne({
- where: {
- id: webhook_id,
- },
- relations: { channel: true, guild: true, application: true },
- });
-
- if (!webhook) {
- throw DiscordApiErrors.UNKNOWN_WEBHOOK;
- }
-
- if (webhook.token !== token) {
- throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
- }
+ const webhook = await getWebhookForToken(webhook_id, token, { channel: true, guild: true, application: true });
if (body.username) {
ValidateName(body.username);
@@ -86,14 +74,11 @@ export const executeWebhook = async (req: Request, res: Response) => {
}
const files = (req.files as Express.Multer.File[]) ?? [];
- for (const currFile of files) {
- try {
- const file = await uploadFile(`/attachments/${sendChannel.id}/${messageId}`, currFile);
- attachments.push(Attachment.create(file));
- } catch (error) {
- if (wait) res.status(400).json({ message: error?.toString() });
- return;
- }
+ try {
+ attachments.push(...(await uploadWebhookMessageFiles(sendChannel.id, messageId, files)));
+ } catch (error) {
+ if (wait) res.status(400).json({ message: error?.toString() });
+ return;
}
const embeds = body.embeds || [];
diff --git a/src/api/util/handlers/WebhookMessage.test.ts b/src/api/util/handlers/WebhookMessage.test.ts
new file mode 100644
index 0000000000..6518e8b901
--- /dev/null
+++ b/src/api/util/handlers/WebhookMessage.test.ts
@@ -0,0 +1,197 @@
+/*
+ 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
+ MERCHANTIBILITY 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 assert from "node:assert/strict";
+import { before, describe, it } from "node:test";
+import type { Attachment, Webhook } from "@spacebar/util";
+import type * as MessageModule from "./Message";
+import type * as WebhookMessageModule from "./WebhookMessage";
+
+let DiscordApiErrors: (typeof import("@spacebar/util"))["DiscordApiErrors"];
+let ChannelType: (typeof import("@spacebar/schemas"))["ChannelType"];
+let assertWebhookToken: typeof WebhookMessageModule.assertWebhookToken;
+let buildWebhookMessageEditBody: typeof WebhookMessageModule.buildWebhookMessageEditBody;
+let getWebhookMessageWhere: typeof WebhookMessageModule.getWebhookMessageWhere;
+let normalizeWebhookMessageEditBody: typeof WebhookMessageModule.normalizeWebhookMessageEditBody;
+let resolveWebhookMessageEditAttachments: typeof WebhookMessageModule.resolveWebhookMessageEditAttachments;
+let shouldDecrementWebhookMessageChannel: typeof WebhookMessageModule.shouldDecrementWebhookMessageChannel;
+let uploadWebhookMessageFiles: typeof WebhookMessageModule.uploadWebhookMessageFiles;
+let isMessageEditOperation: typeof MessageModule.isMessageEditOperation;
+let shouldResolveMessageAuthor: typeof MessageModule.shouldResolveMessageAuthor;
+
+before(async () => {
+ process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test";
+
+ ({ DiscordApiErrors } = require("@spacebar/util") as typeof import("@spacebar/util"));
+ ({ ChannelType } = require("@spacebar/schemas") as typeof import("@spacebar/schemas"));
+ ({
+ assertWebhookToken,
+ buildWebhookMessageEditBody,
+ getWebhookMessageWhere,
+ normalizeWebhookMessageEditBody,
+ resolveWebhookMessageEditAttachments,
+ shouldDecrementWebhookMessageChannel,
+ uploadWebhookMessageFiles,
+ } = await import("./WebhookMessage.js"));
+ ({ isMessageEditOperation, shouldResolveMessageAuthor } = await import("./Message.js"));
+});
+
+function attachment(id: string): Attachment {
+ return { id, filename: `${id}.txt` } as Attachment;
+}
+
+describe("WebhookMessage handlers", () => {
+ it("accepts matching webhook tokens", () => {
+ assert.doesNotThrow(() => assertWebhookToken({ token: "secret" } as Webhook, "secret"));
+ });
+
+ it("rejects missing webhooks as unknown webhooks", () => {
+ assert.throws(() => assertWebhookToken(null, "secret"), {
+ code: DiscordApiErrors.UNKNOWN_WEBHOOK.code,
+ message: DiscordApiErrors.UNKNOWN_WEBHOOK.message,
+ });
+ });
+
+ it("rejects mismatched webhook tokens", () => {
+ assert.throws(() => assertWebhookToken({ token: "secret" } as Webhook, "wrong"), {
+ code: DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED.code,
+ message: DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED.message,
+ });
+ });
+
+ it("scopes webhook message lookups to the webhook id", () => {
+ assert.deepEqual(getWebhookMessageWhere("webhook", "message"), {
+ id: "message",
+ webhook_id: "webhook",
+ });
+ });
+
+ it("scopes threaded webhook message lookups to the thread id", () => {
+ assert.deepEqual(getWebhookMessageWhere("webhook", "message", "thread"), {
+ id: "message",
+ webhook_id: "webhook",
+ channel_id: "thread",
+ });
+ });
+
+ it("keeps existing attachments when webhook message edits omit attachments", () => {
+ const existing = [attachment("keep"), attachment("also-keep")];
+ const uploaded = [attachment("uploaded")];
+
+ assert.deepEqual(resolveWebhookMessageEditAttachments(existing, undefined, uploaded), [...existing, ...uploaded]);
+ });
+
+ it("retains requested existing attachments and appends new multipart uploads", () => {
+ const keep = attachment("keep");
+ const remove = attachment("remove");
+ const uploaded = attachment("uploaded");
+
+ assert.deepEqual(
+ resolveWebhookMessageEditAttachments(
+ [keep, remove],
+ [
+ { id: "keep", filename: "keep.txt" },
+ { id: "0", filename: "new-file-placeholder.txt" },
+ ],
+ [uploaded],
+ ),
+ [keep, uploaded],
+ );
+ });
+
+ it("clears retained attachments when webhook message edits send null attachments", () => {
+ assert.deepEqual(resolveWebhookMessageEditAttachments([attachment("remove")], null, []), []);
+ });
+
+ it("preserves cloud attachments for message edit processing", () => {
+ const cloudAttachment = {
+ id: "cloud",
+ filename: "cloud.txt",
+ uploaded_filename: "cloud-upload-key",
+ };
+
+ assert.deepEqual(resolveWebhookMessageEditAttachments([], [cloudAttachment], []), [cloudAttachment]);
+ });
+
+ it("uploads webhook files under the target channel and message path", async () => {
+ const file = {
+ buffer: Buffer.from("hello"),
+ mimetype: "text/plain",
+ originalname: "hello.txt",
+ };
+ const calls: { path: string; originalname: string }[] = [];
+
+ const uploaded = await uploadWebhookMessageFiles("channel", "message", [file], async (path, uploadedFile) => {
+ calls.push({ path, originalname: uploadedFile.originalname });
+ return attachment("uploaded");
+ });
+
+ assert.deepEqual(calls, [{ path: "/attachments/channel/message", originalname: "hello.txt" }]);
+ assert.equal(uploaded[0].id, "uploaded");
+ });
+
+ it("normalizes nullable webhook message edit fields before message processing", () => {
+ assert.deepEqual(normalizeWebhookMessageEditBody({ allowed_mentions: null, components: null, content: null, embeds: null }), {
+ components: [],
+ content: "",
+ embeds: [],
+ });
+ });
+
+ it("marks edit operations so message processing can skip create-only side effects", () => {
+ assert.equal(isMessageEditOperation({ is_edit: true }), true);
+ assert.equal(isMessageEditOperation({ is_edit: false }), false);
+ assert.equal(isMessageEditOperation({}), false);
+ });
+
+ it("does not run normal user author checks for webhook-authenticated message operations", () => {
+ assert.equal(shouldResolveMessageAuthor({ author_id: "user" }), true);
+ assert.equal(shouldResolveMessageAuthor({ author_id: "webhook", webhook_id: "webhook" }), false);
+ assert.equal(shouldResolveMessageAuthor({ webhook_id: "webhook" }), false);
+ });
+
+ it("builds webhook message edit bodies with retained and uploaded attachments", async () => {
+ const existing = attachment("existing");
+
+ const body = await buildWebhookMessageEditBody(
+ { id: "message", channel_id: "channel", attachments: [existing] },
+ { content: "edited" },
+ [{ buffer: Buffer.from("new"), mimetype: "text/plain", originalname: "new.txt" }],
+ async () => attachment("uploaded"),
+ );
+
+ assert.equal(body.content, "edited");
+ assert.deepEqual(
+ body.attachments?.map((current) => current.id),
+ ["existing", "uploaded"],
+ );
+ });
+
+ it("rejects edit body construction for messages without a channel", async () => {
+ await assert.rejects(() => buildWebhookMessageEditBody({ id: "message", attachments: [] }, {}, []), {
+ code: DiscordApiErrors.UNKNOWN_MESSAGE.code,
+ message: DiscordApiErrors.UNKNOWN_MESSAGE.message,
+ });
+ });
+
+ it("only decrements public thread counters during webhook message deletion", () => {
+ assert.equal(shouldDecrementWebhookMessageChannel({ type: ChannelType.GUILD_PUBLIC_THREAD }), true);
+ assert.equal(shouldDecrementWebhookMessageChannel({ type: ChannelType.GUILD_TEXT }), false);
+ assert.equal(shouldDecrementWebhookMessageChannel(undefined), false);
+ });
+});
diff --git a/src/api/util/handlers/WebhookMessage.ts b/src/api/util/handlers/WebhookMessage.ts
new file mode 100644
index 0000000000..e326606a14
--- /dev/null
+++ b/src/api/util/handlers/WebhookMessage.ts
@@ -0,0 +1,223 @@
+/*
+ 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
+ MERCHANTIBILITY 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 } from "@spacebar/api";
+import { Attachment, Channel, DiscordApiErrors, emitEvent, Message, MessageDeleteEvent, MessageUpdateEvent, uploadFile, Webhook } from "@spacebar/util";
+import { ChannelType, WebhookMessageEditSchema } from "@spacebar/schemas";
+import { FindOptionsRelations, FindOptionsWhere } from "typeorm";
+
+type WebhookMessageUploadFile = Pick;
+type WebhookMessageUploader = (path: string, file: WebhookMessageUploadFile) => Promise;
+type WebhookMessageEditAttachment = NonNullable[number] | Attachment;
+
+export type PreparedWebhookMessageEdit = Omit & {
+ attachments?: WebhookMessageEditAttachment[];
+ allowed_mentions?: Exclude;
+ components?: Exclude;
+ content?: Exclude;
+ embeds?: Exclude;
+};
+
+export function assertWebhookToken(webhook: Pick | null | undefined, token: string): asserts webhook is Pick {
+ if (!webhook) {
+ throw DiscordApiErrors.UNKNOWN_WEBHOOK;
+ }
+
+ if (webhook.token !== token) {
+ throw DiscordApiErrors.INVALID_WEBHOOK_TOKEN_PROVIDED;
+ }
+}
+
+export function getWebhookMessageWhere(webhook_id: string, message_id: string, thread_id?: string): FindOptionsWhere {
+ return {
+ id: message_id,
+ webhook_id,
+ ...(thread_id ? { channel_id: thread_id } : {}),
+ };
+}
+
+export async function getWebhookForToken(webhook_id: string, token: string, relations?: FindOptionsRelations): Promise {
+ const webhook = await Webhook.findOne({
+ where: {
+ id: webhook_id,
+ },
+ ...(relations ? { relations } : {}),
+ });
+
+ assertWebhookToken(webhook, token);
+ return webhook;
+}
+
+export async function getWebhookMessage(webhook_id: string, message_id: string, thread_id?: string): Promise {
+ const message = await Message.findOne({
+ where: getWebhookMessageWhere(webhook_id, message_id, thread_id),
+ relations: {
+ attachments: true,
+ application: true,
+ author: true,
+ channel: true,
+ member: true,
+ mention_channels: true,
+ mention_roles: true,
+ mentions: true,
+ sticker_items: true,
+ thread: true,
+ webhook: true,
+ },
+ });
+
+ if (!message) {
+ throw DiscordApiErrors.UNKNOWN_MESSAGE;
+ }
+
+ return message;
+}
+
+export async function uploadWebhookMessageFiles(
+ channel_id: string,
+ message_id: string,
+ files: readonly WebhookMessageUploadFile[] = [],
+ uploader: WebhookMessageUploader = uploadFile,
+): Promise {
+ const attachments: Attachment[] = [];
+
+ for (const file of files) {
+ attachments.push(Object.assign(new Attachment(), await uploader(`/attachments/${channel_id}/${message_id}`, file)));
+ }
+
+ return attachments;
+}
+
+export function resolveWebhookMessageEditAttachments(
+ existingAttachments: readonly Attachment[] = [],
+ requestedAttachments: WebhookMessageEditSchema["attachments"],
+ uploadedAttachments: readonly Attachment[] = [],
+): WebhookMessageEditAttachment[] {
+ const retainedAttachments: WebhookMessageEditAttachment[] = [];
+
+ if (requestedAttachments === undefined) {
+ retainedAttachments.push(...existingAttachments);
+ } else if (requestedAttachments !== null) {
+ for (const attachment of requestedAttachments) {
+ if ("uploaded_filename" in attachment) {
+ retainedAttachments.push(attachment);
+ continue;
+ }
+
+ const existing = existingAttachments.find((current) => current.id === attachment.id);
+ if (existing) {
+ retainedAttachments.push(existing);
+ }
+ }
+ }
+
+ return [...retainedAttachments, ...uploadedAttachments];
+}
+
+export function normalizeWebhookMessageEditBody(body: WebhookMessageEditSchema): Omit {
+ const { allowed_mentions, attachments: _attachments, components, content, embeds, ...rest } = body;
+
+ return {
+ ...rest,
+ ...(allowed_mentions !== null && allowed_mentions !== undefined ? { allowed_mentions } : {}),
+ ...(components !== null && components !== undefined ? { components } : {}),
+ ...(components === null ? { components: [] } : {}),
+ ...(content !== null && content !== undefined ? { content } : {}),
+ ...(content === null ? { content: "" } : {}),
+ ...(embeds !== null && embeds !== undefined ? { embeds } : {}),
+ ...(embeds === null ? { embeds: [] } : {}),
+ };
+}
+
+export async function buildWebhookMessageEditBody(
+ message: Pick,
+ body: WebhookMessageEditSchema,
+ files: readonly WebhookMessageUploadFile[] = [],
+ uploader: WebhookMessageUploader = uploadFile,
+): Promise {
+ if (!message.channel_id) {
+ throw DiscordApiErrors.UNKNOWN_MESSAGE;
+ }
+
+ return {
+ ...normalizeWebhookMessageEditBody(body),
+ attachments: resolveWebhookMessageEditAttachments(
+ message.attachments ?? [],
+ body.attachments,
+ await uploadWebhookMessageFiles(message.channel_id, message.id, files, uploader),
+ ),
+ };
+}
+
+export async function editWebhookMessage(message: Message, body: PreparedWebhookMessageEdit): Promise {
+ const newMessage = await handleMessage({
+ ...message,
+ message_reference: message.message_reference,
+ ...body,
+ author_id: message.author_id,
+ channel_id: message.channel_id,
+ id: message.id,
+ edited_timestamp: new Date(),
+ is_edit: true,
+ });
+
+ await newMessage.save();
+ await emitEvent({
+ event: "MESSAGE_UPDATE",
+ channel_id: newMessage.channel_id,
+ data: {
+ ...newMessage.toJSON(),
+ nonce: undefined,
+ },
+ } satisfies MessageUpdateEvent);
+
+ postHandleMessage(newMessage).catch((e) => console.error("[WebhookMessage] post-message handler failed", e));
+ return newMessage;
+}
+
+export function shouldDecrementWebhookMessageChannel(channel?: Pick | null): boolean {
+ return channel?.type === ChannelType.GUILD_PUBLIC_THREAD;
+}
+
+export async function deleteWebhookMessage(message: Message): Promise {
+ if (!message.channel_id || !message.webhook_id) {
+ throw DiscordApiErrors.UNKNOWN_MESSAGE;
+ }
+
+ if (shouldDecrementWebhookMessageChannel(message.channel)) {
+ if (message.channel.message_count !== undefined) {
+ message.channel.message_count = Math.max(0, message.channel.message_count - 1);
+ }
+ await message.channel.save();
+ }
+
+ if (message.attachments?.length) {
+ await Attachment.remove(message.attachments);
+ }
+
+ await Message.delete({ id: message.id, webhook_id: message.webhook_id });
+ await emitEvent({
+ event: "MESSAGE_DELETE",
+ channel_id: message.channel_id,
+ data: {
+ id: message.id,
+ channel_id: message.channel_id,
+ guild_id: message.guild_id,
+ },
+ } satisfies MessageDeleteEvent);
+}
diff --git a/src/api/util/handlers/WebhookMessageRoute.test.ts b/src/api/util/handlers/WebhookMessageRoute.test.ts
new file mode 100644
index 0000000000..2ce2035dd2
--- /dev/null
+++ b/src/api/util/handlers/WebhookMessageRoute.test.ts
@@ -0,0 +1,186 @@
+/*
+ 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
+ MERCHANTIBILITY 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 assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { promisify } from "node:util";
+import { describe, it } from "node:test";
+
+const execFileAsync = promisify(execFile);
+
+const routeHarness = String.raw`
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+process.env.DATABASE ??= "postgres://user:pass@localhost:5432/test";
+process.env.NODE_ENV = "test";
+process.env.NODE_PATH = path.join(process.cwd(), "node_modules");
+require("node:module").Module._initPaths();
+require("module-alias/register");
+
+const express = require("express");
+const { Server } = require("lambert-server");
+
+const message = {
+ id: "message",
+ toJSON: () => ({ id: "message", content: "original" }),
+};
+const updatedMessage = {
+ id: "message",
+ toJSON: () => ({ id: "message", content: "edited" }),
+};
+let calls = [];
+function record(name, ...args) {
+ calls.push({ name, args });
+}
+function callNames() {
+ return calls.map((call) => call.name);
+}
+
+global.__webhookMessageHelpers = {
+ getWebhookForToken: async (...args) => {
+ record("getWebhookForToken", ...args);
+ return { id: args[0], token: args[1] };
+ },
+ getWebhookMessage: async (...args) => {
+ record("getWebhookMessage", ...args);
+ return message;
+ },
+ buildWebhookMessageEditBody: async (...args) => {
+ record("buildWebhookMessageEditBody", ...args);
+ return args[1];
+ },
+ editWebhookMessage: async (...args) => {
+ record("editWebhookMessage", ...args);
+ return updatedMessage;
+ },
+ deleteWebhookMessage: async (...args) => {
+ record("deleteWebhookMessage", ...args);
+ },
+};
+
+async function main() {
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "spacebar-webhook-route-"));
+ const tempRoutesRoot = path.join(tempRoot, "routes");
+ const tempRouteDir = path.join(tempRoutesRoot, "webhooks", "#webhook_id", "#token", "messages", "#message_id");
+ const tempHelperDir = path.join(tempRoot, "util", "handlers");
+ fs.mkdirSync(tempRouteDir, { recursive: true });
+ fs.mkdirSync(tempHelperDir, { recursive: true });
+ fs.writeFileSync(path.join(tempHelperDir, "WebhookMessage.js"), "module.exports = global.__webhookMessageHelpers;\n");
+
+ const sourceRouteFile = path.join(process.cwd(), "dist/api/routes/webhooks/#webhook_id/#token/messages/#message_id/index.js");
+ const sourceRoute = fs.readFileSync(sourceRouteFile, "utf8").replace(/\n\/\/# sourceMappingURL=.*\n?$/, "\n");
+ fs.writeFileSync(path.join(tempRouteDir, "index.js"), sourceRoute);
+
+ const app = express();
+ app.use(express.json());
+
+ const server = new Server({ app, serverInitLogging: false });
+ const routesRoot = tempRoutesRoot;
+ const routeFile = path.join(routesRoot, "webhooks", "#webhook_id", "#token", "messages", "#message_id", "index.js");
+ assert.ok(server.registerRoute(routesRoot, routeFile), "webhook message route should register");
+
+ const listener = app.listen(0);
+ await new Promise((resolve) => listener.once("listening", resolve));
+ const baseUrl = "http://127.0.0.1:" + listener.address().port;
+
+ try {
+ let response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message?thread_id=thread");
+ assert.equal(response.status, 200);
+ assert.deepEqual(await response.json(), { id: "message", content: "original" });
+ assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage"]);
+ assert.deepEqual(calls[0].args, ["webhook", "token"]);
+ assert.deepEqual(calls[1].args, ["webhook", "message", "thread"]);
+
+ calls = [];
+ response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ content: "edited" }),
+ });
+ assert.equal(response.status, 200);
+ assert.deepEqual(await response.json(), { id: "message", content: "edited" });
+ assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "buildWebhookMessageEditBody", "editWebhookMessage"]);
+ assert.equal(calls[2].args[0], message);
+ assert.deepEqual({ ...calls[2].args[1] }, { content: "edited" });
+ assert.equal(calls[3].args[0], message);
+ assert.deepEqual({ ...calls[3].args[1] }, { content: "edited" });
+
+ calls = [];
+ const nullableBody = { attachments: null, content: null, embeds: null };
+ response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", {
+ method: "PATCH",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(nullableBody),
+ });
+ assert.equal(response.status, 200);
+ assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "buildWebhookMessageEditBody", "editWebhookMessage"]);
+ assert.equal(calls[2].args[0], message);
+ assert.deepEqual({ ...calls[2].args[1] }, nullableBody);
+ assert.equal(calls[3].args[0], message);
+ assert.deepEqual({ ...calls[3].args[1] }, nullableBody);
+
+ calls = [];
+ response = await fetch(baseUrl + "/webhooks/webhook/token/messages/message", { method: "DELETE" });
+ assert.equal(response.status, 204);
+ assert.equal(await response.text(), "");
+ assert.deepEqual(callNames(), ["getWebhookForToken", "getWebhookMessage", "deleteWebhookMessage"]);
+ assert.deepEqual(calls[0].args, ["webhook", "token"]);
+ assert.deepEqual(calls[1].args, ["webhook", "message", undefined]);
+ assert.deepEqual(calls[2].args, [message]);
+ } finally {
+ await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()));
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ }
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
+`;
+
+describe("webhook message route registration", () => {
+ it("registers and dispatches GET/PATCH/DELETE webhook message requests", async () => {
+ const { NODE_V8_COVERAGE: _nodeV8Coverage, ...env } = process.env;
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "spacebar-webhook-route-harness-"));
+ const harnessFile = path.join(tempRoot, "route-harness.js");
+ fs.writeFileSync(harnessFile, routeHarness);
+
+ try {
+ const { stderr, stdout } = await execFileAsync(process.execPath, ["--enable-source-maps", harnessFile], {
+ cwd: process.cwd(),
+ env: {
+ ...env,
+ DATABASE: process.env.DATABASE ?? "postgres://user:pass@localhost:5432/test",
+ },
+ timeout: 10_000,
+ });
+
+ assert.equal(stdout, "");
+ assert.equal(stderr, "");
+ } finally {
+ fs.rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/cdn/util/Storage.ts b/src/cdn/util/Storage.ts
index 7579e2d610..3245f65572 100644
--- a/src/cdn/util/Storage.ts
+++ b/src/cdn/util/Storage.ts
@@ -86,7 +86,9 @@ if (process.env.STORAGE_PROVIDER === "file" || !process.env.STORAGE_PROVIDER) {
const forcePathStyle = process.env.STORAGE_FORCE_PATH_STYLE === "true";
if (process.env.STORAGE_FORCE_PATH_STYLE === undefined) {
- console.warn(`[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`);
+ console.warn(
+ `[CDN] STORAGE_FORCE_PATH_STYLE is not set for S3 provider; defaulting to virtual-hosted style. Set STORAGE_FORCE_PATH_STYLE=true to enable path-style addressing.`,
+ );
}
const { S3Storage } = require("./S3Storage");
diff --git a/src/schemas/uncategorised/WebhookMessageEditSchema.ts b/src/schemas/uncategorised/WebhookMessageEditSchema.ts
new file mode 100644
index 0000000000..6452a18339
--- /dev/null
+++ b/src/schemas/uncategorised/WebhookMessageEditSchema.ts
@@ -0,0 +1,30 @@
+/*
+ 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
+ MERCHANTIBILITY 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 { AllowedMentions, BaseMessageComponents, Embed } from "@spacebar/schemas";
+import { MessageCreateAttachment, MessageCreateCloudAttachment } from "./MessageCreateSchema";
+
+export interface WebhookMessageEditSchema {
+ content?: string | null;
+ embeds?: Embed[] | null;
+ allowed_mentions?: AllowedMentions | null;
+ components?: BaseMessageComponents[] | null;
+ payload_json?: string;
+ attachments?: (MessageCreateAttachment | MessageCreateCloudAttachment)[] | null;
+ flags?: number;
+}
diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts
index 28ac51d5ad..b439c57cd1 100644
--- a/src/schemas/uncategorised/index.ts
+++ b/src/schemas/uncategorised/index.ts
@@ -88,6 +88,7 @@ export * from "./VoiceStateUpdateSchema";
export * from "./WebAuthnSchema";
export * from "./WebhookCreateSchema";
export * from "./WebhookExecuteSchema";
+export * from "./WebhookMessageEditSchema";
export * from "./WebhookUpdateSchema";
export * from "./WidgetModifySchema";
export * from "./MessageThreadCreationSchema";