From c2dd4515b2406f5927ade915af97d53cd9a7f928 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 14:44:00 -0300 Subject: [PATCH 1/7] 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. --- app/Enums/PostPlatform/ContentType.php | 9 + app/Enums/SocialAccount/Platform.php | 10 + .../Social/DiscordPublishException.php | 89 +++++ .../Controllers/App/DiscordController.php | 59 ++++ .../Controllers/Auth/DiscordController.php | 38 +++ .../Discord/IndexDiscordMentionRequest.php | 25 ++ .../Requests/App/Post/UpdatePostRequest.php | 18 + app/Jobs/PublishToSocialPlatform.php | 4 +- app/Providers/AppServiceProvider.php | 7 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Social/ConnectionVerifier.php | 8 + app/Services/Social/Discord/DiscordClient.php | 122 +++++++ .../Social/Discord/DiscordPublisher.php | 299 +++++++++++++++++ app/Socialite/DiscordProvider.php | 94 ++++++ config/services.php | 7 + config/trypost.php | 14 + database/factories/SocialAccountFactory.php | 13 + lang/en/accounts.php | 1 + lang/en/posts.php | 23 ++ lang/es/accounts.php | 1 + lang/es/posts.php | 23 ++ lang/pt-BR/accounts.php | 1 + lang/pt-BR/posts.php | 23 ++ .../js/components/ChannelConfigurator.vue | 9 + .../components/accounts/AddSocialDialog.vue | 5 + .../posts/editor/DiscordSettings.vue | 313 ++++++++++++++++++ .../posts/previews/DiscordPreview.vue | 110 ++++++ .../posts/previews/PlatformPreview.vue | 3 + resources/js/composables/usePlatformLogo.ts | 3 + resources/js/composables/usePostCompliance.ts | 4 + resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + routes/app.php | 14 + tests/Feature/Discord/DiscordLookupTest.php | 85 +++++ .../Social/ConnectionVerifierTest.php | 28 ++ .../Services/Social/DiscordPublisherTest.php | 152 +++++++++ .../Feature/Social/DiscordControllerTest.php | 80 +++++ tests/Feature/UpdatePostRequestTest.php | 44 +++ 38 files changed, 1745 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/Social/DiscordPublishException.php create mode 100644 app/Http/Controllers/App/DiscordController.php create mode 100644 app/Http/Controllers/Auth/DiscordController.php create mode 100644 app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php create mode 100644 app/Services/Social/Discord/DiscordClient.php create mode 100644 app/Services/Social/Discord/DiscordPublisher.php create mode 100644 app/Socialite/DiscordProvider.php create mode 100644 resources/js/components/posts/editor/DiscordSettings.vue create mode 100644 resources/js/components/posts/previews/DiscordPreview.vue create mode 100644 tests/Feature/Discord/DiscordLookupTest.php create mode 100644 tests/Feature/Services/Social/DiscordPublisherTest.php create mode 100644 tests/Feature/Social/DiscordControllerTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 4c3214ff..0c525df9 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -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, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 25337147..0d645c97 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -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, }; } diff --git a/app/Exceptions/Social/DiscordPublishException.php b/app/Exceptions/Social/DiscordPublishException.php new file mode 100644 index 00000000..3aaa40cf --- /dev/null +++ b/app/Exceptions/Social/DiscordPublishException.php @@ -0,0 +1,89 @@ +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'; + } +} diff --git a/app/Http/Controllers/App/DiscordController.php b/app/Http/Controllers/App/DiscordController.php new file mode 100644 index 00000000..83d230e4 --- /dev/null +++ b/app/Http/Controllers/App/DiscordController.php @@ -0,0 +1,59 @@ +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); + } +} diff --git a/app/Http/Controllers/Auth/DiscordController.php b/app/Http/Controllers/Auth/DiscordController.php new file mode 100644 index 00000000..87ff8c25 --- /dev/null +++ b/app/Http/Controllers/Auth/DiscordController.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php new file mode 100644 index 00000000..25967af3 --- /dev/null +++ b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'q' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 47941085..1f70fcd8 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -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'), + ); + } } }); } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index e4d494d1..5bf96054 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -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), }; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d5ed7d9..1ea3193d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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); diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index c17e9469..65de20e3 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -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, + ], }; } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 79ea1d50..92f62433 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -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'); diff --git a/app/Services/Social/Discord/DiscordClient.php b/app/Services/Social/Discord/DiscordClient.php new file mode 100644 index 00000000..70e7b9b0 --- /dev/null +++ b/app/Services/Social/Discord/DiscordClient.php @@ -0,0 +1,122 @@ + + * + * @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 + */ + 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 $query + * @return array + */ + 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'); + } +} diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php new file mode 100644 index 00000000..14e8f1eb --- /dev/null +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -0,0 +1,299 @@ +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 $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 $payload + * @param Collection $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 + */ + 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 + */ + 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> + */ + 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; + } +} diff --git a/app/Socialite/DiscordProvider.php b/app/Socialite/DiscordProvider.php new file mode 100644 index 00000000..41d2bfd1 --- /dev/null +++ b/app/Socialite/DiscordProvider.php @@ -0,0 +1,94 @@ + + */ + private array $tokenResponse = []; + + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state); + } + + /** + * @return array + */ + 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 + */ + public function getAccessTokenResponse($code): array + { + return $this->tokenResponse = parent::getAccessTokenResponse($code); + } + + /** + * The bot-authorization token response includes the guild the bot joined. + * + * @return array + */ + protected function getUserByToken($token): array + { + return (array) data_get($this->tokenResponse, 'guild', []); + } + + /** + * @param array $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, + ]); + } +} diff --git a/config/services.php b/config/services.php index b6c194cc..e72632d6 100644 --- a/config/services.php +++ b/config/services.php @@ -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'), ], diff --git a/config/trypost.php b/config/trypost.php index e69f375a..16bac3f5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -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'))))), + ], ], ]; diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index e18e27fc..927aba12 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -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) => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 71a3369f..52871353 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -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' => [ diff --git a/lang/en/posts.php b/lang/en/posts.php index b38b845d..93781fce 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -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', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 4fe5eb1e..3a8ed353 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -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' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index 220880f2..1537a9c0 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -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', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 9323dc74..a4382bb9 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -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' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e3311e02..01f95f25 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -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', diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 8df2a0fb..5295d3db 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -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)" /> + diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index 0e93b7a1..fdee97f1 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -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) => diff --git a/resources/js/components/posts/editor/DiscordSettings.vue b/resources/js/components/posts/editor/DiscordSettings.vue new file mode 100644 index 00000000..45141a0d --- /dev/null +++ b/resources/js/components/posts/editor/DiscordSettings.vue @@ -0,0 +1,313 @@ + + +