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.
This commit is contained in:
Paulo Castellano 2026-06-16 14:44:00 -03:00
parent 7d52ea72c5
commit c2dd4515b2
38 changed files with 1745 additions and 1 deletions

View file

@ -53,6 +53,9 @@ enum ContentType: string
// Telegram
case TelegramPost = 'telegram_post';
// Discord
case DiscordMessage = 'discord_message';
/**
* AI generation format for an Instagram carousel. Not a content type —
* carousel posts are persisted as InstagramFeed.
@ -81,6 +84,7 @@ public function label(): string
self::BlueskyPost => 'Post',
self::MastodonPost => 'Post',
self::TelegramPost => 'Post',
self::DiscordMessage => 'Message',
};
}
@ -104,6 +108,7 @@ public function platform(): SocialPlatform
self::BlueskyPost => SocialPlatform::Bluesky,
self::MastodonPost => SocialPlatform::Mastodon,
self::TelegramPost => SocialPlatform::Telegram,
self::DiscordMessage => SocialPlatform::Discord,
};
}
@ -173,6 +178,7 @@ public function maxMediaCount(): int
self::BlueskyPost => 4,
self::MastodonPost => 4,
self::TelegramPost => 10,
self::DiscordMessage => 10,
};
}
@ -193,6 +199,7 @@ public function supportsVideo(): bool
self::BlueskyPost => true,
self::MastodonPost => true,
self::TelegramPost => true,
self::DiscordMessage => true,
};
}
@ -232,6 +239,7 @@ public function requiresMedia(): bool
self::TelegramPost => false,
self::FacebookPost => false,
self::InstagramFeed => false,
self::DiscordMessage => false,
default => true,
};
}
@ -313,6 +321,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::Bluesky => self::BlueskyPost,
SocialPlatform::Mastodon => self::MastodonPost,
SocialPlatform::Telegram => self::TelegramPost,
SocialPlatform::Discord => self::DiscordMessage,
};
}
}

View file

@ -21,6 +21,7 @@ enum Platform: string
case Bluesky = 'bluesky';
case Mastodon = 'mastodon';
case Telegram = 'telegram';
case Discord = 'discord';
public function label(): string
{
@ -38,6 +39,7 @@ public function label(): string
self::Bluesky => 'Bluesky',
self::Mastodon => 'Mastodon',
self::Telegram => 'Telegram',
self::Discord => 'Discord',
};
}
@ -56,6 +58,7 @@ public function color(): string
self::Bluesky => '#0085FF',
self::Mastodon => '#6364FF',
self::Telegram => '#26A5E4',
self::Discord => '#5865F2',
};
}
@ -73,6 +76,7 @@ public function allowedMediaTypes(): array
self::Bluesky => [MediaType::Image, MediaType::Video],
self::Mastodon => [MediaType::Image, MediaType::Video],
self::Telegram => [MediaType::Image, MediaType::Video],
self::Discord => [MediaType::Image, MediaType::Video],
};
}
@ -90,6 +94,7 @@ public function maxImages(): int
self::Bluesky => 4,
self::Mastodon => 4,
self::Telegram => 10,
self::Discord => 10,
};
}
@ -129,6 +134,7 @@ public function maxContentLength(): int
self::Bluesky => 300,
self::Mastodon => 500,
self::Telegram => 4096,
self::Discord => 2000,
};
}
@ -172,6 +178,8 @@ public function recommendedAiContentLength(): int
self::YouTube => 80,
// Telegram channel posts — short announcements read best
self::Telegram => 400,
// Discord — conversational community posts read best when concise
self::Discord => 280,
};
}
@ -194,6 +202,7 @@ public function requiredPublishScopes(): array
self::Bluesky => [],
self::Mastodon => ['write:statuses'],
self::Telegram => [],
self::Discord => [],
};
}
@ -211,6 +220,7 @@ public function supportsTextOnly(): bool
self::Bluesky => true,
self::Mastodon => true,
self::Telegram => true,
self::Discord => true,
};
}

View file

@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Social;
use Illuminate\Http\Client\Response;
class DiscordPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->status();
$rawResponse = $response->body();
$code = (int) data_get($response->json(), 'code');
$message = (string) data_get($response->json(), 'message', 'An unknown Discord error occurred.');
// Missing access / missing permissions: the bot can't see or post in the channel.
if (in_array($code, [50001, 50013], true) || $status === 403) {
return new static(
userMessage: "The bot can't post in this channel. Make sure it has access and permission to send messages there.",
category: ErrorCategory::Permission,
platformErrorCode: (string) $code,
rawResponse: $rawResponse,
);
}
// Unknown channel — it was deleted or the id is stale.
if ($code === 10003) {
return new static(
userMessage: 'That Discord channel no longer exists. Pick a different channel.',
category: ErrorCategory::Permission,
platformErrorCode: (string) $code,
rawResponse: $rawResponse,
);
}
// Attachment too large.
if ($code === 40005 || $status === 413) {
return new static(
userMessage: "A media file exceeds Discord's upload size limit.",
category: ErrorCategory::MediaFormat,
platformErrorCode: (string) $code,
rawResponse: $rawResponse,
);
}
// 401: the configured bot token is invalid (operator-level misconfiguration).
if ($status === 401) {
return new static(
userMessage: 'Discord rejected the bot token. Check the DISCORD_BOT_TOKEN configuration.',
category: ErrorCategory::Permission,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
if ($status === 429) {
return new static(
userMessage: 'Discord rate limit reached. Please try again shortly.',
category: ErrorCategory::RateLimit,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
if ($status >= 500) {
return new static(
userMessage: 'Discord is temporarily unavailable. Please try again later.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}
return new static(
userMessage: $message,
category: ErrorCategory::Unknown,
platformErrorCode: (string) $code,
rawResponse: $rawResponse,
);
}
public function platform(): string
{
return 'discord';
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\App;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\Discord\IndexDiscordMentionRequest;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class DiscordController extends Controller
{
public function __construct(private DiscordClient $discord) {}
public function channels(Request $request, SocialAccount $account): JsonResponse
{
$this->authorizeDiscordAccount($request, $account);
// Degrade to an empty list on a transient Discord outage — the picker
// shows "no channels" rather than erroring; the publish path still
// retries (channels() throws there).
return response()->json([
'channels' => rescue(
fn () => $this->discord->channels((string) $account->platform_user_id),
[],
report: false,
),
]);
}
public function mentions(IndexDiscordMentionRequest $request, SocialAccount $account): JsonResponse
{
$this->authorizeDiscordAccount($request, $account);
return response()->json([
'mentions' => $this->discord->mentions(
(string) $account->platform_user_id,
(string) $request->validated('q', ''),
),
]);
}
private function authorizeDiscordAccount(Request $request, SocialAccount $account): void
{
$workspace = $request->user()->currentWorkspace;
abort_unless(
$workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Discord,
Response::HTTP_FORBIDDEN,
);
$this->authorize('view', $workspace);
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response;
class DiscordController extends SocialController
{
protected string $driver = 'discord';
protected SocialPlatform $platform = SocialPlatform::Discord;
public function connect(Request $request): Response|RedirectResponse
{
$this->ensurePlatformEnabled();
$workspace = $request->user()->currentWorkspace;
if (! $workspace) {
return redirect()->route('app.workspaces.create');
}
$this->authorize('manageAccounts', $workspace);
return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.discord.scopes'));
}
public function callback(Request $request): View
{
return $this->handleCallback($request, $this->platform, $this->driver);
}
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Discord;
use Illuminate\Foundation\Http\FormRequest;
class IndexDiscordMentionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'q' => ['nullable', 'string', 'max:100'],
];
}
}

View file

@ -82,6 +82,16 @@ public function rules(): array
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'],
'platforms.*.meta.mentions.*.token' => ['required', 'string'],
'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'],
'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'],
'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'],
'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'],
'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'],
'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
];
@ -119,6 +129,14 @@ public function withValidator(Validator $validator): void
trans('posts.form.pinterest.board_required'),
);
}
if ($platformEnum === Platform::Discord
&& blank(data_get($platform, 'meta.channel_id'))) {
$validator->errors()->add(
"platforms.{$i}.meta.channel_id",
trans('posts.form.discord.channel_required'),
);
}
}
});
}

View file

@ -19,6 +19,7 @@
use App\Models\PostPlatform;
use App\Services\Social\BlueskyPublisher;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\Discord\DiscordPublisher;
use App\Services\Social\FacebookPublisher;
use App\Services\Social\InstagramPublisher;
use App\Services\Social\LinkedInPagePublisher;
@ -222,7 +223,7 @@ private function broadcastStatus(): void
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());
}
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher
private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher
{
return match ($this->postPlatform->platform) {
SocialPlatform::LinkedIn => app(LinkedInPublisher::class),
@ -237,6 +238,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis
SocialPlatform::Bluesky => app(BlueskyPublisher::class),
SocialPlatform::Mastodon => app(MastodonPublisher::class),
SocialPlatform::Telegram => app(TelegramPublisher::class),
SocialPlatform::Discord => app(DiscordPublisher::class),
};
}

View file

@ -31,6 +31,7 @@
use App\Models\WorkspaceSignature;
use App\Services\PostHogService;
use App\Services\PostTemplate\Registry as PostTemplateRegistry;
use App\Socialite\DiscordProvider;
use App\Socialite\InstagramProvider;
use App\Socialite\LinkedInPageExtendSocialite;
use Carbon\CarbonImmutable;
@ -186,6 +187,12 @@ protected function configureSocialite(): void
return Socialite::buildProvider(InstagramProvider::class, $config);
});
Socialite::extend('discord', function ($app) {
$config = $app['config']['services.discord'];
return Socialite::buildProvider(DiscordProvider::class, $config);
});
Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class);
Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class);

View file

@ -202,6 +202,12 @@ private function getImageConfig(Platform $platform): array
'format' => 'image/jpeg',
'quality' => 100,
],
Platform::Discord => [
'max_width' => 2048,
'max_size' => 8 * 1024 * 1024,
'format' => 'image/jpeg',
'quality' => 100,
],
};
}
}

View file

@ -8,6 +8,7 @@
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;
use App\Services\Social\Telegram\TelegramApi;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
@ -68,6 +69,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool
Platform::Bluesky => $this->verifyBluesky($account),
Platform::Mastodon => $this->verifyMastodon($account),
Platform::Telegram => $this->verifyTelegram($account),
Platform::Discord => $this->verifyDiscord($account),
};
}
@ -516,6 +518,12 @@ private function verifyTelegram(SocialAccount $account): bool
return $response->successful() && data_get($response->json(), 'ok') === true;
}
private function verifyDiscord(SocialAccount $account): bool
{
// The guild endpoint succeeds only while the bot is still a member.
return app(DiscordClient::class)->getGuild((string) $account->platform_user_id)->successful();
}
private function verifyMastodon(SocialAccount $account): bool
{
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Discord;
use App\Exceptions\PlatformUnavailableException;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
/**
* Read-side Discord API calls made with the global bot token (channel listing,
* mention search, guild verification). Posting lives in DiscordPublisher.
*/
class DiscordClient
{
use HasSocialHttpClient;
/**
* Text-postable channel types: 0 = text, 5 = announcement, 15 = forum.
*/
private const POSTABLE_CHANNEL_TYPES = [0, 5, 15];
public function baseUrl(): string
{
return (string) config('trypost.platforms.discord.api');
}
/**
* The text channels of a guild the bot can post into.
*
* @return list<array{id: string, name: string}>
*
* @throws PlatformUnavailableException when the lookup fails transiently, so
* callers (e.g. the publish-time channel guard) retry instead of
* treating an empty result as "channel not found".
*/
public function channels(string $guildId): array
{
$response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels");
if ($response->failed()) {
throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status());
}
$channels = $response->json();
if (! is_array($channels)) {
return [];
}
return collect($channels)
->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true))
->map(fn ($channel) => [
'id' => (string) data_get($channel, 'id'),
'name' => (string) data_get($channel, 'name'),
])
->values()
->all();
}
/**
* Mentionable targets matching a query: @everyone/@here, roles, then members.
*
* @return list<array{id: string, label: string, type: string}>
*/
public function mentions(string $guildId, string $query): array
{
$query = trim($query);
$needle = mb_strtolower($query);
$specials = collect([
['id' => 'everyone', 'label' => '@everyone', 'type' => 'everyone'],
['id' => 'here', 'label' => '@here', 'type' => 'here'],
])->filter(fn ($item) => $needle === '' || str_contains($item['label'], $needle));
$roles = collect($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles"))
->filter(fn ($role) => $needle === '' || str_contains(mb_strtolower((string) data_get($role, 'name')), $needle))
->take(10)
->map(fn ($role) => [
'id' => (string) data_get($role, 'id'),
'label' => '@'.data_get($role, 'name'),
'type' => 'role',
]);
$members = $query === '' ? collect() : collect(
$this->getList("{$this->baseUrl()}/guilds/{$guildId}/members/search", ['query' => $query, 'limit' => 10])
)->map(fn ($member) => [
'id' => (string) data_get($member, 'user.id'),
'label' => '@'.(data_get($member, 'user.global_name') ?: data_get($member, 'user.username')),
'type' => 'user',
]);
return $specials->concat($roles)->concat($members)->values()->all();
}
/**
* GETs a Discord list endpoint, returning [] on failure or a non-list body
* (a failed call returns an error OBJECT, which must not be iterated as rows).
*
* @param array<string, mixed> $query
* @return array<int, mixed>
*/
private function getList(string $url, array $query = []): array
{
$body = $this->bot()->get($url, $query)->json();
return is_array($body) && array_is_list($body) ? $body : [];
}
public function getGuild(string $guildId): Response
{
return $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}");
}
private function bot(): PendingRequest
{
return $this->socialHttp()
->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot');
}
}

