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:
parent
7d52ea72c5
commit
c2dd4515b2
38 changed files with 1745 additions and 1 deletions
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
89
app/Exceptions/Social/DiscordPublishException.php
Normal file
89
app/Exceptions/Social/DiscordPublishException.php
Normal 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';
|
||||
}
|
||||
}
|
||||
59
app/Http/Controllers/App/DiscordController.php
Normal file
59
app/Http/Controllers/App/DiscordController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Auth/DiscordController.php
Normal file
38
app/Http/Controllers/Auth/DiscordController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php
Normal file
25
app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
122
app/Services/Social/Discord/DiscordClient.php
Normal file
122
app/Services/Social/Discord/DiscordClient.php
Normal 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');
|
||||
}
|
||||
}
|
||||
299
app/Services/Social/Discord/DiscordPublisher.php
Normal file
299
app/Services/Social/Discord/DiscordPublisher.php
Normal 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;
|
||||
}
|
||||
}
|
||||
94
app/Socialite/DiscordProvider.php
Normal file
94
app/Socialite/DiscordProvider.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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'))))),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
313
resources/js/components/posts/editor/DiscordSettings.vue
Normal file
313
resources/js/components/posts/editor/DiscordSettings.vue
Normal 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">· {{ 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>
|
||||
110
resources/js/components/posts/previews/DiscordPreview.vue
Normal file
110
resources/js/components/posts/previews/DiscordPreview.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
85
tests/Feature/Discord/DiscordLookupTest.php
Normal file
85
tests/Feature/Discord/DiscordLookupTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
152
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal file
152
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal 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);
|
||||
});
|
||||
80
tests/Feature/Social/DiscordControllerTest.php
Normal file
80
tests/Feature/Social/DiscordControllerTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue