Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Enums/TimestampStyle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Tempcord\Discord\Enums;

/**
* How Discord renders a timestamp.
*
* The client draws these in each reader's own timezone and locale, so a time
* written this way is correct for everyone who sees it.
*
* @see https://discord.com/developers/docs/reference#message-formatting-timestamp-styles
*/
enum TimestampStyle: string
{
/** 16:20 */
case ShortTime = 't';

/** 16:20:30 */
case LongTime = 'T';

/** 20/04/2021 */
case ShortDate = 'd';

/** 20 April 2021 */
case LongDate = 'D';

/** 20 April 2021 16:20 — what Discord uses when no style is given. */
case ShortDateTime = 'f';

/** Tuesday, 20 April 2021 16:20 */
case LongDateTime = 'F';

/** 2 months ago */
case Relative = 'R';
}
80 changes: 80 additions & 0 deletions src/Format.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Tempcord\Discord;

use DateTimeInterface;
use Tempcord\Discord\Enums\TimestampStyle;

/**
* Discord's message markup.
*
* Mentions, timestamps and emoji are assembled by hand wherever a message is
* built, and one assembled slightly wrong renders as literal text rather than
* failing — a bot shows `<@123>` 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 '</' . $name . ':' . $commandId . '>';
}

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 '<t:' . $seconds . ':' . $style->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" . '```';
}
}
111 changes: 111 additions & 0 deletions tests/FormatTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

namespace Tests\Tempcord\Discord;

use DateTimeImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Tempcord\Discord\Enums\TimestampStyle;
use Tempcord\Discord\Format;

class FormatTest extends TestCase
{
public function testMentionsAUser(): void
{
$this->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('</voice room limit:12345>', Format::command('voice room limit', '12345'));
}

public function testFormatsACustomEmoji(): void
{
$this->assertSame('<:apex:12345>', Format::emoji('apex', '12345'));
}

public function testAnAnimatedEmojiIsMarkedAsOne(): void
{
$this->assertSame('<a:apex:12345>', Format::emoji('apex', '12345', animated: true));
}

public function testATimestampIsTakenAsSeconds(): void
{
$this->assertSame('<t:1618928400:f>', Format::timestamp(1618928400));
}

public function testATimestampCanBeGivenAsADate(): void
{
$this->assertSame(
'<t:1618928400:f>',
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, '<t:1618928400:t>'],
'long time' => [TimestampStyle::LongTime, '<t:1618928400:T>'],
'short date' => [TimestampStyle::ShortDate, '<t:1618928400:d>'],
'long date' => [TimestampStyle::LongDate, '<t:1618928400:D>'],
'short date and time' => [TimestampStyle::ShortDateTime, '<t:1618928400:f>'],
'long date and time' => [TimestampStyle::LongDateTime, '<t:1618928400:F>'],
'relative' => [TimestampStyle::Relative, '<t:1618928400:R>'],
];
}

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'));
}
}