View file

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Discord;
use App\DataTransferObjects\MediaItem;
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 selected channel is not part of this Discord server.',
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);
$attachments[] = ['id' => $index, 'filename' => $filename];
}
$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->mime_type && str_starts_with($item->mime_type, 'image/') && ! str_contains($item->mime_type, 'gif')) {
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;
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Socialite;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
use RuntimeException;
/**
* Discord bot-authorization OAuth. The user authorizes adding our shared bot to
* one of their servers; the access-token response carries the `guild` the bot
* was added to, which becomes the connected account (id = guild id). Channel
* listing, posting and mentions all use the global bot token, not this token.
*/
class DiscordProvider extends AbstractProvider implements ProviderInterface
{
protected $scopes = ['bot', 'identify', 'guilds'];
protected $scopeSeparator = ' ';
/**
* The token-exchange response, stashed so the guild it carries can become
* the Socialite user without an extra API call.
*
* @var array<string, mixed>
*/
private array $tokenResponse = [];
protected function getAuthUrl($state): string
{
return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state);
}
/**
* @return array<string, mixed>
*/
protected function getCodeFields($state = null): array
{
return array_merge(parent::getCodeFields($state), [
'permissions' => (string) config('trypost.platforms.discord.permissions'),
'integration_type' => 0,
]);
}
protected function getTokenUrl(): string
{
return config('trypost.platforms.discord.oauth_api').'/token';
}
/**
* @return array<string, mixed>
*/
public function getAccessTokenResponse($code): array
{
return $this->tokenResponse = parent::getAccessTokenResponse($code);
}
/**
* The bot-authorization token response includes the guild the bot joined.
*
* @return array<string, mixed>
*/
protected function getUserByToken($token): array
{
return (array) data_get($this->tokenResponse, 'guild', []);
}
/**
* @param array<string, mixed> $user The guild object.
*/
protected function mapUserToObject(array $user): User
{
$guildId = data_get($user, 'id');
// No guild means the user authorized without adding the bot to a server
// (or cancelled). Fail loudly so the callback shows an error instead of
// persisting a broken account with a null guild id.
if (blank($guildId)) {
throw new RuntimeException('Discord authorization did not include a server.');
}
$icon = data_get($user, 'icon');
return (new User)->setRaw($user)->map([
'id' => $guildId,
'nickname' => data_get($user, 'name'),
'name' => data_get($user, 'name'),
'avatar' => $guildId && $icon ? "https://cdn.discordapp.com/icons/{$guildId}/{$icon}.png" : null,
]);
}
}

