feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
|
|
|
use App\Exceptions\PlatformUnavailableException;
|
|
|
|
|
use App\Exceptions\Social\DiscordPublishException;
|
|
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
2026-06-16 18:04:46 +00:00
|
|
|
use App\Services\Media\MediaOptimizer;
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
use App\Services\Social\Discord\DiscordPublisher;
|
2026-06-16 18:53:20 +00:00
|
|
|
use Illuminate\Support\Facades\Cache;
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
beforeEach(function () {
|
2026-06-16 18:53:20 +00:00
|
|
|
Cache::flush(); // channel list is cached per guild — isolate between tests
|
2026-06-16 19:34:02 +00:00
|
|
|
config([
|
|
|
|
|
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
|
|
|
|
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
|
|
|
|
]);
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
|
|
|
|
|
$this->user = User::factory()->create();
|
|
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->discord()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform_user_id' => '111222333', // guild id
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'content' => 'Hello Discord',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->makePostPlatform = fn (array $meta = ['channel_id' => '444555666']) => PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'platform' => Platform::Discord,
|
|
|
|
|
'content_type' => ContentType::DiscordMessage,
|
|
|
|
|
'meta' => $meta,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher = new DiscordPublisher;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200): void
|
|
|
|
|
{
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([
|
|
|
|
|
['id' => '444555666', 'name' => 'general', 'type' => 0],
|
|
|
|
|
], 200),
|
2026-06-16 19:34:02 +00:00
|
|
|
// @everyone (role id == guild id) grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048).
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([
|
|
|
|
|
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
|
|
|
|
], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response($messageResponse, $status),
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('publishes a text-only message and suppresses accidental pings', function () {
|
|
|
|
|
fakeDiscord();
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('777');
|
|
|
|
|
expect($result['url'])->toBe('https://discord.com/channels/111222333/444555666/777');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false; // only the message POST may satisfy this assertion
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $request->hasHeader('Authorization', 'Bot BOTTOKEN')
|
|
|
|
|
&& data_get($request->data(), 'content') === 'Hello Discord'
|
|
|
|
|
// No mention chips → parse is empty, so typed "@everyone" never pings.
|
|
|
|
|
&& data_get($request->data(), 'allowed_mentions.parse') === [];
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('builds allowed_mentions from explicit mention chips only', function () {
|
|
|
|
|
fakeDiscord();
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)([
|
|
|
|
|
'channel_id' => '444555666',
|
|
|
|
|
'mentions' => [
|
|
|
|
|
['token' => '@everyone', 'label' => '@everyone'],
|
|
|
|
|
['token' => '<@&10>', 'label' => '@moderators'],
|
|
|
|
|
],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$allowed = data_get($request->data(), 'allowed_mentions');
|
|
|
|
|
|
|
|
|
|
return data_get($allowed, 'parse') === ['everyone']
|
|
|
|
|
&& data_get($allowed, 'roles') === ['10']
|
|
|
|
|
&& data_get($request->data(), 'content') === "Hello Discord\n\n@everyone <@&10>";
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('includes rich embeds with clamped color', function () {
|
|
|
|
|
fakeDiscord(['id' => '888']);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)([
|
|
|
|
|
'channel_id' => '444555666',
|
|
|
|
|
'embeds' => [[
|
|
|
|
|
'title' => 'Release v2',
|
|
|
|
|
'description' => 'Shipped today',
|
|
|
|
|
'color' => '#5865F2',
|
|
|
|
|
'image' => 'https://example.com/banner.png',
|
|
|
|
|
]],
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$embed = data_get($request->data(), 'embeds.0');
|
|
|
|
|
|
|
|
|
|
return data_get($embed, 'title') === 'Release v2'
|
|
|
|
|
&& data_get($embed, 'color') === hexdec('5865F2')
|
|
|
|
|
&& data_get($embed, 'image.url') === 'https://example.com/banner.png';
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 18:04:46 +00:00
|
|
|
test('uploads media as a multipart attachment', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'm1',
|
|
|
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pic.jpg',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
|
|
|
->shouldReceive('optimizeImage')
|
|
|
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
2026-06-16 19:34:02 +00:00
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
2026-06-16 18:04:46 +00:00
|
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '901'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = $this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
expect($result['id'])->toBe('901');
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$names = collect($request->data())->pluck('name');
|
|
|
|
|
|
|
|
|
|
return $names->contains('payload_json') && $names->contains('files[0]');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 12:34:57 +00:00
|
|
|
test('sets the attachment description from image alt text, capped at the platform max', function () {
|
|
|
|
|
$longAlt = str_repeat('a', 2000);
|
|
|
|
|
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'm1',
|
|
|
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pic.jpg',
|
|
|
|
|
'meta' => ['alt_text' => $longAlt],
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
|
|
|
->shouldReceive('optimizeImage')
|
|
|
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
|
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '902'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
$expectedAlt = mb_substr($longAlt, 0, Platform::Discord->altTextMaxLength());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) use ($expectedAlt) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
|
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
|
|
|
|
|
|
return data_get($payload, 'attachments.0.description') === $expectedAlt
|
|
|
|
|
&& mb_strlen($expectedAlt) === Platform::Discord->altTextMaxLength();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('omits the attachment description when the image has no alt text', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'm1',
|
|
|
|
|
'path' => 'media/2026-01/pic.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pic.jpg',
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->mock(MediaOptimizer::class)
|
|
|
|
|
->shouldReceive('optimizeImage')
|
|
|
|
|
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
|
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '903'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
|
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
|
|
|
|
|
|
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.
Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.
Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.
Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.
Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 16:55:33 +00:00
|
|
|
test('does not set a description on a non-image attachment even if it carries alt text', function () {
|
|
|
|
|
$this->post->update([
|
|
|
|
|
'media' => [[
|
|
|
|
|
'id' => 'm1',
|
|
|
|
|
'path' => 'media/2026-01/clip.mp4',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/clip.mp4',
|
|
|
|
|
'mime_type' => 'video/mp4',
|
|
|
|
|
'original_filename' => 'clip.mp4',
|
|
|
|
|
'meta' => ['alt_text' => 'alt text must not be sent for a video'],
|
|
|
|
|
]],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
|
|
|
|
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
|
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '904'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(function ($request) {
|
|
|
|
|
if (! str_contains($request->url(), '/messages')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
$payloadPart = collect($request->data())->firstWhere('name', 'payload_json');
|
|
|
|
|
$payload = json_decode(data_get($payloadPart, 'contents'), true);
|
|
|
|
|
|
|
|
|
|
return ! array_key_exists('description', data_get($payload, 'attachments.0'));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
test('throws when no channel is selected', function () {
|
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(meta: [])))
|
|
|
|
|
->toThrow(DiscordPublishException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('throws when the channel is not part of the guild', function () {
|
|
|
|
|
fakeDiscord();
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(['channel_id' => '999999999'])))
|
|
|
|
|
->toThrow(DiscordPublishException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('retries (does not permanently fail) when the channel lookup is transiently down', function () {
|
|
|
|
|
Http::fake([
|
|
|
|
|
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response('upstream down', 500),
|
|
|
|
|
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '777'], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// A 5xx on the channel guard must surface as PlatformUnavailableException so
|
|
|
|
|
// the publish job reschedules — not a permanent "channel not in server" fail.
|
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
|
|
|
|
->toThrow(PlatformUnavailableException::class);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('maps a missing-permission error to a publish exception', function () {
|
|
|
|
|
fakeDiscord(['code' => 50013, 'message' => 'Missing Permissions'], 403);
|
|
|
|
|
|
|
|
|
|
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
|
|
|
|
->toThrow(DiscordPublishException::class);
|
|
|
|
|
});
|
Defuse links in X posts to avoid the link-post fee (#308)
X bills a post containing a URL at a much higher rate than a plain post, and
its algorithm demotes link posts. The X version of a post now rewrites every
URL non-clickable (https://example.com/post becomes example(.)com/post):
scheme and www. dropped, every dot of the host replaced with (.).
Leaving a single dot intact would still leave a resolvable domain for X to
detect, so all of them are broken. A scheme or www. proves a token is a URL on
its own; a bare host only counts when its last label is a delegated TLD, which
is the one thing telling acme.com apart from Node.js. That check runs against
App\Support\LinkTlds, generated from the whole IANA root zone in every form a
TLD can appear in a post -- ASCII, punycode and the Unicode it decodes to --
because whatever X links is what X bills, so a hand-picked subset would leave
us paying for its gaps. If the regex engine bails out on pathological input the
original content is returned instead of crashing the publisher.
The transform lives in the Platform::X arm of ContentSanitizer, so it reaches
publishing and the app/API/MCP previews from one place and cannot touch any
other network. Off by default; opt in with X_DEFUSE_LINKS.
The editor counts characters and renders its preview client-side and cannot ask
the server on every keystroke, so the rewrite is mirrored in TypeScript. PHP
stays the source of truth: a parity test fails if the two TLD sets drift, and a
browser test drives the real editor so the mirror is covered rather than
assumed. Without it the composer promised text the network never receives.
Character limits now measure the text a reader will see: sanitized, then with
markup resolved away. Measuring the raw draft blocked saving posts that publish
fine and let through posts the network rejects, and counted the editor's HTML
toward the limit. Measuring the sanitized form alone would have counted
Telegram's escaped entities, rejecting messages Telegram accepts.
Empty content is handled once inside the sanitizer instead of by a guard
repeated at every call site.
2026-08-29 18:31:05 +00:00
|
|
|
|
|
|
|
|
test('discord publisher keeps links intact', function () {
|
|
|
|
|
config()->set('trypost.platforms.x.defuse_links', true);
|
|
|
|
|
|
|
|
|
|
$this->post->update(['content' => 'New post: https://acme.com/blog']);
|
|
|
|
|
|
|
|
|
|
fakeDiscord();
|
|
|
|
|
|
|
|
|
|
$this->publisher->publish(($this->makePostPlatform)());
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/messages')
|
|
|
|
|
&& data_get($request->data(), 'content') === 'New post: https://acme.com/blog');
|
|
|
|
|
});
|