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.
309 lines
10 KiB
PHP
309 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Social\Discord;
|
|
|
|
use App\DataTransferObjects\MediaItem;
|
|
use App\Enums\Media\Type as MediaType;
|
|
use App\Enums\SocialAccount\Platform;
|
|
use App\Exceptions\Social\DiscordPublishException;
|
|
use App\Exceptions\Social\ErrorCategory;
|
|
use App\Models\PostPlatform;
|
|
use App\Services\Media\MediaOptimizer;
|
|
use App\Services\Social\Concerns\HasSocialHttpClient;
|
|
use App\Services\Social\ContentSanitizer;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Throwable;
|
|
|
|
class DiscordPublisher
|
|
{
|
|
use HasSocialHttpClient;
|
|
|
|
/**
|
|
* Discord's hard message ceiling — also the maxContentLength() for the
|
|
* platform, re-checked here because mentions are appended after validation.
|
|
*/
|
|
private const MAX_MESSAGE_LENGTH = 2000;
|
|
|
|
private const MAX_EMBED_COLOR = 0xFFFFFF;
|
|
|
|
public function publish(PostPlatform $postPlatform): array
|
|
{
|
|
$this->validateContentLength($postPlatform);
|
|
|
|
$account = $postPlatform->socialAccount;
|
|
$guildId = (string) $account->platform_user_id;
|
|
$channelId = (string) data_get($postPlatform->meta, 'channel_id');
|
|
|
|
if ($channelId === '') {
|
|
throw new DiscordPublishException(
|
|
userMessage: 'No Discord channel selected for this post.',
|
|
category: ErrorCategory::Unknown,
|
|
);
|
|
}
|
|
|
|
// Posting uses the global bot token, so the channel id is the only thing
|
|
// scoping where a message lands — verify it belongs to THIS connected
|
|
// server, otherwise a crafted channel_id could post into any server the
|
|
// bot is in (including another workspace's).
|
|
$this->guardChannelBelongsToGuild($guildId, $channelId);
|
|
|
|
$content = $postPlatform->post->content
|
|
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
|
|
: '';
|
|
|
|
$content = $this->appendMentions($content, $postPlatform);
|
|
|
|
if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) {
|
|
throw new DiscordPublishException(
|
|
userMessage: 'The message (including mentions) exceeds Discord\'s 2000-character limit.',
|
|
category: ErrorCategory::Unknown,
|
|
);
|
|
}
|
|
|
|
$embeds = $this->buildEmbeds($postPlatform);
|
|
$media = $postPlatform->post->mediaItems->take($postPlatform->platform->maxImages());
|
|
|
|
$payload = array_filter([
|
|
'content' => $content !== '' ? $content : null,
|
|
'embeds' => $embeds !== [] ? $embeds : null,
|
|
'allowed_mentions' => $this->allowedMentions($postPlatform),
|
|
], fn ($value) => $value !== null);
|
|
|
|
$messageId = $media->isEmpty()
|
|
? $this->send($channelId, $payload)
|
|
: $this->sendWithMedia($channelId, $payload, $media);
|
|
|
|
return [
|
|
'id' => $messageId,
|
|
'url' => "https://discord.com/channels/{$guildId}/{$channelId}/{$messageId}",
|
|
];
|
|
}
|
|
|
|
private function guardChannelBelongsToGuild(string $guildId, string $channelId): void
|
|
{
|
|
$allowed = collect(app(DiscordClient::class)->channels($guildId))
|
|
->pluck('id')
|
|
->contains($channelId);
|
|
|
|
if (! $allowed) {
|
|
throw new DiscordPublishException(
|
|
userMessage: 'The bot can\'t post in the selected channel. Make sure it still exists, belongs to this server, and the bot has permission to send messages there.',
|
|
category: ErrorCategory::Permission,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function send(string $channelId, array $payload): string
|
|
{
|
|
return $this->messageId($this->bot()->post($this->endpoint($channelId), $payload));
|
|
}
|
|
|
|
/**
|
|
* Downloads each media item, optimizes images for Discord, and uploads them
|
|
* as multipart attachments alongside the JSON payload.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
* @param Collection<int, MediaItem> $media
|
|
*/
|
|
private function sendWithMedia(string $channelId, array $payload, Collection $media): string
|
|
{
|
|
$request = $this->bot();
|
|
$attachments = [];
|
|
$tempFiles = [];
|
|
|
|
try {
|
|
foreach ($media->values() as $index => $item) {
|
|
$tempFile = $this->downloadMedia($item);
|
|
$tempFiles[] = $tempFile;
|
|
|
|
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
|
|
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
|
|
|
|
$attachment = ['id' => $index, 'filename' => $filename];
|
|
|
|
$alt = $item->isImage() ? $item->altTextFor(Platform::Discord) : null;
|
|
|
|
if ($alt !== null) {
|
|
$attachment['description'] = $alt;
|
|
}
|
|
|
|
$attachments[] = $attachment;
|
|
}
|
|
|
|
$payload['attachments'] = $attachments;
|
|
|
|
return $this->messageId($request->post($this->endpoint($channelId), [
|
|
'payload_json' => json_encode($payload),
|
|
]));
|
|
} finally {
|
|
foreach ($tempFiles as $tempFile) {
|
|
@unlink($tempFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
private function downloadMedia(MediaItem $item): string
|
|
{
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'discord_media_');
|
|
|
|
$response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->url);
|
|
|
|
if ($response->failed() || (filesize($tempFile) ?: 0) === 0) {
|
|
@unlink($tempFile);
|
|
|
|
throw new DiscordPublishException(
|
|
userMessage: 'Failed to download a media file for this Discord post.',
|
|
category: ErrorCategory::MediaFormat,
|
|
);
|
|
}
|
|
|
|
// Images are downsized to Discord's limit; videos/gifs are sent as-is.
|
|
if ($item->isImage() && ! MediaType::isGif($item->mime_type)) {
|
|
try {
|
|
return app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Discord);
|
|
} catch (Throwable) {
|
|
throw new DiscordPublishException(
|
|
userMessage: 'Failed to process a media image for this Discord post.',
|
|
category: ErrorCategory::MediaFormat,
|
|
);
|
|
} finally {
|
|
@unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
return $tempFile;
|
|
}
|
|
|
|
/**
|
|
* Appends the post's Discord mention tokens to the content. Mentions live in
|
|
* meta — not the shared content — so they only ping on Discord and don't leak
|
|
* literal `<@id>` markup to other platforms.
|
|
*/
|
|
private function appendMentions(string $content, PostPlatform $postPlatform): string
|
|
{
|
|
$tokens = $this->mentionTokens($postPlatform)->implode(' ');
|
|
|
|
if ($tokens === '') {
|
|
return $content;
|
|
}
|
|
|
|
return $content === '' ? $tokens : "{$content}\n\n{$tokens}";
|
|
}
|
|
|
|
/**
|
|
* Builds an explicit allowed_mentions object from the post's mention chips so
|
|
* ONLY those targets ping. Defaults to `parse: []` (suppress everything), so a
|
|
* literal "@everyone" typed into the shared content never pings by accident.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function allowedMentions(PostPlatform $postPlatform): array
|
|
{
|
|
$parse = [];
|
|
$users = [];
|
|
$roles = [];
|
|
|
|
foreach ($this->mentionTokens($postPlatform) as $token) {
|
|
if ($token === '@everyone' || $token === '@here') {
|
|
$parse[] = 'everyone';
|
|
} elseif (preg_match('/^<@&(\d+)>$/', $token, $matches)) {
|
|
$roles[] = $matches[1];
|
|
} elseif (preg_match('/^<@(\d+)>$/', $token, $matches)) {
|
|
$users[] = $matches[1];
|
|
}
|
|
}
|
|
|
|
$allowed = ['parse' => array_values(array_unique($parse))];
|
|
|
|
if ($users !== []) {
|
|
$allowed['users'] = array_values(array_unique($users));
|
|
}
|
|
|
|
if ($roles !== []) {
|
|
$allowed['roles'] = array_values(array_unique($roles));
|
|
}
|
|
|
|
return $allowed;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, string>
|
|
*/
|
|
private function mentionTokens(PostPlatform $postPlatform): Collection
|
|
{
|
|
return collect((array) data_get($postPlatform->meta, 'mentions', []))
|
|
->map(fn ($mention) => trim((string) data_get($mention, 'token')))
|
|
->filter()
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function buildEmbeds(PostPlatform $postPlatform): array
|
|
{
|
|
return collect((array) data_get($postPlatform->meta, 'embeds', []))
|
|
->map(fn ($embed) => array_filter([
|
|
'title' => data_get($embed, 'title'),
|
|
'description' => data_get($embed, 'description'),
|
|
'url' => data_get($embed, 'url'),
|
|
'color' => $this->color(data_get($embed, 'color')),
|
|
'image' => ($image = data_get($embed, 'image')) ? ['url' => $image] : null,
|
|
], fn ($value) => $value !== null && $value !== ''))
|
|
->filter(fn ($embed) => $embed !== [])
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function color(mixed $color): ?int
|
|
{
|
|
if (! is_string($color) || $color === '') {
|
|
return null;
|
|
}
|
|
|
|
$hex = ltrim($color, '#');
|
|
|
|
if (! ctype_xdigit($hex) || strlen($hex) !== 6) {
|
|
return null;
|
|
}
|
|
|
|
return min((int) hexdec($hex), self::MAX_EMBED_COLOR);
|
|
}
|
|
|
|
private function endpoint(string $channelId): string
|
|
{
|
|
return config('trypost.platforms.discord.api')."/channels/{$channelId}/messages";
|
|
}
|
|
|
|
private function bot(): PendingRequest
|
|
{
|
|
return $this->socialHttp()
|
|
->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot');
|
|
}
|
|
|
|
private function messageId(Response $response): string
|
|
{
|
|
if ($response->failed()) {
|
|
throw DiscordPublishException::fromApiResponse($response);
|
|
}
|
|
|
|
$id = (string) data_get($response->json(), 'id');
|
|
|
|
if ($id === '') {
|
|
throw new DiscordPublishException(
|
|
userMessage: 'Discord returned an unexpected response with no message id.',
|
|
category: ErrorCategory::Unknown,
|
|
);
|
|
}
|
|
|
|
return $id;
|
|
}
|
|
}
|