diff --git a/.env.example b/.env.example index dc13c3bb..235552aa 100644 --- a/.env.example +++ b/.env.example @@ -162,6 +162,13 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= TELEGRAM_WEBHOOK_SECRET= +# Discord (single shared app+bot — create one via https://discord.com/developers/applications) +# OAuth2 → add the redirect below; Bot → enable "Server Members Intent" for mention search. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback" + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= 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..9e6d9c74 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -82,6 +82,17 @@ 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.channel_name' => ['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 +130,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/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index de26dff9..b68e3305 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -8,6 +8,7 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Services\Social\BlueskyAnalytics; +use App\Services\Social\Discord\DiscordAnalytics; use App\Services\Social\FacebookAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; @@ -66,6 +67,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), 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/DiscordAnalytics.php b/app/Services/Social/Discord/DiscordAnalytics.php new file mode 100644 index 00000000..db0d9f0c --- /dev/null +++ b/app/Services/Social/Discord/DiscordAnalytics.php @@ -0,0 +1,107 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $guildId = (string) $account->platform_user_id; + + if ($guildId === '') { + return []; + } + + try { + $response = $this->client->getGuild($guildId); + } catch (Throwable) { + return []; + } + + if ($response->failed()) { + return []; + } + + $count = data_get($response->json(), 'approximate_member_count'); + + if (! is_int($count)) { + return []; + } + + return [ + ['label' => __('analytics.metrics.members'), 'value' => $count], + ]; + } + + /** + * Post-level metrics: thread replies plus reaction counts per emoji, tagged + * so the UI renders them as pills alongside the member count. + * + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $metrics = $account + ? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account)) + : []; + + $channelId = (string) data_get($postPlatform->meta, 'channel_id'); + $messageId = (string) $postPlatform->platform_post_id; + + if ($channelId === '' || $messageId === '') { + return $metrics; + } + + try { + $response = $this->client->getMessage($channelId, $messageId); + } catch (Throwable) { + return $metrics; + } + + if ($response->failed()) { + return $metrics; + } + + $message = $response->json(); + + foreach ((array) data_get($message, 'reactions', []) as $reaction) { + $name = (string) data_get($reaction, 'emoji.name'); + $label = data_get($reaction, 'emoji.id') ? ":{$name}:" : $name; + + $metrics[] = [ + 'label' => $label !== '' ? $label : __('analytics.metrics.custom_reaction'), + 'value' => (int) data_get($reaction, 'count'), + 'kind' => 'reaction', + ]; + } + + // Thread replies are their own metric (kind "comments") so the UI renders + // them as a 💬 pill next to the member count, with a distinguishing tooltip. + $replies = (int) data_get($message, 'thread.message_count', 0); + + if ($replies > 0) { + $metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $replies, 'kind' => 'comments']; + } + + return $metrics; + } +} diff --git a/app/Services/Social/Discord/DiscordClient.php b/app/Services/Social/Discord/DiscordClient.php new file mode 100644 index 00000000..ac83a243 --- /dev/null +++ b/app/Services/Social/Discord/DiscordClient.php @@ -0,0 +1,258 @@ + + * + * @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 + { + // Cached per guild for 5 minutes — channels change rarely and this runs + // both in the composer picker and on every publish (the channel guard), + // all against the shared, rate-limited bot token. A transient failure + // throws and is NOT cached, so the next call retries. + return Cache::remember("discord:channels:{$guildId}", self::CHANNELS_TTL, function () use ($guildId) { + $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 []; + } + + $rolePermissions = $this->guildRolePermissions($guildId); + $botRoleIds = $this->botRoleIds($guildId); + + return collect($channels) + ->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true)) + ->filter(fn ($channel) => $this->botCanPostInChannel($channel, $guildId, $rolePermissions, $botRoleIds)) + ->map(fn ($channel) => [ + 'id' => (string) data_get($channel, 'id'), + 'name' => (string) data_get($channel, 'name'), + ]) + ->values() + ->all(); + }); + } + + /** + * Whether the bot has VIEW_CHANNEL + SEND_MESSAGES in a channel, computed from + * its roles' base permissions and the channel's permission overwrites (Discord's + * standard algorithm). Falls back to `true` when the bot's roles can't be + * resolved, so a transient API hiccup never hides every channel from the picker. + * + * @param array $channel + * @param array $rolePermissions role id => base permission bitfield + * @param list|null $botRoleIds role ids assigned to the bot, or null when unknown + */ + private function botCanPostInChannel(array $channel, string $guildId, array $rolePermissions, ?array $botRoleIds): bool + { + if ($botRoleIds === null || $rolePermissions === []) { + return true; + } + + $base = $rolePermissions[$guildId] ?? 0; // @everyone role id == guild id + foreach ($botRoleIds as $roleId) { + $base |= ($rolePermissions[$roleId] ?? 0); + } + + if (($base & self::PERMISSION_ADMINISTRATOR) !== 0) { + return true; + } + + $permissions = $this->applyChannelOverwrites($base, $channel, $guildId, $botRoleIds); + + return ($permissions & self::PERMISSION_VIEW_CHANNEL) !== 0 + && ($permissions & self::PERMISSION_SEND_MESSAGES) !== 0; + } + + /** + * Applies a channel's permission_overwrites to a base bitfield in Discord's + * documented order: @everyone overwrite, then the union of role overwrites, + * then the bot's member overwrite. + * + * @param array $channel + * @param list $botRoleIds + */ + private function applyChannelOverwrites(int $permissions, array $channel, string $guildId, array $botRoleIds): int + { + $overwrites = collect((array) data_get($channel, 'permission_overwrites', [])); + + $everyone = $overwrites->first(fn ($overwrite) => (string) data_get($overwrite, 'id') === $guildId); + if ($everyone !== null) { + $permissions = ($permissions & ~(int) data_get($everyone, 'deny', 0)) | (int) data_get($everyone, 'allow', 0); + } + + $roleAllow = 0; + $roleDeny = 0; + foreach ($overwrites as $overwrite) { + if ((int) data_get($overwrite, 'type') === 0 && in_array((string) data_get($overwrite, 'id'), $botRoleIds, true)) { + $roleAllow |= (int) data_get($overwrite, 'allow', 0); + $roleDeny |= (int) data_get($overwrite, 'deny', 0); + } + } + $permissions = ($permissions & ~$roleDeny) | $roleAllow; + + $botId = (string) config('services.discord.client_id'); + $member = $overwrites->first(fn ($overwrite) => (int) data_get($overwrite, 'type') === 1 && (string) data_get($overwrite, 'id') === $botId); + if ($member !== null) { + $permissions = ($permissions & ~(int) data_get($member, 'deny', 0)) | (int) data_get($member, 'allow', 0); + } + + return $permissions; + } + + /** + * Base permission bitfield per guild role (role id => permissions). Returns [] + * when the roles can't be fetched, which disables permission filtering. + * + * @return array + */ + private function guildRolePermissions(string $guildId): array + { + $permissions = []; + + foreach ($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles") as $role) { + $permissions[(string) data_get($role, 'id')] = (int) data_get($role, 'permissions', 0); + } + + return $permissions; + } + + /** + * The role ids assigned to the bot in a guild, or null when they can't be + * resolved (no client id configured, or the member lookup failed). + * + * @return list|null + */ + private function botRoleIds(string $guildId): ?array + { + $botId = (string) config('services.discord.client_id'); + + if ($botId === '') { + return null; + } + + $response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/members/{$botId}"); + + if ($response->failed()) { + return null; + } + + $roles = $response->json('roles'); + + return is_array($roles) ? array_map('strval', $roles) : null; + } + + /** + * 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}", ['with_counts' => 'true']); + } + + public function getMessage(string $channelId, string $messageId): Response + { + return $this->bot()->get("{$this->baseUrl()}/channels/{$channelId}/messages/{$messageId}"); + } + + 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..aa35d5e3 --- /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 bot can\'t post in the selected channel. Make sure it still exists, belongs to this server, and the bot has permission to send messages there.', + category: ErrorCategory::Permission, + ); + } + } + + /** + * @param array $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/analytics.php b/lang/en/analytics.php index ee6fbf22..99c8190c 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impressions', 'interactions' => 'Interactions', 'likes' => 'Likes', + 'members' => 'Members', 'minutes_watched' => 'Minutes Watched', 'organic_followers' => 'Organic Followers', 'outbound_clicks' => 'Outbound Clicks', diff --git a/lang/en/posts.php b/lang/en/posts.php index b38b845d..6fe57b73 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -161,6 +161,26 @@ '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…', + 'search_channel' => 'Search channels…', + 'no_channels' => 'No channels found.', + '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 +508,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 +615,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/analytics.php b/lang/es/analytics.php index 4f0a4ab0..da4d2a40 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impresiones', 'interactions' => 'Interacciones', 'likes' => 'Me gusta', + 'members' => 'Miembros', 'minutes_watched' => 'Minutos Vistos', 'organic_followers' => 'Seguidores Orgánicos', 'outbound_clicks' => 'Clics Externos', diff --git a/lang/es/posts.php b/lang/es/posts.php index 220880f2..c1da9111 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -161,6 +161,26 @@ '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…', + 'search_channel' => 'Buscar canales…', + 'no_channels' => 'No se encontraron 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 +508,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 +616,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/analytics.php b/lang/pt-BR/analytics.php index 95e2139d..bad4c550 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impressões', 'interactions' => 'Interações', 'likes' => 'Curtidas', + 'members' => 'Membros', 'minutes_watched' => 'Minutos Assistidos', 'organic_followers' => 'Seguidores Orgânicos', 'outbound_clicks' => 'Cliques Externos', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e3311e02..831b745e 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -161,6 +161,26 @@ '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…', + 'search_channel' => 'Buscar canais…', + 'no_channels' => 'Nenhum canal encontrado.', + '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 +508,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 +615,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/public/images/accounts/discord.png b/public/images/accounts/discord.png new file mode 100644 index 00000000..90af762a Binary files /dev/null and b/public/images/accounts/discord.png differ 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/SearchableSelect.vue b/resources/js/components/SearchableSelect.vue new file mode 100644 index 00000000..fdad0bb2 --- /dev/null +++ b/resources/js/components/SearchableSelect.vue @@ -0,0 +1,83 @@ + + + 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/PostPlatformMetrics.vue b/resources/js/components/posts/PostPlatformMetrics.vue index bf4fb35c..385105d7 100644 --- a/resources/js/components/posts/PostPlatformMetrics.vue +++ b/resources/js/components/posts/PostPlatformMetrics.vue @@ -1,6 +1,6 @@ + +