View file

@ -111,6 +111,13 @@
'redirect' => env('PINTEREST_CLIENT_REDIRECT'),
],
// Discord
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => env('DISCORD_CLIENT_REDIRECT'),
],
'gtm' => [
'id' => env('GTM_ID'),
],

View file

@ -162,6 +162,20 @@
// Secret-token header Telegram echoes on every webhook call.
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
],
'discord' => [
'enabled' => env('DISCORD_ENABLED', true),
// Single shared bot application. OAuth (bot scope) authorizes adding the
// bot to the user's server; channel listing, mentions and posting all
// use this bot token, not the user's OAuth token.
'bot_token' => env('DISCORD_BOT_TOKEN'),
'api' => env('DISCORD_API', 'https://discord.com/api/v10'),
'oauth_api' => env('DISCORD_OAUTH_API', 'https://discord.com/api/oauth2'),
// Permission bitfield requested for the bot: VIEW_CHANNEL (1<<10) +
// SEND_MESSAGES (1<<11) + EMBED_LINKS (1<<14) + ATTACH_FILES (1<<15) +
// READ_MESSAGE_HISTORY (1<<16) + MENTION_EVERYONE (1<<17) = 248832.
'permissions' => env('DISCORD_PERMISSIONS', '248832'),
'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))),
],
],
];

