From ef8d1225b568abd58bfa6eb1ca79e21d6c3f8dfa Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 31 Aug 2026 19:13:51 +0200 Subject: [PATCH] refactor(api): split webhook attachment schemas (#2268) --- fluxer_api/src/api/openapi/openapi.json | 116 +++++++++++------- .../src/api/webhook/WebhookController.ts | 14 ++- .../domains/webhook/WebhookRequestSchemas.ts | 26 ++-- 3 files changed, 102 insertions(+), 54 deletions(-) diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index c64592b7f..e7343829f 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -22220,7 +22220,7 @@ "required": true, "content": { "application/json": {"schema": {"$ref": "#/components/schemas/WebhookMessageRequest"}}, - "multipart/form-data": {"schema": {"$ref": "#/components/schemas/WebhookMessageRequest"}} + "multipart/form-data": {"schema": {"$ref": "#/components/schemas/WebhookMultipartMessageRequest"}} } } } @@ -34715,51 +34715,51 @@ "items": {"$ref": "#/components/schemas/RichEmbedRequest"}, "description": "Array of embed objects to include in the message" }, + "message_reference": { + "anyOf": [{"$ref": "#/components/schemas/MessageReferenceRequest"}, {"type": "null"}], + "description": "Reference to another message (for replies or forwards)" + }, + "allowed_mentions": { + "anyOf": [{"$ref": "#/components/schemas/AllowedMentionsRequest"}, {"type": "null"}], + "description": "Controls which mentions trigger notifications" + }, + "flags": {"$ref": "#/components/schemas/MessageFlags"}, + "nonce": {"$ref": "#/components/schemas/MessageNonceRequest"}, + "favorite_meme_id": { + "anyOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"type": "null"}], + "description": "ID of a favorite meme to attach" + }, + "sticker_ids": { + "anyOf": [ + {"type": "array", "items": {"$ref": "#/components/schemas/SnowflakeType"}, "maxItems": 3}, + {"type": "null"} + ], + "description": "Array of sticker IDs to include (max 3)" + }, + "tts": {"type": "boolean", "description": "Whether this is a text-to-speech message"}, + "username": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Override the default username of the webhook for this message" + }, + "avatar_url": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Override the default avatar URL of the webhook for this message" + }, "attachments": { "type": "array", - "items": { - "oneOf": [ - {"$ref": "#/components/schemas/ClientUploadedAttachmentRequest"}, - { - "type": "object", - "properties": { - "id": { - "oneOf": [ - {"$ref": "#/components/schemas/SnowflakeType"}, - {"$ref": "#/components/schemas/Int32Type"} - ], - "description": "Attachment ID for referencing uploaded files" - }, - "filename": {"type": "string", "description": "Name of the file (1-1024 characters)"}, - "description": { - "type": "string", - "description": "Description for the attachment (max 4096 characters)" - }, - "content_type": {"type": "string", "description": "MIME type of the file"}, - "upload_filename": {"not": {}}, - "file_size": {"not": {}}, - "size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, - "url": {"type": "string", "description": "URL of the attachment"}, - "proxy_url": {"type": "string", "description": "Proxied URL of the attachment"}, - "height": { - "type": "integer", - "format": "int53", - "description": "Height of the image/video in pixels" - }, - "width": { - "type": "integer", - "format": "int53", - "description": "Width of the image/video in pixels" - }, - "ephemeral": {"type": "boolean", "description": "Whether this attachment is ephemeral"}, - "duration": {"type": "number", "description": "Duration of audio file in seconds"}, - "waveform": {"type": "string", "description": "Base64-encoded bytearray of audio waveform"}, - "flags": {"$ref": "#/components/schemas/MessageAttachmentFlags"} - } - } - ] - }, - "description": "Array of attachment objects" + "items": {"$ref": "#/components/schemas/ClientUploadedAttachmentRequest"}, + "description": "Array of attachments uploaded through the presigned upload endpoint" + } + } + }, + "WebhookMultipartMessageRequest": { + "type": "object", + "properties": { + "content": {"anyOf": [{"$ref": "#/components/schemas/MessageContentRequest"}, {"type": "null"}]}, + "embeds": { + "type": "array", + "items": {"$ref": "#/components/schemas/RichEmbedRequest"}, + "description": "Array of embed objects to include in the message" }, "message_reference": { "anyOf": [{"$ref": "#/components/schemas/MessageReferenceRequest"}, {"type": "null"}], @@ -34790,6 +34790,34 @@ "avatar_url": { "anyOf": [{"type": "string"}, {"type": "null"}], "description": "Override the default avatar URL of the webhook for this message" + }, + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "oneOf": [{"$ref": "#/components/schemas/SnowflakeType"}, {"$ref": "#/components/schemas/Int32Type"}], + "description": "Attachment ID for referencing uploaded files" + }, + "filename": {"type": "string", "description": "Name of the file (1-1024 characters)"}, + "description": { + "type": "string", + "description": "Description for the attachment (max 4096 characters)" + }, + "content_type": {"type": "string", "description": "MIME type of the file"}, + "size": {"$ref": "#/components/schemas/NonNegativeSafeIntegerType"}, + "url": {"type": "string", "description": "URL of the attachment"}, + "proxy_url": {"type": "string", "description": "Proxied URL of the attachment"}, + "height": {"type": "integer", "format": "int53", "description": "Height of the image/video in pixels"}, + "width": {"type": "integer", "format": "int53", "description": "Width of the image/video in pixels"}, + "ephemeral": {"type": "boolean", "description": "Whether this attachment is ephemeral"}, + "duration": {"type": "number", "description": "Duration of audio file in seconds"}, + "waveform": {"type": "string", "description": "Base64-encoded bytearray of audio waveform"}, + "flags": {"$ref": "#/components/schemas/MessageAttachmentFlags"} + } + }, + "description": "Array of multipart attachment metadata objects" } } }, diff --git a/fluxer_api/src/api/webhook/WebhookController.ts b/fluxer_api/src/api/webhook/WebhookController.ts index d89f0fead..d92b203ee 100644 --- a/fluxer_api/src/api/webhook/WebhookController.ts +++ b/fluxer_api/src/api/webhook/WebhookController.ts @@ -19,6 +19,7 @@ import { WebhookExecuteQueryRequest, WebhookMessageEditRequest, WebhookMessageRequest, + WebhookMultipartMessageRequest, WebhookTokenUpdateRequest, WebhookUpdateRequest, } from '@fluxer/schema/src/domains/webhook/WebhookRequestSchemas'; @@ -47,6 +48,14 @@ function validateWebhookMessagePayload(data: unknown): WebhookMessageRequest { return validationResult.data; } +function validateWebhookMultipartMessagePayload(data: unknown): WebhookMultipartMessageRequest { + const validationResult = WebhookMultipartMessageRequest.safeParse(normalizeMessageRequestPayload(data)); + if (!validationResult.success) { + throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA); + } + return validationResult.data; +} + async function parseWebhookJsonMessageData(ctx: Context): Promise { let data: unknown; try { @@ -89,10 +98,11 @@ async function parseWebhookMultipartMessageData( if (!parsedPayload) { throw InputValidationError.fromCode('message_data', ValidationErrorCodes.INVALID_MESSAGE_DATA); } - const webhookData = validateWebhookMessagePayload(parsedPayload); + const webhookData = validateWebhookMultipartMessagePayload(parsedPayload); return { ...webhookData, ...messageData, + attachments: messageData.attachments, username: webhookData.username, avatar_url: webhookData.avatar_url, }; @@ -333,7 +343,7 @@ export function WebhookController(app: HonoApp) { 'Executes the webhook by sending a message to its configured channel. If the wait query parameter is true, returns the created message object; otherwise returns a 204 status with no content.', responseSchema: MessageResponseSchema, requestSchema: WebhookMessageRequest, - requestFormSchema: WebhookMessageRequest, + requestFormSchema: WebhookMultipartMessageRequest, statusCode: 200, tags: ['Webhooks'], }), diff --git a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts index 3aadc7d70..257af8129 100644 --- a/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts +++ b/packages/schema/src/domains/webhook/WebhookRequestSchemas.ts @@ -62,7 +62,7 @@ export const WebhookTokenUpdateRequest = z export type WebhookTokenUpdateRequest = z.infer; -const WebhookAttachmentRequest = z.object({ +const WebhookMultipartAttachmentRequest = z.object({ id: z .union([SnowflakeType, coerceNumberFromString(Int32Type)]) .optional() @@ -70,8 +70,6 @@ const WebhookAttachmentRequest = z.object({ filename: createStringType(1, 1024).optional().describe('Name of the file (1-1024 characters)'), description: createStringType(1, 4096).optional().describe('Description for the attachment (max 4096 characters)'), content_type: createStringType(1, 256).optional().describe('MIME type of the file'), - upload_filename: z.never().optional(), - file_size: z.never().optional(), size: NonNegativeSafeIntegerType.optional().describe('Size of the file in bytes'), url: URLType.optional().describe('URL of the attachment'), proxy_url: URLType.optional().describe('Proxied URL of the attachment'), @@ -88,14 +86,10 @@ const WebhookAttachmentRequest = z.object({ ).optional(), }); -export const WebhookMessageRequest = z +const WebhookMessageRequestBase = z .object({ content: MessageContentRequest.nullish(), embeds: z.array(RichEmbedRequest).optional().describe('Array of embed objects to include in the message'), - attachments: z - .array(z.union([ClientUploadedAttachmentRequest, WebhookAttachmentRequest])) - .optional() - .describe('Array of attachment objects'), message_reference: MessageReferenceRequest.nullish().describe( 'Reference to another message (for replies or forwards)', ), @@ -115,8 +109,24 @@ export const WebhookMessageRequest = z }) .partial(); +export const WebhookMessageRequest = WebhookMessageRequestBase.extend({ + attachments: z + .array(ClientUploadedAttachmentRequest) + .optional() + .describe('Array of attachments uploaded through the presigned upload endpoint'), +}); + export type WebhookMessageRequest = z.infer; +export const WebhookMultipartMessageRequest = WebhookMessageRequestBase.extend({ + attachments: z + .array(WebhookMultipartAttachmentRequest) + .optional() + .describe('Array of multipart attachment metadata objects'), +}); + +export type WebhookMultipartMessageRequest = z.infer; + export const WebhookMessageEditRequest = z .object({ content: MessageContentRequest.nullish().describe(