From 3a1239711deb3d86026c5382b97a40a3575a09e6 Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Wed, 2 Sep 2026 03:08:57 +0200 Subject: [PATCH] feat(format): spell Discord's message markup once Mentions, timestamps and custom emoji are assembled by hand wherever a message is built, and one assembled slightly wrong renders as literal text rather than failing: the bot shows `<@123>` to its users and nothing says why. Format holds the shapes so they are written correctly in one place. Timestamp styles are an enum rather than the single letter the API takes, since nothing about 'R' says "2 months ago" at a call site. --- src/Enums/TimestampStyle.php | 37 ++++++++++++ src/Format.php | 80 +++++++++++++++++++++++++ tests/FormatTest.php | 111 +++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/Enums/TimestampStyle.php create mode 100644 src/Format.php create mode 100644 tests/FormatTest.php diff --git a/src/Enums/TimestampStyle.php b/src/Enums/TimestampStyle.php new file mode 100644 index 0000000..d8fa874 --- /dev/null +++ b/src/Enums/TimestampStyle.php @@ -0,0 +1,37 @@ +` to its users and nothing says why. The shapes + * live here so they are spelled correctly once. + * + * @see https://discord.com/developers/docs/reference#message-formatting + */ +final class Format +{ + public static function user(string $userId): string + { + return '<@' . $userId . '>'; + } + + public static function role(string $roleId): string + { + return '<@&' . $roleId . '>'; + } + + public static function channel(string $channelId): string + { + return '<#' . $channelId . '>'; + } + + /** + * A slash command, rendered as a link that fills it in when clicked. + * + * The name is the full path as the user types it, so a subcommand is given + * as "voice room limit". + */ + public static function command(string $name, string $commandId): string + { + return ''; + } + + public static function emoji(string $name, string $emojiId, bool $animated = false): string + { + return '<' . ($animated ? 'a' : '') . ':' . $name . ':' . $emojiId . '>'; + } + + /** + * A moment, drawn in each reader's own timezone. + * + * A plain integer is a Unix timestamp in seconds, which is what Discord + * takes; anything else is asked for one. + */ + public static function timestamp( + DateTimeInterface|int $moment, + TimestampStyle $style = TimestampStyle::ShortDateTime, + ): string { + $seconds = $moment instanceof DateTimeInterface ? $moment->getTimestamp() : $moment; + + return 'value . '>'; + } + + public static function code(string $text): string + { + return '`' . $text . '`'; + } + + /** + * @param string|null $language a syntax highlighting hint, such as "php" + */ + public static function codeBlock(string $text, ?string $language = null): string + { + return '```' . ($language ?? '') . "\n" . $text . "\n" . '```'; + } +} diff --git a/tests/FormatTest.php b/tests/FormatTest.php new file mode 100644 index 0000000..4a84cd5 --- /dev/null +++ b/tests/FormatTest.php @@ -0,0 +1,111 @@ +assertSame('<@254766810296090626>', Format::user('254766810296090626')); + } + + public function testMentionsARole(): void + { + $this->assertSame('<@&979052732218503178>', Format::role('979052732218503178')); + } + + public function testMentionsAChannel(): void + { + $this->assertSame('<#979052732218503179>', Format::channel('979052732218503179')); + } + + /** + * A subcommand is mentioned by the whole path the user types, not just the + * leaf, and carries the id of the top level command it belongs to. + */ + public function testMentionsASubcommandByItsFullPath(): void + { + $this->assertSame('', Format::command('voice room limit', '12345')); + } + + public function testFormatsACustomEmoji(): void + { + $this->assertSame('<:apex:12345>', Format::emoji('apex', '12345')); + } + + public function testAnAnimatedEmojiIsMarkedAsOne(): void + { + $this->assertSame('', Format::emoji('apex', '12345', animated: true)); + } + + public function testATimestampIsTakenAsSeconds(): void + { + $this->assertSame('', Format::timestamp(1618928400)); + } + + public function testATimestampCanBeGivenAsADate(): void + { + $this->assertSame( + '', + Format::timestamp(new DateTimeImmutable('@1618928400')), + ); + } + + /** + * Discord's own default when a style is left off, so leaving it off here + * has to mean the same thing. + */ + public function testTheDefaultStyleIsShortDateTime(): void + { + $this->assertSame( + Format::timestamp(1618928400, TimestampStyle::ShortDateTime), + Format::timestamp(1618928400), + ); + } + + #[DataProvider('stylesProvider')] + public function testATimestampCarriesItsStyle(TimestampStyle $style, string $expected): void + { + $this->assertSame($expected, Format::timestamp(1618928400, $style)); + } + + public static function stylesProvider(): array + { + return [ + 'short time' => [TimestampStyle::ShortTime, ''], + 'long time' => [TimestampStyle::LongTime, ''], + 'short date' => [TimestampStyle::ShortDate, ''], + 'long date' => [TimestampStyle::LongDate, ''], + 'short date and time' => [TimestampStyle::ShortDateTime, ''], + 'long date and time' => [TimestampStyle::LongDateTime, ''], + 'relative' => [TimestampStyle::Relative, ''], + ]; + } + + public function testFormatsInlineCode(): void + { + $this->assertSame('`/warn`', Format::code('/warn')); + } + + /** + * The closing fence has to be on its own line, or a block whose content + * ends without a newline swallows it. + */ + public function testFormatsACodeBlock(): void + { + $this->assertSame("```\nechoed\n```", Format::codeBlock('echoed')); + } + + public function testACodeBlockCanNameItsLanguage(): void + { + $this->assertSame("```php\n\$x = 1;\n```", Format::codeBlock('$x = 1;', 'php')); + } +}