View file

@ -154,6 +154,19 @@ public function telegram(): static
]);
}
public function discord(): static
{
return $this->state(fn (array $attributes) => [
'platform' => Platform::Discord,
'scopes' => Platform::Discord->requiredPublishScopes(),
'token_expires_at' => null, // posting uses the global bot token
'access_token' => '',
'refresh_token' => '',
'platform_user_id' => '999000111', // guild id
'display_name' => 'My Server',
]);
}
public function disconnected(): static
{
return $this->state(fn (array $attributes) => [

View file

@ -52,6 +52,7 @@
'bluesky' => 'Connect your Bluesky account',
'mastodon' => 'Connect your Mastodon account',
'telegram' => 'Connect a Telegram channel or group',
'discord' => 'Connect a Discord server',
],
'disconnect_modal' => [

View file

@ -161,6 +161,24 @@
'no_board_found' => 'No board matches your search.',
'board_required' => 'Select a Pinterest board to publish this post.',
],
'discord' => [
'settings' => 'Discord Settings',
'posting_to' => 'Posting to',
'channel' => 'Channel',
'select_channel' => 'Select a channel',
'loading_channels' => 'Loading channels…',
'channel_required' => 'Select a Discord channel to publish this post.',
'mentions' => 'Mentions',
'search_mention' => 'Mention a role or member…',
'embeds' => 'Embeds',
'embed' => 'Embed',
'add_embed' => 'Add embed',
'embed_title' => 'Embed title',
'embed_description' => 'Embed description',
'embed_url' => 'Embed URL',
'embed_image' => 'Image URL',
'embed_color' => 'Color',
],
'warnings' => [
'no_variant' => 'Pick a post type to continue.',
'requires_media' => 'This post type requires at least one image or video.',
@ -488,6 +506,10 @@
'label' => 'Post',
'description' => 'Text post with optional media',
],
'discord_message' => [
'label' => 'Message',
'description' => 'Message to a Discord channel with optional media & embeds',
],
],
'platforms' => [
@ -591,6 +613,7 @@
'threads_post' => 'Threads Post',
'mastodon_post' => 'Mastodon Post',
'telegram_post' => 'Telegram Post',
'discord_message' => 'Discord Message',
'facebook_post' => 'Facebook Post',
'pinterest_pin' => 'Pinterest Pin',
'instagram_story' => 'Instagram Story',

View file

@ -52,6 +52,7 @@
'bluesky' => 'Conecta tu cuenta de Bluesky',
'mastodon' => 'Conecta tu cuenta de Mastodon',
'telegram' => 'Conecta un canal o grupo de Telegram',
'discord' => 'Conecta un servidor de Discord',
],
'disconnect_modal' => [

View file

@ -161,6 +161,24 @@
'no_board_found' => 'Ningún tablero coincide con tu búsqueda.',
'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.',
],
'discord' => [
'settings' => 'Configuración de Discord',
'posting_to' => 'Publicando en',
'channel' => 'Canal',
'select_channel' => 'Selecciona un canal',
'loading_channels' => 'Cargando canales…',
'channel_required' => 'Selecciona un canal de Discord para publicar este post.',
'mentions' => 'Menciones',
'search_mention' => 'Menciona un rol o miembro…',
'embeds' => 'Embeds',
'embed' => 'Embed',
'add_embed' => 'Añadir embed',
'embed_title' => 'Título del embed',
'embed_description' => 'Descripción del embed',
'embed_url' => 'URL del embed',
'embed_image' => 'URL de la imagen',
'embed_color' => 'Color',
],
'warnings' => [
'no_variant' => 'Elige un tipo de publicación para continuar.',
'requires_media' => 'Este tipo requiere al menos una imagen o video.',
@ -488,6 +506,10 @@
'label' => 'Post',
'description' => 'Post de texto con multimedia opcional',
],
'discord_message' => [
'label' => 'Mensaje',
'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales',
],
],
'platforms' => [
@ -592,6 +614,7 @@
'threads_post' => 'Post en Threads',
'mastodon_post' => 'Post en Mastodon',
'telegram_post' => 'Post en Telegram',
'discord_message' => 'Mensaje de Discord',
'facebook_post' => 'Post en Facebook',
'pinterest_pin' => 'Pin de Pinterest',
'instagram_story' => 'Story de Instagram',

View file

@ -52,6 +52,7 @@
'bluesky' => 'Conecte sua conta do Bluesky',
'mastodon' => 'Conecte sua conta do Mastodon',
'telegram' => 'Conecte um canal ou grupo do Telegram',
'discord' => 'Conecte um servidor do Discord',
],
'disconnect_modal' => [

View file

@ -161,6 +161,24 @@
'no_board_found' => 'Nenhum quadro encontrado.',
'board_required' => 'Selecione um quadro do Pinterest para publicar este post.',
],
'discord' => [
'settings' => 'Configurações do Discord',
'posting_to' => 'Publicando em',
'channel' => 'Canal',
'select_channel' => 'Selecione um canal',
'loading_channels' => 'Carregando canais…',
'channel_required' => 'Selecione um canal do Discord para publicar este post.',
'mentions' => 'Menções',
'search_mention' => 'Mencione um cargo ou membro…',
'embeds' => 'Embeds',
'embed' => 'Embed',
'add_embed' => 'Adicionar embed',
'embed_title' => 'Título do embed',
'embed_description' => 'Descrição do embed',
'embed_url' => 'URL do embed',
'embed_image' => 'URL da imagem',
'embed_color' => 'Cor',
],
'warnings' => [
'no_variant' => 'Escolha um tipo de publicação para continuar.',
'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.',
@ -488,6 +506,10 @@
'label' => 'Post',
'description' => 'Post de texto com mídia opcional',
],
'discord_message' => [
'label' => 'Mensagem',
'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais',
],
],
'platforms' => [
@ -591,6 +613,7 @@
'threads_post' => 'Post no Threads',
'mastodon_post' => 'Post no Mastodon',
'telegram_post' => 'Post no Telegram',
'discord_message' => 'Mensagem do Discord',
'facebook_post' => 'Post no Facebook',
'pinterest_pin' => 'Pin no Pinterest',
'instagram_story' => 'Story do Instagram',

View file

@ -2,6 +2,7 @@
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-vue';
import { computed } from 'vue';
import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
@ -168,6 +169,14 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
:preview-only="previewOnly"
@update:content-type="emit('update:contentType', channel.id, $event)"
/>
<DiscordSettings
v-else-if="channel.platform === Platform.Discord"
:social-account="channel.socialAccount"
:meta="channel.meta"
:disabled="disabled"
:preview-only="previewOnly"
@update:meta="emit('update:meta', channel.id, $event)"
/>
</template>
</div>
</template>

View file

@ -104,6 +104,11 @@ const platformTheme: Record<
rotate: '-rotate-2',
image: '/images/accounts/telegram.png',
},
discord: {
bg: 'bg-indigo-200',
rotate: 'rotate-1',
image: '/images/accounts/discord.png',
},
};
const themeFor = (value: string) =>

