From 98cfa027036ab29a85f4068f6c93b8e92bcc4368 Mon Sep 17 00:00:00 2001 From: Refaltor77 Date: Mon, 20 Apr 2026 09:59:36 +0200 Subject: [PATCH 1/3] fix: Embed::addField off-by-one + centralised payload limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Embed::addField()` previously rejected only when the embed already had more than 25 fields (`count($this->fields) > 25`). With exactly 25 fields the check returned `false` and a 26th field was accepted, causing Discord to reject the resulting message with HTTP 400. Replaced the loose `> 25` check with `>= EMBED_FIELDS_MAX` and added the missing per-field validations that the docstring already promised: - Embed field name length → max 256 chars (EMBED_FIELD_NAME_MAX) - Embed field value length → max 1024 chars (EMBED_FIELD_VALUE_MAX) - Combined embed text length → max 6000 chars (EMBED_TOTAL_CHARS_MAX) Introduces `Discord\Helpers\ValidatesDiscordLimits`, an interface that centralises Discord's documented payload limits as constants. `Embed` now implements this interface, replacing the previously hard-coded literals (256, 4096, 2048, 6000…) at every call site. The interface also exposes message-level limits (content 2000, embeds 10, files 10) that other builders can adopt incrementally. --- .../Helpers/ValidatesDiscordLimits.php | 64 +++++++++++++++++++ src/Discord/Parts/Embed/Embed.php | 48 +++++++++----- 2 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 src/Discord/Helpers/ValidatesDiscordLimits.php diff --git a/src/Discord/Helpers/ValidatesDiscordLimits.php b/src/Discord/Helpers/ValidatesDiscordLimits.php new file mode 100644 index 000000000..29fcf7ed0 --- /dev/null +++ b/src/Discord/Helpers/ValidatesDiscordLimits.php @@ -0,0 +1,64 @@ + + * Copyright (c) 2020-present Valithor Obsidion + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers; + +/** + * Centralises Discord API payload size limits as shared constants. + * + * Classes that produce or validate Discord-bound payloads implement this + * interface to expose consistent limits in a single location. Grouping + * them prevents the "magic number" drift that happens when the same + * limit is repeated across builders and parts. + * + * @link https://docs.discord.com/developers/resources/message#embed-object-embed-limits + * @link https://docs.discord.com/developers/resources/message#create-message + * + * @since 10.48.0 + */ +interface ValidatesDiscordLimits +{ + /** Maximum characters in an embed title. */ + public const EMBED_TITLE_MAX = 256; + + /** Maximum characters in an embed description. */ + public const EMBED_DESCRIPTION_MAX = 4096; + + /** Maximum characters in an embed author name. */ + public const EMBED_AUTHOR_NAME_MAX = 256; + + /** Maximum characters in an embed footer text. */ + public const EMBED_FOOTER_TEXT_MAX = 2048; + + /** Maximum characters in an embed field name. */ + public const EMBED_FIELD_NAME_MAX = 256; + + /** Maximum characters in an embed field value. */ + public const EMBED_FIELD_VALUE_MAX = 1024; + + /** Maximum number of fields in an embed. */ + public const EMBED_FIELDS_MAX = 25; + + /** Maximum combined characters across all embed text fields. */ + public const EMBED_TOTAL_CHARS_MAX = 6000; + + /** Maximum characters in a message content field. */ + public const MESSAGE_CONTENT_MAX = 2000; + + /** Maximum embeds attached to a single message. */ + public const MESSAGE_EMBEDS_MAX = 10; + + /** Maximum file attachments on a single message. */ + public const MESSAGE_FILES_MAX = 10; +} diff --git a/src/Discord/Parts/Embed/Embed.php b/src/Discord/Parts/Embed/Embed.php index 759e16460..33d5cae4c 100644 --- a/src/Discord/Parts/Embed/Embed.php +++ b/src/Discord/Parts/Embed/Embed.php @@ -16,6 +16,7 @@ use Carbon\Carbon; use Discord\Helpers\ExCollectionInterface; +use Discord\Helpers\ValidatesDiscordLimits; use Discord\Parts\Channel\Attachment; use Discord\Parts\Part; @@ -44,7 +45,7 @@ * @property ?ExCollectionInterface|Field[] $fields A collection of embed fields (max of 25). * @property ?int|null $flags Embedded flags combined as a bitfield. */ -class Embed extends Part +class Embed extends Part implements ValidatesDiscordLimits { public const TYPES = [ 0 => Embed::class, // Fallback for unknown types @@ -211,11 +212,11 @@ protected function setDescriptionAttribute($description): void { if (poly_strlen($description) === 0) { $this->attributes['description'] = null; - } elseif (poly_strlen($description) > 4096) { - throw new \LengthException('Embed description can not be longer than 4096 characters'); + } elseif (poly_strlen($description) > self::EMBED_DESCRIPTION_MAX) { + throw new \LengthException('Embed description can not be longer than '.self::EMBED_DESCRIPTION_MAX.' characters'); } else { if ($this->exceedsOverallLimit(poly_strlen($description))) { - throw new \LengthException('Embed text values collectively can not exceed than 6000 characters'); + throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); } $this->attributes['description'] = $description; @@ -253,10 +254,10 @@ protected function setTitleAttribute(string $title): self { if (poly_strlen($title) === 0) { $this->attributes['title'] = null; - } elseif (poly_strlen($title) > 256) { - throw new \LengthException('Embed title can not be longer than 256 characters'); + } elseif (poly_strlen($title) > self::EMBED_TITLE_MAX) { + throw new \LengthException('Embed title can not be longer than '.self::EMBED_TITLE_MAX.' characters'); } elseif ($this->exceedsOverallLimit(poly_strlen($title))) { - throw new \LengthException('Embed text values collectively can not exceed than 6000 characters'); + throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); } else { $this->attributes['title'] = $title; } @@ -334,14 +335,29 @@ public function setColor($color): self public function addField(...$fields): self { foreach ($fields as $field) { - if (count($this->fields) > 25) { - throw new \OverflowException('Embeds can not have more than 25 fields.'); + if (count($this->fields) >= self::EMBED_FIELDS_MAX) { + throw new \OverflowException('Embeds can not have more than '.self::EMBED_FIELDS_MAX.' fields.'); } if ($field instanceof Field) { $field = $field->getRawAttributes(); } + $name = is_string($field['name'] ?? null) ? $field['name'] : ''; + $value = is_string($field['value'] ?? null) ? $field['value'] : ''; + + if (poly_strlen($name) > self::EMBED_FIELD_NAME_MAX) { + throw new \LengthException('Embed field name can not be longer than '.self::EMBED_FIELD_NAME_MAX.' characters'); + } + + if (poly_strlen($value) > self::EMBED_FIELD_VALUE_MAX) { + throw new \LengthException('Embed field value can not be longer than '.self::EMBED_FIELD_VALUE_MAX.' characters'); + } + + if ($this->exceedsOverallLimit(poly_strlen($name) + poly_strlen($value))) { + throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + } + $this->attributes['fields'][] = $field; } @@ -385,10 +401,10 @@ public function setAuthor(string $name, $iconurl = null, ?string $url = null): s $length = poly_strlen($name); if ($length === 0) { $this->author = null; - } elseif ($length > 256) { - throw new \LengthException('Author name can not be longer than 256 characters.'); + } elseif ($length > self::EMBED_AUTHOR_NAME_MAX) { + throw new \LengthException('Author name can not be longer than '.self::EMBED_AUTHOR_NAME_MAX.' characters.'); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException('Embed text values collectively can not exceed than 6000 characters'); + throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); } if ($iconurl instanceof Attachment) { @@ -424,10 +440,10 @@ public function setFooter(string $text, $iconurl = null): self $length = poly_strlen($text); if ($length === 0) { $this->footer = null; - } elseif ($length > 2048) { - throw new \LengthException('Footer text can not be longer than 2048 characters.'); + } elseif ($length > self::EMBED_FOOTER_TEXT_MAX) { + throw new \LengthException('Footer text can not be longer than '.self::EMBED_FOOTER_TEXT_MAX.' characters.'); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException('Embed text values collectively can not exceed than 6000 characters'); + throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); } if ($iconurl instanceof Attachment) { @@ -556,7 +572,7 @@ protected function exceedsOverallLimit(int $addition): bool $total += poly_strlen($field['value']); } - return ($total > 6000); + return ($total > self::EMBED_TOTAL_CHARS_MAX); } /** From 4035376b69783239503950d3ca50632f3aa4c391 Mon Sep 17 00:00:00 2001 From: Refaltor77 Date: Mon, 20 Apr 2026 10:08:45 +0200 Subject: [PATCH 2/3] style: use sprintf() in embed exception messages to satisfy Codacy Codacy's WordPress XSS ruleset (WordPress.Security.EscapeOutput) flags any `self::CONST` concatenated directly into an exception message as unescaped output, producing 8+ CRITICAL false positives on this file. Switched all touched `throw` sites to `sprintf('... %d ...', self::CONST)`. Message text is byte-identical, behaviour unchanged. --- src/Discord/Parts/Embed/Embed.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Discord/Parts/Embed/Embed.php b/src/Discord/Parts/Embed/Embed.php index 33d5cae4c..1ddce932f 100644 --- a/src/Discord/Parts/Embed/Embed.php +++ b/src/Discord/Parts/Embed/Embed.php @@ -213,10 +213,10 @@ protected function setDescriptionAttribute($description): void if (poly_strlen($description) === 0) { $this->attributes['description'] = null; } elseif (poly_strlen($description) > self::EMBED_DESCRIPTION_MAX) { - throw new \LengthException('Embed description can not be longer than '.self::EMBED_DESCRIPTION_MAX.' characters'); + throw new \LengthException(sprintf('Embed description can not be longer than %d characters', self::EMBED_DESCRIPTION_MAX)); } else { if ($this->exceedsOverallLimit(poly_strlen($description))) { - throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); } $this->attributes['description'] = $description; @@ -255,9 +255,9 @@ protected function setTitleAttribute(string $title): self if (poly_strlen($title) === 0) { $this->attributes['title'] = null; } elseif (poly_strlen($title) > self::EMBED_TITLE_MAX) { - throw new \LengthException('Embed title can not be longer than '.self::EMBED_TITLE_MAX.' characters'); + throw new \LengthException(sprintf('Embed title can not be longer than %d characters', self::EMBED_TITLE_MAX)); } elseif ($this->exceedsOverallLimit(poly_strlen($title))) { - throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); } else { $this->attributes['title'] = $title; } @@ -336,7 +336,7 @@ public function addField(...$fields): self { foreach ($fields as $field) { if (count($this->fields) >= self::EMBED_FIELDS_MAX) { - throw new \OverflowException('Embeds can not have more than '.self::EMBED_FIELDS_MAX.' fields.'); + throw new \OverflowException(sprintf('Embeds can not have more than %d fields.', self::EMBED_FIELDS_MAX)); } if ($field instanceof Field) { @@ -347,15 +347,15 @@ public function addField(...$fields): self $value = is_string($field['value'] ?? null) ? $field['value'] : ''; if (poly_strlen($name) > self::EMBED_FIELD_NAME_MAX) { - throw new \LengthException('Embed field name can not be longer than '.self::EMBED_FIELD_NAME_MAX.' characters'); + throw new \LengthException(sprintf('Embed field name can not be longer than %d characters', self::EMBED_FIELD_NAME_MAX)); } if (poly_strlen($value) > self::EMBED_FIELD_VALUE_MAX) { - throw new \LengthException('Embed field value can not be longer than '.self::EMBED_FIELD_VALUE_MAX.' characters'); + throw new \LengthException(sprintf('Embed field value can not be longer than %d characters', self::EMBED_FIELD_VALUE_MAX)); } if ($this->exceedsOverallLimit(poly_strlen($name) + poly_strlen($value))) { - throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); } $this->attributes['fields'][] = $field; @@ -402,9 +402,9 @@ public function setAuthor(string $name, $iconurl = null, ?string $url = null): s if ($length === 0) { $this->author = null; } elseif ($length > self::EMBED_AUTHOR_NAME_MAX) { - throw new \LengthException('Author name can not be longer than '.self::EMBED_AUTHOR_NAME_MAX.' characters.'); + throw new \LengthException(sprintf('Author name can not be longer than %d characters.', self::EMBED_AUTHOR_NAME_MAX)); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); } if ($iconurl instanceof Attachment) { @@ -441,9 +441,9 @@ public function setFooter(string $text, $iconurl = null): self if ($length === 0) { $this->footer = null; } elseif ($length > self::EMBED_FOOTER_TEXT_MAX) { - throw new \LengthException('Footer text can not be longer than '.self::EMBED_FOOTER_TEXT_MAX.' characters.'); + throw new \LengthException(sprintf('Footer text can not be longer than %d characters.', self::EMBED_FOOTER_TEXT_MAX)); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException('Embed text values collectively can not exceed '.self::EMBED_TOTAL_CHARS_MAX.' characters'); + throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); } if ($iconurl instanceof Attachment) { From 4e7a6445b97fe78dbb40a44409086aca626928db Mon Sep 17 00:00:00 2001 From: Refaltor77 Date: Mon, 20 Apr 2026 10:13:55 +0200 Subject: [PATCH 3/3] style: hardcode Discord limits in exception messages (Codacy XSS FP) Codacy's WordPress.Security.EscapeOutput rule flags any `self::CONST` referenced inside an exception argument, whether via concatenation or sprintf(), as unescaped output. False positive for a non-WordPress library, but the project enforces zero new issues. Keep the centralised constants where they carry real refactor value (the `if` comparison conditions) and inline the literal Discord API limits in the user-facing message strings. Matches the surrounding upstream style and clears the 8 CRITICAL Codacy findings on this PR. --- src/Discord/Parts/Embed/Embed.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Discord/Parts/Embed/Embed.php b/src/Discord/Parts/Embed/Embed.php index 1ddce932f..40275f039 100644 --- a/src/Discord/Parts/Embed/Embed.php +++ b/src/Discord/Parts/Embed/Embed.php @@ -213,10 +213,10 @@ protected function setDescriptionAttribute($description): void if (poly_strlen($description) === 0) { $this->attributes['description'] = null; } elseif (poly_strlen($description) > self::EMBED_DESCRIPTION_MAX) { - throw new \LengthException(sprintf('Embed description can not be longer than %d characters', self::EMBED_DESCRIPTION_MAX)); + throw new \LengthException('Embed description can not be longer than 4096 characters'); } else { if ($this->exceedsOverallLimit(poly_strlen($description))) { - throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); + throw new \LengthException('Embed text values collectively can not exceed 6000 characters'); } $this->attributes['description'] = $description; @@ -255,9 +255,9 @@ protected function setTitleAttribute(string $title): self if (poly_strlen($title) === 0) { $this->attributes['title'] = null; } elseif (poly_strlen($title) > self::EMBED_TITLE_MAX) { - throw new \LengthException(sprintf('Embed title can not be longer than %d characters', self::EMBED_TITLE_MAX)); + throw new \LengthException('Embed title can not be longer than 256 characters'); } elseif ($this->exceedsOverallLimit(poly_strlen($title))) { - throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); + throw new \LengthException('Embed text values collectively can not exceed 6000 characters'); } else { $this->attributes['title'] = $title; } @@ -336,7 +336,7 @@ public function addField(...$fields): self { foreach ($fields as $field) { if (count($this->fields) >= self::EMBED_FIELDS_MAX) { - throw new \OverflowException(sprintf('Embeds can not have more than %d fields.', self::EMBED_FIELDS_MAX)); + throw new \OverflowException('Embeds can not have more than 25 fields.'); } if ($field instanceof Field) { @@ -347,15 +347,15 @@ public function addField(...$fields): self $value = is_string($field['value'] ?? null) ? $field['value'] : ''; if (poly_strlen($name) > self::EMBED_FIELD_NAME_MAX) { - throw new \LengthException(sprintf('Embed field name can not be longer than %d characters', self::EMBED_FIELD_NAME_MAX)); + throw new \LengthException('Embed field name can not be longer than 256 characters'); } if (poly_strlen($value) > self::EMBED_FIELD_VALUE_MAX) { - throw new \LengthException(sprintf('Embed field value can not be longer than %d characters', self::EMBED_FIELD_VALUE_MAX)); + throw new \LengthException('Embed field value can not be longer than 1024 characters'); } if ($this->exceedsOverallLimit(poly_strlen($name) + poly_strlen($value))) { - throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); + throw new \LengthException('Embed text values collectively can not exceed 6000 characters'); } $this->attributes['fields'][] = $field; @@ -402,9 +402,9 @@ public function setAuthor(string $name, $iconurl = null, ?string $url = null): s if ($length === 0) { $this->author = null; } elseif ($length > self::EMBED_AUTHOR_NAME_MAX) { - throw new \LengthException(sprintf('Author name can not be longer than %d characters.', self::EMBED_AUTHOR_NAME_MAX)); + throw new \LengthException('Author name can not be longer than 256 characters.'); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); + throw new \LengthException('Embed text values collectively can not exceed 6000 characters'); } if ($iconurl instanceof Attachment) { @@ -441,9 +441,9 @@ public function setFooter(string $text, $iconurl = null): self if ($length === 0) { $this->footer = null; } elseif ($length > self::EMBED_FOOTER_TEXT_MAX) { - throw new \LengthException(sprintf('Footer text can not be longer than %d characters.', self::EMBED_FOOTER_TEXT_MAX)); + throw new \LengthException('Footer text can not be longer than 2048 characters.'); } elseif ($this->exceedsOverallLimit($length)) { - throw new \LengthException(sprintf('Embed text values collectively can not exceed %d characters', self::EMBED_TOTAL_CHARS_MAX)); + throw new \LengthException('Embed text values collectively can not exceed 6000 characters'); } if ($iconurl instanceof Attachment) {