Merge pull request #100 from trypostit/feat/discord-channel
feat(channels): add Discord as a social channel
This commit is contained in:
commit
206d01f77e
48 changed files with 2347 additions and 14 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal file
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Discord;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Discord engagement metrics. Discord exposes no impressions/reach/views for
|
||||
* bot messages, so the signals are the server member count (account-level) and,
|
||||
* per post, reaction counts and thread replies — read live with the bot token.
|
||||
*/
|
||||
class DiscordAnalytics
|
||||
{
|
||||
public function __construct(private DiscordClient $client) {}
|
||||
|
||||
/**
|
||||
* Account-level metric: the server's approximate member count.
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}>
|
||||
*/
|
||||
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<int, array{label: string, value: int, kind?: string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
258
app/Services/Social/Discord/DiscordClient.php
Normal file
258
app/Services/Social/Discord/DiscordClient.php
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<?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;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Channel types that accept a direct message via POST /channels/{id}/messages:
|
||||
* 0 = text, 5 = announcement. Forum (15) is excluded — it only accepts a thread
|
||||
* (forum post), so a plain message returns "Cannot send messages in a non-text channel".
|
||||
*/
|
||||
private const POSTABLE_CHANNEL_TYPES = [0, 5];
|
||||
|
||||
private const CHANNELS_TTL = 300;
|
||||
|
||||
private const PERMISSION_ADMINISTRATOR = 0x8;
|
||||
|
||||
private const PERMISSION_VIEW_CHANNEL = 0x400;
|
||||
|
||||
private const PERMISSION_SEND_MESSAGES = 0x800;
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return (string) config('trypost.platforms.discord.api');
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels of a guild the bot can actually post into: postable type
|
||||
* (text/announcement) AND the bot has VIEW_CHANNEL + SEND_MESSAGES there.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
// 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<string, mixed> $channel
|
||||
* @param array<string, int> $rolePermissions role id => base permission bitfield
|
||||
* @param list<string>|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<string, mixed> $channel
|
||||
* @param list<string> $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<string, int>
|
||||
*/
|
||||
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<string>|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<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}", ['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');
|
||||
}
|
||||
}
|
||||
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 bot can\'t post in the selected channel. Make sure it still exists, belongs to this server, and the bot has permission to send messages there.',
|
||||
category: ErrorCategory::Permission,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function send(string $channelId, array $payload): string
|
||||
{
|
||||
return $this->messageId($this->bot()->post($this->endpoint($channelId), $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads each media item, optimizes images for Discord, and uploads them
|
||||
* as multipart attachments alongside the JSON payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @param Collection<int, MediaItem> $media
|
||||
*/
|
||||
private function sendWithMedia(string $channelId, array $payload, Collection $media): string
|
||||
{
|
||||
$request = $this->bot();
|
||||
$attachments = [];
|
||||
$tempFiles = [];
|
||||
|
||||
try {
|
||||
foreach ($media->values() as $index => $item) {
|
||||
$tempFile = $this->downloadMedia($item);
|
||||
$tempFiles[] = $tempFile;
|
||||
|
||||
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
|
||||
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
|
||||
$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' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
'impressions' => 'Impressions',
|
||||
'interactions' => 'Interactions',
|
||||
'likes' => 'Likes',
|
||||
'members' => 'Members',
|
||||
'minutes_watched' => 'Minutes Watched',
|
||||
'organic_followers' => 'Organic Followers',
|
||||
'outbound_clicks' => 'Outbound Clicks',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
BIN
public/images/accounts/discord.png
Normal file
BIN
public/images/accounts/discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
83
resources/js/components/SearchableSelect.vue
Normal file
83
resources/js/components/SearchableSelect.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Select…',
|
||||
searchPlaceholder: 'Search…',
|
||||
emptyText: 'No results.',
|
||||
disabled: false,
|
||||
invalid: false,
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<string>({ default: '' });
|
||||
|
||||
const open = ref(false);
|
||||
const selected = computed(() => props.options.find((option) => option.value === value.value));
|
||||
|
||||
const select = (option: Option) => {
|
||||
value.value = option.value;
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-between font-normal"
|
||||
:class="invalid ? 'border-rose-500' : ''"
|
||||
>
|
||||
<span :class="selected ? 'text-foreground' : 'text-foreground/50'">
|
||||
{{ selected ? selected.label : placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-(--reka-popover-trigger-width) p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput :placeholder="searchPlaceholder" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ emptyText }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="select(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<IconCheck :class="cn('ml-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</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) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconChartBar, IconLoader2, IconUsers } from '@tabler/icons-vue';
|
||||
import { IconChartBar, IconLoader2, IconMessageCircle, IconUsers } from '@tabler/icons-vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
|
|
@ -34,6 +34,9 @@ const stats = computed(() => metrics.value.filter((m) => !m.kind));
|
|||
const subscribers = computed(() =>
|
||||
metrics.value.find((m) => m.kind === 'subscribers'),
|
||||
);
|
||||
const comments = computed(() =>
|
||||
metrics.value.find((m) => m.kind === 'comments'),
|
||||
);
|
||||
const reactions = computed(() =>
|
||||
metrics.value.filter((m) => m.kind === 'reaction'),
|
||||
);
|
||||
|
|
@ -98,18 +101,17 @@ onMounted(async () => {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-if="subscribers || reactions.length > 0"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
v-if="subscribers || comments || reactions.length > 0"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
:class="{ 'mt-2': stats.length > 0 }"
|
||||
>
|
||||
<!-- Audience counts: monochrome line icons read as stats, not reactions. -->
|
||||
<TooltipProvider v-if="subscribers">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs"
|
||||
>
|
||||
<IconUsers class="size-3.5 text-muted-foreground" />
|
||||
<span class="font-semibold tabular-nums">{{
|
||||
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconUsers class="size-4" :stroke="1.75" />
|
||||
<span class="font-semibold tabular-nums text-foreground">{{
|
||||
formatNumberCompact(subscribers.value)
|
||||
}}</span>
|
||||
</span>
|
||||
|
|
@ -120,19 +122,37 @@ onMounted(async () => {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider v-if="comments">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconMessageCircle class="size-4" :stroke="1.75" />
|
||||
<span class="font-semibold tabular-nums text-foreground">{{
|
||||
formatNumberCompact(comments.value)
|
||||
}}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ comments.label }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Divider between audience stats and the reactions chip. -->
|
||||
<span
|
||||
v-if="subscribers && reactions.length > 0"
|
||||
class="mx-0.5 h-4 w-px bg-border"
|
||||
v-if="(subscribers || comments) && reactions.length > 0"
|
||||
class="h-3.5 w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Reactions: each emoji its own clean pill with its count. -->
|
||||
<span
|
||||
v-for="reaction in reactions"
|
||||
:key="reaction.label"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-1 text-xs"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs"
|
||||
>
|
||||
<span class="text-sm leading-none">{{ reaction.label }}</span>
|
||||
<span class="font-semibold tabular-nums">{{
|
||||
<span class="text-[13px] leading-none">{{ reaction.label }}</span>
|
||||
<span class="font-semibold tabular-nums text-foreground/80">{{
|
||||
formatNumberCompact(reaction.value)
|
||||
}}</span>
|
||||
</span>
|
||||
|
|
|
|||
334
resources/js/components/posts/editor/DiscordSettings.vue
Normal file
334
resources/js/components/posts/editor/DiscordSettings.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<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 SearchableSelect from '@/components/SearchableSelect.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 }),
|
||||
});
|
||||
|
||||
// Keep a saved channel selectable even before the live list loads (or if the
|
||||
// lookup is unavailable), so editing a post never visually "loses" its channel.
|
||||
const channelOptions = computed<DiscordChannel[]>(() => {
|
||||
if (channelId.value && !channels.value.some((channel) => channel.id === channelId.value)) {
|
||||
return [{ id: channelId.value, name: channelId.value }, ...channels.value];
|
||||
}
|
||||
|
||||
return channels.value;
|
||||
});
|
||||
|
||||
const channelSelectOptions = computed(() => channelOptions.value.map((channel) => ({ value: channel.id, label: `#${channel.name}` })));
|
||||
|
||||
// Persist the channel NAME alongside the id (display-only, for the preview) and
|
||||
// keep it fresh as the live list loads or the channel is renamed.
|
||||
watch([channelId, channels], () => {
|
||||
const name = channels.value.find((channel) => channel.id === channelId.value)?.name;
|
||||
|
||||
if (name && props.meta?.channel_name !== name) {
|
||||
updateMeta({ channel_name: name });
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
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>
|
||||
<SearchableSelect
|
||||
v-model="channelId"
|
||||
:options="channelSelectOptions"
|
||||
:placeholder="channelsLoading ? $t('posts.form.discord.loading_channels') : $t('posts.form.discord.select_channel')"
|
||||
:search-placeholder="$t('posts.form.discord.search_channel')"
|
||||
:empty-text="$t('posts.form.discord.no_channels')"
|
||||
:disabled="disabled || channelsLoading"
|
||||
:invalid="!!channelError"
|
||||
/>
|
||||
<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>
|
||||
128
resources/js/components/posts/previews/DiscordPreview.vue
Normal file
128
resources/js/components/posts/previews/DiscordPreview.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<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;
|
||||
}
|
||||
|
||||
interface MentionChip {
|
||||
token: string;
|
||||
label: 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[]) : []));
|
||||
const channelName = computed<string>(() => (props.meta?.channel_name as string) || 'channel');
|
||||
const mentions = computed<MentionChip[]>(() => (Array.isArray(props.meta?.mentions) ? (props.meta!.mentions as MentionChip[]) : []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col overflow-hidden bg-white">
|
||||
<!-- Channel header -->
|
||||
<div class="flex items-center gap-2 border-b border-black/10 px-4 py-2.5 text-[#313338]">
|
||||
<span class="text-xl leading-none text-[#80848e]">#</span>
|
||||
<span class="truncate text-[15px] font-semibold">{{ channelName }}</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-[#060607]">{{ 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-[#5c5e66]">Today at 4:30 PM</span>
|
||||
</div>
|
||||
|
||||
<p v-if="content" class="mt-0.5 whitespace-pre-wrap text-[15px] leading-[1.375] text-[#313338]">{{ content }}</p>
|
||||
|
||||
<!-- Mentions (Discord renders them as blurple pills) -->
|
||||
<div v-if="mentions.length" class="mt-0.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="mention in mentions"
|
||||
:key="mention.token"
|
||||
class="rounded bg-[#e6e9ff] px-1 text-[15px] font-medium text-[#444ec1]"
|
||||
>
|
||||
{{ mention.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 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-[#f2f3f5] p-3"
|
||||
:style="{ borderColor: embed.color || '#5865F2' }"
|
||||
>
|
||||
<p v-if="embed.title" class="text-[15px] font-semibold text-[#0866d8]">{{ embed.title }}</p>
|
||||
<p v-if="embed.description" class="mt-1 whitespace-pre-wrap text-[14px] leading-[1.3] text-[#313338]">{{ 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');
|
||||
|
|
|
|||
137
tests/Feature/Discord/DiscordLookupTest.php
Normal file
137
tests/Feature/Discord/DiscordLookupTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?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\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush(); // channel list is cached per guild — isolate between tests
|
||||
config([
|
||||
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
||||
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
||||
]);
|
||||
|
||||
$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 channels (text + announcement) the bot can send in', 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
|
||||
['id' => '5', 'name' => 'forum', 'type' => 15], // forum — excluded (only accepts threads)
|
||||
], 200),
|
||||
// @everyone grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048) everywhere.
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 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('excludes channels where the bot lacks send permission', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
|
||||
['id' => '1', 'name' => 'open', 'type' => 0],
|
||||
['id' => '2', 'name' => 'locked', 'type' => 0, 'permission_overwrites' => [
|
||||
// @everyone overwrite denies SEND_MESSAGES (2048) on this channel.
|
||||
['id' => '111222333', 'type' => 0, 'allow' => '0', 'deny' => '2048'],
|
||||
]],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 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(['open']);
|
||||
});
|
||||
|
||||
test('does not hide channels when the bot role lookup is unavailable', function () {
|
||||
// A failed members lookup must not blank the picker — fall back to type filtering.
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
|
||||
['id' => '1', 'name' => 'general', 'type' => 0],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
|
||||
]);
|
||||
|
||||
$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']);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
|
|||
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal file
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\Status;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordAnalytics;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
|
||||
});
|
||||
|
||||
it('returns the server member count as an account metric', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['id' => '111222333', 'approximate_member_count' => 4200], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->getMetrics($account))
|
||||
->toBe([['label' => 'Members', 'value' => 4200]]);
|
||||
});
|
||||
|
||||
it('returns no account metrics when the guild lookup fails', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['message' => 'Unknown Guild'], 404),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->getMetrics($account))->toBe([]);
|
||||
});
|
||||
|
||||
it('maps message reactions and thread replies to post metrics', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => '777',
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
|
||||
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response([
|
||||
'id' => '777',
|
||||
'thread' => ['message_count' => 8],
|
||||
'reactions' => [
|
||||
['count' => 12, 'emoji' => ['id' => null, 'name' => '🔥']],
|
||||
['count' => 3, 'emoji' => ['id' => '999', 'name' => 'partyblob']],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
|
||||
->toBe([
|
||||
['label' => 'Members', 'value' => 50, 'kind' => 'subscribers'],
|
||||
['label' => '🔥', 'value' => 12, 'kind' => 'reaction'],
|
||||
['label' => ':partyblob:', 'value' => 3, 'kind' => 'reaction'],
|
||||
['label' => 'Comments', 'value' => 8, 'kind' => 'comments'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only the member count when the message has no engagement yet', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => '777',
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
|
||||
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response(['id' => '777'], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
|
||||
->toBe([['label' => 'Members', 'value' => 50, 'kind' => 'subscribers']]);
|
||||
});
|
||||
200
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal file
200
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?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\Media\MediaOptimizer;
|
||||
use App\Services\Social\Discord\DiscordPublisher;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush(); // channel list is cached per guild — isolate between tests
|
||||
config([
|
||||
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
||||
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
||||
]);
|
||||
|
||||
$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),
|
||||
// @everyone (role id == guild id) grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048).
|
||||
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 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('uploads media as a multipart attachment', function () {
|
||||
$this->post->update([
|
||||
'media' => [[
|
||||
'id' => 'm1',
|
||||
'path' => 'media/2026-01/pic.jpg',
|
||||
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'original_filename' => 'pic.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->mock(MediaOptimizer::class)
|
||||
->shouldReceive('optimizeImage')
|
||||
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
||||
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
||||
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '901'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->publisher->publish(($this->makePostPlatform)());
|
||||
|
||||
expect($result['id'])->toBe('901');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/messages')) {
|
||||
return false;
|
||||
}
|
||||
$names = collect($request->data())->pluck('name');
|
||||
|
||||
return $names->contains('payload_json') && $names->contains('files[0]');
|
||||
});
|
||||
});
|
||||
|
||||
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