View file

@ -0,0 +1,313 @@
<script setup lang="ts">
import { useHttp } from '@inertiajs/vue3';
import { IconChevronDown, IconChevronUp, IconPlus, IconX } from '@tabler/icons-vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { channels as channelsRoute, mentions as mentionsRoute } from '@/actions/App/Http/Controllers/App/DiscordController';
import InputError from '@/components/InputError.vue';
import { Avatar } from '@/components/ui/avatar';
import { Input } from '@/components/ui/input';
import { usePageErrors } from '@/composables/usePageErrors';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface DiscordChannel {
id: string;
name: string;
}
interface MentionTarget {
id: string;
label: string;
type: 'everyone' | 'here' | 'role' | 'user';
}
interface MentionChip {
token: string;
label: string;
}
interface EmbedDraft {
title?: string;
description?: string;
url?: string;
image?: string;
color?: string;
}
const props = withDefaults(
defineProps<{
socialAccount: SocialAccount | null;
meta: Record<string, any>;
disabled?: boolean;
previewOnly?: boolean;
}>(),
{ disabled: false, previewOnly: false },
);
const emit = defineEmits<{ 'update:meta': [value: Record<string, any>] }>();
const open = ref(false);
const updateMeta = (patch: Record<string, any>) => emit('update:meta', { ...props.meta, ...patch });
// --- Channel picker (live fetch) ---------------------------------------------
const channels = ref<DiscordChannel[]>([]);
const channelsLoading = ref(false);
const channelsHttp = useHttp<Record<string, never>, { channels: DiscordChannel[] }>();
const loadChannels = async () => {
if (!props.socialAccount || channelsLoading.value) {
return;
}
channelsLoading.value = true;
try {
const { channels: list } = await channelsHttp.get(channelsRoute.url(props.socialAccount.id));
channels.value = list;
} catch {
channels.value = [];
} finally {
channelsLoading.value = false;
}
};
onMounted(loadChannels);
const channelId = computed({
get: () => (props.meta?.channel_id as string) ?? '',
set: (value: string) => updateMeta({ channel_id: value || null }),
});
const errors = usePageErrors();
const channelError = computed<string | undefined>(() => {
if (props.meta?.channel_id) {
return undefined;
}
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.channel_id'))?.[1];
});
// --- Mentions (autocomplete chips) -------------------------------------------
const mentionQuery = ref('');
const mentionResults = ref<MentionTarget[]>([]);
const mentionsHttp = useHttp<Record<string, never>, { mentions: MentionTarget[] }>();
const mentions = computed<MentionChip[]>(() => {
const value = props.meta?.mentions;
return Array.isArray(value) ? (value as MentionChip[]) : [];
});
const tokenFor = (target: MentionTarget): string =>
({
everyone: '@everyone',
here: '@here',
role: `<@&${target.id}>`,
user: `<@${target.id}>`,
})[target.type];
let mentionTimer: ReturnType<typeof setTimeout> | undefined;
watch(mentionQuery, (query) => {
clearTimeout(mentionTimer);
if (!props.socialAccount || query.trim() === '') {
mentionResults.value = [];
return;
}
mentionTimer = setTimeout(async () => {
try {
const { mentions: list } = await mentionsHttp.get(
mentionsRoute.url(props.socialAccount!.id, { query: { q: query } }),
);
mentionResults.value = list;
} catch {
mentionResults.value = [];
}
}, 250);
});
onUnmounted(() => clearTimeout(mentionTimer));
const addMention = (target: MentionTarget) => {
const token = tokenFor(target);
if (mentions.value.some((mention) => mention.token === token)) {
return;
}
updateMeta({ mentions: [...mentions.value, { token, label: target.label }] });
mentionQuery.value = '';
mentionResults.value = [];
};
const removeMention = (token: string) =>
updateMeta({ mentions: mentions.value.filter((mention) => mention.token !== token) });
// --- Embeds (repeater) -------------------------------------------------------
// Derived straight from meta (like channel/mentions) so it never diverges from
// the persisted/auto-saved state. Inputs are controlled (one-way :value + emit),
// so index keys are safe — Vue patches each reused row to the correct values.
const embeds = computed<EmbedDraft[]>(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
const addEmbed = () => updateMeta({ embeds: [...embeds.value, {}] });
const removeEmbed = (index: number) => updateMeta({ embeds: embeds.value.filter((_, i) => i !== index) });
const updateEmbed = (index: number, patch: Partial<EmbedDraft>) =>
updateMeta({ embeds: embeds.value.map((embed, i) => (i === index ? { ...embed, ...patch } : embed)) });
</script>
<template>
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('discord')" alt="Discord" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.discord.settings') }}</span>
<span v-if="socialAccount?.display_name" class="truncate font-medium text-foreground/60">·&nbsp;{{ socialAccount.display_name }}</span>
</span>
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.posting_to') }}</p>
<p class="truncate text-sm font-bold text-foreground">{{ socialAccount.display_name }}</p>
</div>
</div>
<!-- Channel -->
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.channel') }}</p>
<select
v-model="channelId"
:disabled="disabled || channelsLoading"
class="w-full rounded-lg border-2 bg-card px-3 py-2 text-sm font-medium text-foreground transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="channelError ? 'border-rose-500' : 'border-foreground/30 hover:border-foreground'"
>
<option value="">
{{ channelsLoading ? $t('posts.form.discord.loading_channels') : $t('posts.form.discord.select_channel') }}
</option>
<option v-for="channel in channels" :key="channel.id" :value="channel.id">#{{ channel.name }}</option>
</select>
<InputError :message="channelError" />
</div>
<!-- Mentions -->
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.mentions') }}</p>
<div v-if="mentions.length" class="flex flex-wrap gap-1.5">
<span
v-for="mention in mentions"
:key="mention.token"
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground/30 bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-foreground"
>
{{ mention.label }}
<button type="button" :disabled="disabled" class="text-foreground/50 hover:text-foreground" @click="removeMention(mention.token)">
<IconX class="size-3" />
</button>
</span>
</div>
<div class="relative">
<Input
v-model="mentionQuery"
:disabled="disabled"
:placeholder="$t('posts.form.discord.search_mention')"
/>
<ul
v-if="mentionResults.length"
class="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border-2 border-foreground bg-card shadow-2xs"
>
<li v-for="target in mentionResults" :key="target.type + target.id">
<button
type="button"
class="flex w-full cursor-pointer items-center px-3 py-1.5 text-left text-sm hover:bg-foreground/5"
@click="addMention(target)"
>
{{ target.label }}
</button>
</li>
</ul>
</div>
</div>
<!-- Embeds -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.embeds') }}</p>
<button
type="button"
:disabled="disabled"
class="inline-flex cursor-pointer items-center gap-1 text-xs font-bold text-foreground/70 hover:text-foreground disabled:opacity-50"
@click="addEmbed"
>
<IconPlus class="size-3.5" />
{{ $t('posts.form.discord.add_embed') }}
</button>
</div>
<div
v-for="(embed, index) in embeds"
:key="index"
class="space-y-2 rounded-lg border-2 border-foreground/20 p-3"
>
<div class="flex items-center justify-between">
<span class="text-[11px] font-black uppercase tracking-widest text-foreground/50">{{ $t('posts.form.discord.embed') }} {{ index + 1 }}</span>
<button type="button" :disabled="disabled" class="text-foreground/50 hover:text-rose-600" @click="removeEmbed(index)">
<IconX class="size-3.5" />
</button>
</div>
<Input
:model-value="embed.title"
:disabled="disabled"
:placeholder="$t('posts.form.discord.embed_title')"
@update:model-value="updateEmbed(index, { title: String($event) })"
/>
<textarea
:value="embed.description"
:disabled="disabled"
:placeholder="$t('posts.form.discord.embed_description')"
rows="2"
class="w-full rounded-lg border-2 border-foreground/30 bg-card px-3 py-2 text-sm transition-colors hover:border-foreground focus:border-foreground focus:outline-none disabled:opacity-50"
@input="updateEmbed(index, { description: ($event.target as HTMLTextAreaElement).value })"
/>
<Input
:model-value="embed.url"
:disabled="disabled"
:placeholder="$t('posts.form.discord.embed_url')"
@update:model-value="updateEmbed(index, { url: String($event) })"
/>
<Input
:model-value="embed.image"
:disabled="disabled"
:placeholder="$t('posts.form.discord.embed_image')"
@update:model-value="updateEmbed(index, { image: String($event) })"
/>
<div class="flex items-center gap-2">
<input
type="color"
:value="embed.color || '#5865F2'"
:disabled="disabled"
class="h-8 w-12 cursor-pointer rounded border-2 border-foreground/30 disabled:opacity-50"
@input="updateEmbed(index, { color: ($event.target as HTMLInputElement).value })"
/>
<span class="text-xs font-medium text-foreground/60">{{ $t('posts.form.discord.embed_color') }}</span>
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia } from '@/composables/useMedia';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
id: string;
platform: string;
display_name: string;
username: string;
avatar_url: string | null;
}
interface EmbedDraft {
title?: string;
description?: string;
url?: string;
image?: string;
color?: string;
}
const props = defineProps<{
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
meta?: Record<string, any>;
}>();
const embeds = computed<EmbedDraft[]>(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
</script>
<template>
<div class="flex h-full w-full flex-col overflow-hidden bg-[#313338]">
<!-- Channel header -->
<div class="flex items-center gap-2 border-b border-black/20 px-4 py-2.5 text-[#f2f3f5]">
<span class="text-xl leading-none text-[#80848e]">#</span>
<span class="truncate text-[15px] font-semibold">{{ socialAccount.display_name || 'channel' }}</span>
</div>
<!-- Message -->
<div class="flex-1 overflow-y-auto px-4 py-4">
<div class="flex gap-3">
<img
v-if="socialAccount.avatar_url"
:src="socialAccount.avatar_url"
:alt="socialAccount.display_name"
class="h-10 w-10 shrink-0 rounded-full object-cover"
/>
<div
v-else
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2] font-semibold text-white"
>
{{ socialAccount.display_name?.charAt(0) }}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<span class="text-[15px] font-medium text-white">{{ socialAccount.display_name || 'TryPost' }}</span>
<span class="rounded bg-[#5865F2] px-1 text-[10px] font-bold uppercase tracking-wide text-white">Bot</span>
<span class="text-[11px] text-[#949ba4]">Today at 4:30 PM</span>
</div>
<p v-if="content" class="mt-0.5 whitespace-pre-wrap text-[15px] leading-[1.375] text-[#dbdee1]">{{ content }}</p>
<!-- Media -->
<div
v-if="media.length > 0"
class="mt-2 overflow-hidden rounded-lg"
:class="{ 'grid grid-cols-2 gap-0.5': media.length >= 2 }"
>
<div
v-for="(item, index) in media.slice(0, 4)"
:key="item.id"
class="relative overflow-hidden"
:class="media.length === 1 ? 'aspect-[4/3] max-w-sm' : 'aspect-square'"
>
<img
v-if="!isVideoMedia(item)"
:src="item.url"
:alt="item.original_filename"
class="h-full w-full object-cover"
/>
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
<div
v-if="media.length > 4 && index === 3"
class="absolute inset-0 flex items-center justify-center bg-black/60"
>
<span class="text-xl font-semibold text-white">+{{ media.length - 4 }}</span>
</div>
</div>
</div>
<!-- Embeds -->
<div
v-for="(embed, index) in embeds"
:key="index"
class="mt-2 max-w-md overflow-hidden rounded border-l-4 bg-[#2b2d31] p-3"
:style="{ borderColor: embed.color || '#5865F2' }"
>
<p v-if="embed.title" class="text-[15px] font-semibold text-[#00a8fc]">{{ embed.title }}</p>
<p v-if="embed.description" class="mt-1 whitespace-pre-wrap text-[14px] leading-[1.3] text-[#dbdee1]">{{ embed.description }}</p>
<img v-if="embed.image" :src="embed.image" alt="" class="mt-2 max-h-48 rounded object-cover" />
</div>
</div>
</div>
</div>
</div>
</template>

