Merge pull request #100 from trypostit/feat/discord-channel

feat(channels): add Discord as a social channel
This commit is contained in:
Paulo Castellano 2026-06-16 17:13:47 -03:00 committed by GitHub
commit 206d01f77e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2347 additions and 14 deletions

View file

@ -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=

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -82,6 +82,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'),
);
}
}
});
}

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@
use App\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),

View file

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

View file

@ -0,0 +1,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;
}
}

View 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');
}
}

View file

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Services\Social\Discord;
use App\DataTransferObjects\MediaItem;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\DiscordPublishException;
use App\Exceptions\Social\ErrorCategory;
use App\Models\PostPlatform;
use App\Services\Media\MediaOptimizer;
use App\Services\Social\Concerns\HasSocialHttpClient;
use App\Services\Social\ContentSanitizer;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Throwable;
class DiscordPublisher
{
use HasSocialHttpClient;
/**
* Discord's hard message ceiling — also the maxContentLength() for the
* platform, re-checked here because mentions are appended after validation.
*/
private const MAX_MESSAGE_LENGTH = 2000;
private const MAX_EMBED_COLOR = 0xFFFFFF;
public function publish(PostPlatform $postPlatform): array
{
$this->validateContentLength($postPlatform);
$account = $postPlatform->socialAccount;
$guildId = (string) $account->platform_user_id;
$channelId = (string) data_get($postPlatform->meta, 'channel_id');
if ($channelId === '') {
throw new DiscordPublishException(
userMessage: 'No Discord channel selected for this post.',
category: ErrorCategory::Unknown,
);
}
// Posting uses the global bot token, so the channel id is the only thing
// scoping where a message lands — verify it belongs to THIS connected
// server, otherwise a crafted channel_id could post into any server the
// bot is in (including another workspace's).
$this->guardChannelBelongsToGuild($guildId, $channelId);
$content = $postPlatform->post->content
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
: '';
$content = $this->appendMentions($content, $postPlatform);
if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) {
throw new DiscordPublishException(
userMessage: 'The message (including mentions) exceeds Discord\'s 2000-character limit.',
category: ErrorCategory::Unknown,
);
}
$embeds = $this->buildEmbeds($postPlatform);
$media = $postPlatform->post->mediaItems->take($postPlatform->platform->maxImages());
$payload = array_filter([
'content' => $content !== '' ? $content : null,
'embeds' => $embeds !== [] ? $embeds : null,
'allowed_mentions' => $this->allowedMentions($postPlatform),
], fn ($value) => $value !== null);
$messageId = $media->isEmpty()
? $this->send($channelId, $payload)
: $this->sendWithMedia($channelId, $payload, $media);
return [
'id' => $messageId,
'url' => "https://discord.com/channels/{$guildId}/{$channelId}/{$messageId}",
];
}
private function guardChannelBelongsToGuild(string $guildId, string $channelId): void
{
$allowed = collect(app(DiscordClient::class)->channels($guildId))
->pluck('id')
->contains($channelId);
if (! $allowed) {
throw new DiscordPublishException(
userMessage: 'The 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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,7 @@
'impressions' => 'Impressions',
'interactions' => 'Interactions',
'likes' => 'Likes',
'members' => 'Members',
'minutes_watched' => 'Minutes Watched',
'organic_followers' => 'Organic Followers',
'outbound_clicks' => 'Outbound Clicks',

View file

@ -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',

View file

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

View file

@ -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',

View file

@ -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',

View file

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

View file

@ -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',

View file

@ -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',

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

@ -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>

View file

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

View file

@ -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>

View 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">·&nbsp;{{ socialAccount.display_name }}</span>
</span>
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.posting_to') }}</p>
<p class="truncate text-sm font-bold text-foreground">{{ socialAccount.display_name }}</p>
</div>
</div>
<!-- Channel -->
<div class="space-y-2">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.channel') }}</p>
<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>

View 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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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();
});

View file

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

View file

@ -0,0 +1,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']]);
});

View 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);
});

View file

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

View file

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