View file

@ -4,6 +4,7 @@ import { computed } from 'vue';
import type { MediaItem } from '@/types/media';
import BlueskyPreview from './BlueskyPreview.vue';
import DiscordPreview from './DiscordPreview.vue';
import FacebookPreview from './FacebookPreview.vue';
import InstagramPreview from './InstagramPreview.vue';
import LinkedInPreview from './LinkedInPreview.vue';
@ -68,6 +69,8 @@ const previewComponent = computed(() => {
return MastodonPreview;
case 'telegram':
return TelegramPreview;
case 'discord':
return DiscordPreview;
default:
return LinkedInPreview;
}

View file

@ -12,6 +12,7 @@ const PLATFORM_LOGOS: Record<string, string> = {
pinterest: '/images/accounts/pinterest.png',
mastodon: '/images/accounts/mastodon.png',
telegram: '/images/accounts/telegram.png',
discord: '/images/accounts/discord.png',
};
const PLATFORM_LABELS: Record<string, string> = {
@ -28,6 +29,7 @@ const PLATFORM_LABELS: Record<string, string> = {
pinterest: 'Pinterest',
mastodon: 'Mastodon',
telegram: 'Telegram',
discord: 'Discord',
};
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
@ -48,6 +50,7 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
bluesky: ['bluesky_post'],
mastodon: ['mastodon_post'],
telegram: ['telegram_post'],
discord: ['discord_message'],
};
export interface ContentTypeOption {

View file

@ -57,6 +57,10 @@ const PLATFORM_META_RULES: Record<string, MetaRule> = {
valid: Boolean(meta.board_id),
tooltipKey: meta.board_id ? null : 'posts.form.pinterest.board_required',
}),
[Platform.Discord]: (meta) => ({
valid: Boolean(meta.channel_id),
tooltipKey: meta.channel_id ? null : 'posts.form.discord.channel_required',
}),
};
/**

View file

@ -20,6 +20,7 @@ export const ContentType = {
BlueskyPost: 'bluesky_post',
MastodonPost: 'mastodon_post',
TelegramPost: 'telegram_post',
DiscordMessage: 'discord_message',
} as const;
export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType];

View file

@ -12,6 +12,7 @@ export const Platform = {
Bluesky: 'bluesky',
Mastodon: 'mastodon',
Telegram: 'telegram',
Discord: 'discord',
} as const;
export type PlatformValue = (typeof Platform)[keyof typeof Platform];

View file

@ -7,6 +7,7 @@
use App\Http\Controllers\App\AssetController;
use App\Http\Controllers\App\AutomationController;
use App\Http\Controllers\App\BillingController;
use App\Http\Controllers\App\DiscordController as AppDiscordController;
use App\Http\Controllers\App\GiphyController;
use App\Http\Controllers\App\NotificationController;
use App\Http\Controllers\App\PostAiCreateController;
@ -29,6 +30,7 @@
use App\Http\Controllers\App\WorkspaceLabelController;
use App\Http\Controllers\App\WorkspaceSignatureController;
use App\Http\Controllers\Auth\BlueskyController;
use App\Http\Controllers\Auth\DiscordController;
use App\Http\Controllers\Auth\FacebookController;
use App\Http\Controllers\Auth\InstagramController;
use App\Http\Controllers\Auth\InstagramFacebookController;
@ -120,10 +122,22 @@
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback');
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
});
// Routes that require active subscription and completed onboarding
Route::middleware(['auth', EnsureAccountReady::class])->group(function () {
// Discord — live lookups for the composer (channel picker + mention autocomplete).
// Throttled because they proxy the shared bot's (rate-limited) Discord API.
Route::get('discord/accounts/{account}/channels', [AppDiscordController::class, 'channels'])
->middleware('throttle:60,1')
->name('app.discord.channels');
Route::get('discord/accounts/{account}/mentions', [AppDiscordController::class, 'mentions'])
->middleware('throttle:60,1')
->name('app.discord.mentions');
// Workspaces
Route::get('workspaces', [WorkspaceController::class, 'index'])->name('app.workspaces.index');
Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('app.workspaces.switch');

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
use App\Enums\UserWorkspace\Role;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id, 'user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->user->refresh();
$this->account = SocialAccount::factory()->discord()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => '111222333',
]);
});
test('lists only postable text channels of the guild', function () {
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
['id' => '1', 'name' => 'general', 'type' => 0],
['id' => '2', 'name' => 'voice', 'type' => 2], // voice — excluded
['id' => '3', 'name' => 'news', 'type' => 5],
['id' => '4', 'name' => 'category', 'type' => 4], // category — excluded
], 200),
]);
$response = $this->actingAs($this->user)
->getJson(route('app.discord.channels', $this->account->id));
$response->assertOk();
expect(collect($response->json('channels'))->pluck('name')->all())->toBe(['general', 'news']);
});
test('returns mention targets: specials, roles and members', function () {
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
['id' => '10', 'name' => 'moderators'],
], 200),
config('trypost.platforms.discord.api').'/guilds/111222333/members/search*' => Http::response([
['user' => ['id' => '20', 'username' => 'mod_jane', 'global_name' => 'Jane']],
], 200),
]);
$response = $this->actingAs($this->user)
->getJson(route('app.discord.mentions', $this->account->id).'?q=mod');
$response->assertOk();
$types = collect($response->json('mentions'))->pluck('type')->all();
expect($types)->toContain('role')->toContain('user');
});
test('degrades to specials only when the roles/members lookup fails', function () {
// A failed Discord call returns an error OBJECT, which must not be iterated
// into bogus mention rows.
Http::fake([
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
config('trypost.platforms.discord.api').'/guilds/111222333/members/search*' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
]);
$response = $this->actingAs($this->user)
->getJson(route('app.discord.mentions', $this->account->id).'?q=mod');
$response->assertOk();
$types = collect($response->json('mentions'))->pluck('type');
expect($types)->not->toContain('role')->not->toContain('user');
});
test('forbids looking up a discord account in another workspace', function () {
$other = SocialAccount::factory()->discord()->create([
'workspace_id' => Workspace::factory()->create()->id,
]);
$this->actingAs($this->user)
->getJson(route('app.discord.channels', $other->id))
->assertForbidden();
});

View file

@ -448,3 +448,31 @@
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('verifies discord by checking the bot is still in the guild', function () {
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*' => Http::response(['id' => '999000111'], 200),
]);
$account = SocialAccount::factory()->discord()->create([
'platform_user_id' => '999000111',
]);
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
});
test('reports discord disconnected when the bot was removed from the guild', function () {
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
Http::fake([
config('trypost.platforms.discord.api').'/guilds/*' => Http::response(['message' => 'Unknown Guild'], 404),
]);
$account = SocialAccount::factory()->discord()->create([
'platform_user_id' => '999000111',
]);
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
});

View file

@ -0,0 +1,152 @@
<?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;
use App\Services\Social\Discord\DiscordPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
$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),
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';
});
});
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);
});

View file

@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->user->update(['current_workspace_id' => $this->workspace->id]);
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
});
test('discord connect redirects to the oauth provider', function () {
$driverMock = Mockery::mock();
$driverMock->shouldReceive('scopes')->andReturnSelf();
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
'getTargetUrl' => 'https://discord.com/api/oauth2/authorize?test=1',
]));
Socialite::shouldReceive('driver')->with('discord')->andReturn($driverMock);
$this->actingAs($this->user)
->withHeader('X-Inertia', 'true')
->get(route('app.social.discord.connect'))
->assertStatus(409); // Inertia::location
expect(session('social_connect_workspace'))->toBe($this->workspace->id);
});
test('discord oauth callback creates the server account', function () {
session(['social_connect_workspace' => $this->workspace->id]);
$socialiteUser = Mockery::mock(SocialiteUser::class);
$socialiteUser->shouldReceive('getId')->andReturn('999000111'); // guild id
$socialiteUser->shouldReceive('getNickname')->andReturn('My Server');
$socialiteUser->shouldReceive('getName')->andReturn('My Server');
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
$socialiteUser->token = 'discord-access-token';
$socialiteUser->refreshToken = 'discord-refresh-token';
$socialiteUser->expiresIn = null;
$socialiteUser->approvedScopes = ['bot', 'identify', 'guilds'];
Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser]));
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
$response->assertOk();
$response->assertViewHas('success', true);
$this->assertDatabaseHas('social_accounts', [
'workspace_id' => $this->workspace->id,
'platform' => Platform::Discord->value,
'platform_user_id' => '999000111',
'status' => Status::Connected->value,
]);
});
test('discord callback fails gracefully when no server was authorized', function () {
session(['social_connect_workspace' => $this->workspace->id]);
// DiscordProvider throws when the token response carries no guild.
$mock = Mockery::mock();
$mock->shouldReceive('user')->andThrow(new RuntimeException('Discord authorization did not include a server.'));
Socialite::shouldReceive('driver')->with('discord')->andReturn($mock);
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
$response->assertOk();
$response->assertViewHas('success', false);
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(0);
});

View file

@ -541,3 +541,47 @@
$response->assertSessionHasErrors('platforms.0.content_type');
});
test('publishing a discord post without a channel is rejected', function () {
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'platform' => Platform::Discord,
'content_type' => ContentType::DiscordMessage,
'meta' => [],
]);
$this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Publishing->value,
'platforms' => [[
'id' => $postPlatform->id,
'content_type' => ContentType::DiscordMessage->value,
'meta' => [],
]],
])
->assertSessionHasErrors('platforms.0.meta.channel_id');
});
test('saving a discord draft without a channel is allowed', function () {
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
$postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $account->id,
'platform' => Platform::Discord,
'content_type' => ContentType::DiscordMessage,
'meta' => [],
]);
$this->actingAs($this->user)
->put(route('app.posts.update', $this->post), [
'status' => Status::Draft->value,
'platforms' => [[
'id' => $postPlatform->id,
'content_type' => ContentType::DiscordMessage->value,
'meta' => [],
]],
])
->assertSessionDoesntHaveErrors('platforms.0.meta.channel_id');
});