diff --git a/.claude/commands/release.md b/.claude/commands/release.md index b3c74a94..8eb67224 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -89,12 +89,12 @@ #### Structure ```markdown --- -subject: "Changelog: TryPost — , , ..." +subject: "Changelog — , , ..." --- -# Changelog: TryPost — , , ... +# Changelog — , , ... -By Paulo Castellano • Release +By TryPost Product Team • [Release ](https://github.com///releases/tag/) Hello! Welcome to this week's update. Here's what's new in TryPost. @@ -121,9 +121,11 @@ ## Fixes - <...> Cheers, -Paulo Castellano from TryPost.it +Paulo from TryPost.it ``` +**Always link the GitHub release from the byline** — make `Release ` a link to `https://github.com///releases/tag/` (as shown above). It gives developer-minded readers the raw PR-level changelog without cluttering the body. + #### Theme grouping (AI clusters by user impact) Read all commits since the last tag and cluster into **2-3 user-facing themes**. Use whatever frame makes the changes feel coherent to a customer, not to a developer. @@ -159,7 +161,9 @@ #### Bullet rules for "New features" / "Fixes" #### Subject line -Pattern: `Changelog: TryPost — , , ...` +Pattern: `Changelog — , , ...` + +Don't put "TryPost" in the subject — the email already comes from the TryPost sender, so it's redundant. Cap around 80 chars. If themes don't fit, shorten to the 2 most impactful + "and more...". @@ -167,13 +171,13 @@ ### Step 5 — Humanize the email prose Run the email body through the `humanizer` skill before previewing: -1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo Castellano from TryPost.it' signature."* +1. Invoke the `Skill` tool with `skill: humanizer` and pass the draft email body plus this context: *"This is a customer-facing changelog email for TryPost (social media scheduler SaaS). Tone: developer founder writing to early users on a Friday — warm, specific, no marketing puffery. Cal.com style. Keep the existing structure (subject frontmatter, section headers, bullets, signature). Do not strip section headers or the 'Cheers, Paulo from TryPost.it' signature."* 2. Replace the draft email body with the humanized version. **Do NOT humanize:** - The changelog from Step 3 (flat commit list, no prose). - The subject line frontmatter. -- The literal signature `Cheers,\nPaulo Castellano from TryPost.it` — keep it exact. +- The literal signature `Cheers,\nPaulo from TryPost.it` — keep it exact. The humanizer skill itself covers all patterns. Trust it. diff --git a/.env.example b/.env.example index dc13c3bb..235552aa 100644 --- a/.env.example +++ b/.env.example @@ -162,6 +162,13 @@ TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= TELEGRAM_WEBHOOK_SECRET= +# Discord (single shared app+bot — create one via https://discord.com/developers/applications) +# OAuth2 → add the redirect below; Bot → enable "Server Members Intent" for mention search. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_BOT_TOKEN= +DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback" + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/CLAUDE.md b/CLAUDE.md index 0c066141..ce363040 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -277,6 +277,14 @@ ## Backend Validation - Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller. - Naming: `Request.php` (e.g. `StorePostRequest`, `ApplyPostTemplateRequest`, `IndexPostTemplateRequest`). +## Per-Platform Post Meta (`PostPlatform.meta`) + +- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`. + - Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool. + - Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized. +- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline. +- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php`. + ## Pest / Feature Tests - ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`. diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index cc0b02b3..6bf2a388 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -10,11 +10,11 @@ use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; +use App\Services\Automation\FeedParser; use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Http; use RuntimeException; -use SimpleXMLElement; use Throwable; /** @@ -40,6 +40,7 @@ public function __construct( private ExpressionResolver $resolver, private SafeHttpFetcher $safeHttp, private AdvanceAutomationRun $advance, + private FeedParser $parser, ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult @@ -59,15 +60,15 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - $response = Http::get($feedUrl); + $response = Http::timeout(10)->get($feedUrl); if (! $response->successful()) { return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]); } - try { - $xml = new SimpleXMLElement($response->body()); - } catch (Throwable) { + $items = $this->parser->parse($response->body()); + + if ($items === null) { return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed')); } @@ -83,7 +84,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ? CarbonImmutable::createFromTimestamp(0) : $this->parseWatermark(data_get($state->data, 'last_item_date')); - [$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark); + [$newItems, $newestSeen] = $this->collectNewItems($items, $watermark); if ($state !== null && $newestSeen !== null) { $state->update(['data' => array_merge($state->data ?? [], [ @@ -95,58 +96,66 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE); } - $first = array_shift($newItems); + $total = count($newItems); - if (! $isPreview) { - $this->spawnSiblings($run, $nodeId, $newItems); + // A preview (manual/dry test) surfaces ONE item and never fans out, so it + // shows the newest item — what the user expects to test against. A real run + // takes the oldest new item and fans the rest out as siblings, preserving + // feed chronology across branches (items are sorted oldest-first). + if ($isPreview) { + return NodeRunResult::completed([ + 'fetch' => ['count' => $total, 'spawned' => 0], + 'fetched' => end($newItems), + ]); } + $first = array_shift($newItems); + $this->spawnSiblings($run, $nodeId, $newItems); + return NodeRunResult::completed([ - 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)], + 'fetch' => ['count' => $total, 'spawned' => count($newItems)], 'fetched' => $first, ]); } - private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array + /** + * @param list> $parsed Normalized items from FeedParser. + * @return array{0: list>, 1: ?CarbonImmutable} + */ + private function collectNewItems(array $parsed, CarbonImmutable $watermark): array { $items = []; $newestSeen = null; - foreach ($xml->channel->item ?? [] as $item) { - $key = (string) ($item->guid ?? $item->link); + foreach ($parsed as $item) { + $key = (string) data_get($item, 'key', ''); if ($key === '') { continue; } - $pubDate = $this->parsePubDate((string) $item->pubDate); - if ($pubDate === null) { + $date = $this->parsePubDate((string) data_get($item, 'date', '')); + if ($date === null) { continue; } - if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) { - $newestSeen = $pubDate; + if ($newestSeen === null || $date->greaterThan($newestSeen)) { + $newestSeen = $date; } - if (! $pubDate->greaterThan($watermark)) { + if (! $date->greaterThan($watermark)) { continue; } - $items[] = [ - '_pubDate' => $pubDate, - 'key' => $key, - 'title' => (string) $item->title, - 'link' => (string) $item->link, - 'description' => (string) $item->description, - 'pubDate' => (string) $item->pubDate, - ]; + $item['_sort'] = $date->getTimestamp(); + $items[] = $item; } // Process oldest-first so siblings inherit a stable order matching feed chronology. - usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp()); + usort($items, fn ($a, $b) => $a['_sort'] <=> $b['_sort']); // Drop the internal sort key — downstream nodes shouldn't see it. $items = array_map(function (array $item): array { - unset($item['_pubDate']); + unset($item['_sort']); return $item; }, $items); diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 4c3214ff..0c525df9 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -53,6 +53,9 @@ enum ContentType: string // Telegram case TelegramPost = 'telegram_post'; + // Discord + case DiscordMessage = 'discord_message'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -81,6 +84,7 @@ public function label(): string self::BlueskyPost => 'Post', self::MastodonPost => 'Post', self::TelegramPost => 'Post', + self::DiscordMessage => 'Message', }; } @@ -104,6 +108,7 @@ public function platform(): SocialPlatform self::BlueskyPost => SocialPlatform::Bluesky, self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, + self::DiscordMessage => SocialPlatform::Discord, }; } @@ -173,6 +178,7 @@ public function maxMediaCount(): int self::BlueskyPost => 4, self::MastodonPost => 4, self::TelegramPost => 10, + self::DiscordMessage => 10, }; } @@ -193,6 +199,7 @@ public function supportsVideo(): bool self::BlueskyPost => true, self::MastodonPost => true, self::TelegramPost => true, + self::DiscordMessage => true, }; } @@ -232,6 +239,7 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::InstagramFeed => false, + self::DiscordMessage => false, default => true, }; } @@ -313,6 +321,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Bluesky => self::BlueskyPost, SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, + SocialPlatform::Discord => self::DiscordMessage, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 25337147..0d645c97 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -21,6 +21,7 @@ enum Platform: string case Bluesky = 'bluesky'; case Mastodon = 'mastodon'; case Telegram = 'telegram'; + case Discord = 'discord'; public function label(): string { @@ -38,6 +39,7 @@ public function label(): string self::Bluesky => 'Bluesky', self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', + self::Discord => 'Discord', }; } @@ -56,6 +58,7 @@ public function color(): string self::Bluesky => '#0085FF', self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', + self::Discord => '#5865F2', }; } @@ -73,6 +76,7 @@ public function allowedMediaTypes(): array self::Bluesky => [MediaType::Image, MediaType::Video], self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], + self::Discord => [MediaType::Image, MediaType::Video], }; } @@ -90,6 +94,7 @@ public function maxImages(): int self::Bluesky => 4, self::Mastodon => 4, self::Telegram => 10, + self::Discord => 10, }; } @@ -129,6 +134,7 @@ public function maxContentLength(): int self::Bluesky => 300, self::Mastodon => 500, self::Telegram => 4096, + self::Discord => 2000, }; } @@ -172,6 +178,8 @@ public function recommendedAiContentLength(): int self::YouTube => 80, // Telegram channel posts — short announcements read best self::Telegram => 400, + // Discord — conversational community posts read best when concise + self::Discord => 280, }; } @@ -194,6 +202,7 @@ public function requiredPublishScopes(): array self::Bluesky => [], self::Mastodon => ['write:statuses'], self::Telegram => [], + self::Discord => [], }; } @@ -211,6 +220,7 @@ public function supportsTextOnly(): bool self::Bluesky => true, self::Mastodon => true, self::Telegram => true, + self::Discord => true, }; } diff --git a/app/Exceptions/Social/DiscordPublishException.php b/app/Exceptions/Social/DiscordPublishException.php new file mode 100644 index 00000000..3aaa40cf --- /dev/null +++ b/app/Exceptions/Social/DiscordPublishException.php @@ -0,0 +1,89 @@ +status(); + $rawResponse = $response->body(); + $code = (int) data_get($response->json(), 'code'); + $message = (string) data_get($response->json(), 'message', 'An unknown Discord error occurred.'); + + // Missing access / missing permissions: the bot can't see or post in the channel. + if (in_array($code, [50001, 50013], true) || $status === 403) { + return new static( + userMessage: "The bot can't post in this channel. Make sure it has access and permission to send messages there.", + category: ErrorCategory::Permission, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // Unknown channel — it was deleted or the id is stale. + if ($code === 10003) { + return new static( + userMessage: 'That Discord channel no longer exists. Pick a different channel.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // Attachment too large. + if ($code === 40005 || $status === 413) { + return new static( + userMessage: "A media file exceeds Discord's upload size limit.", + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // 401: the configured bot token is invalid (operator-level misconfiguration). + if ($status === 401) { + return new static( + userMessage: 'Discord rejected the bot token. Check the DISCORD_BOT_TOKEN configuration.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Discord rate limit reached. Please try again shortly.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Discord is temporarily unavailable. Please try again later.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $message, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'discord'; + } +} diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index cfd6b898..e691eb8c 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -17,11 +17,13 @@ use App\Actions\Automation\Run\TestAutomation; use App\Http\Controllers\Controller; use App\Http\Requests\App\Automations\ActivateAutomationRequest; +use App\Http\Requests\App\Automations\InspectFeedRequest; use App\Http\Requests\App\Automations\PauseAutomationRequest; use App\Http\Requests\App\Automations\RetryRunRequest; use App\Http\Requests\App\Automations\StoreAutomationRequest; use App\Http\Requests\App\Automations\TestAutomationRequest; use App\Http\Requests\App\Automations\UpdateAutomationRequest; +use App\Http\Resources\App\Automation\FeedInspectionResource; use App\Http\Resources\App\PlatformConfigResource; use App\Http\Resources\App\SocialAccountResource; use App\Http\Resources\AutomationInvocationResource; @@ -31,11 +33,16 @@ use App\Models\Automation; use App\Models\AutomationNodeRun; use App\Models\AutomationRun; +use App\Services\Automation\ExpressionResolver; +use App\Services\Automation\FeedParser; +use App\Services\Brand\SafeHttpFetcher; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response as HttpResponse; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class AutomationController extends Controller { @@ -203,6 +210,38 @@ public function test(TestAutomationRequest $request, Automation $automation, Tes return response()->json(['run_id' => $run->id]); } + public function inspectFeed( + InspectFeedRequest $request, + Automation $automation, + ExpressionResolver $resolver, + SafeHttpFetcher $safeHttp, + FeedParser $parser, + ): JsonResponse|FeedInspectionResource { + $this->authorize('update', $automation); + + $feedUrl = $resolver->resolve( + (string) $request->validated('feed_url'), + ['variables' => $automation->resolvedVariables()], + ); + + // SafeHttpFetcher::get() bundles the SSRF guard, a request timeout, a + // redirect cap and a branded user-agent — so a slow or hostile feed can't + // hang this synchronous request. It throws on SSRF, timeout or non-2xx. + try { + $response = $safeHttp->get($feedUrl); + } catch (RuntimeException) { + return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $items = $parser->parse($response->body()); + + if ($items === null) { + return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + return new FeedInspectionResource($items[0] ?? []); + } + public function showRun(Automation $automation, AutomationRun $run): JsonResponse { $this->authorize('view', $automation); diff --git a/app/Http/Controllers/App/DiscordController.php b/app/Http/Controllers/App/DiscordController.php new file mode 100644 index 00000000..83d230e4 --- /dev/null +++ b/app/Http/Controllers/App/DiscordController.php @@ -0,0 +1,59 @@ +authorizeDiscordAccount($request, $account); + + // Degrade to an empty list on a transient Discord outage — the picker + // shows "no channels" rather than erroring; the publish path still + // retries (channels() throws there). + return response()->json([ + 'channels' => rescue( + fn () => $this->discord->channels((string) $account->platform_user_id), + [], + report: false, + ), + ]); + } + + public function mentions(IndexDiscordMentionRequest $request, SocialAccount $account): JsonResponse + { + $this->authorizeDiscordAccount($request, $account); + + return response()->json([ + 'mentions' => $this->discord->mentions( + (string) $account->platform_user_id, + (string) $request->validated('q', ''), + ), + ]); + } + + private function authorizeDiscordAccount(Request $request, SocialAccount $account): void + { + $workspace = $request->user()->currentWorkspace; + + abort_unless( + $workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Discord, + Response::HTTP_FORBIDDEN, + ); + + $this->authorize('view', $workspace); + } +} diff --git a/app/Http/Controllers/Auth/DiscordController.php b/app/Http/Controllers/Auth/DiscordController.php new file mode 100644 index 00000000..87ff8c25 --- /dev/null +++ b/app/Http/Controllers/Auth/DiscordController.php @@ -0,0 +1,38 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.discord.scopes')); + } + + public function callback(Request $request): View + { + return $this->handleCallback($request, $this->platform, $this->driver); + } +} diff --git a/app/Http/Requests/Api/Post/StorePostRequest.php b/app/Http/Requests/Api/Post/StorePostRequest.php index 979a448c..cbdb4c0a 100644 --- a/app/Http/Requests/Api/Post/StorePostRequest.php +++ b/app/Http/Requests/Api/Post/StorePostRequest.php @@ -4,12 +4,12 @@ namespace App\Http\Requests\Api\Post; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\SocialAccount; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPlatform; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -50,8 +50,7 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform, ], - 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), 'scheduled_at' => ['nullable', 'date', 'after:now'], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => [ diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 939e4a5d..7d3ef18e 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -5,16 +5,17 @@ namespace App\Http\Requests\Api\Post; use App\Enums\Post\Status; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Models\Post; use App\Models\PostPlatform; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; +use Illuminate\Validation\Validator; class UpdatePostRequest extends FormRequest { @@ -51,8 +52,7 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform, ], - 'platforms.*.meta' => ['nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), 'scheduled_at' => [ 'nullable', 'date', @@ -66,6 +66,23 @@ public function rules(): array ]; } + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { + return; + } + + $platformsById = $this->resolveSelectedPlatforms(); + + PostPlatformMetaRules::addRequiredOnPublishErrors( + $validator, + $this->input('platforms', []), + fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null, + ); + }); + } + /** * @return Collection */ diff --git a/app/Http/Requests/App/Automations/InspectFeedRequest.php b/app/Http/Requests/App/Automations/InspectFeedRequest.php new file mode 100644 index 00000000..e3d878e5 --- /dev/null +++ b/app/Http/Requests/App/Automations/InspectFeedRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'feed_url' => ['required', 'string', 'max:2048'], + ]; + } +} diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index bae5eb20..0b0a3677 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -12,6 +12,7 @@ use App\Enums\Automation\Publish\Mode as PublishMode; use App\Enums\Automation\ScheduleField; use App\Enums\Automation\Trigger\Type as TriggerType; +use App\Rules\ResolvableUrl; use App\Services\Automation\AutomationConfigValidator; use App\Services\Automation\GenerateNodeValidator; use Illuminate\Contracts\Validation\Validator; @@ -129,10 +130,13 @@ private function dataRulesForNodeType(?string $type, int $i): array 'schedule_timezone' => ['sometimes', 'string', 'timezone'], ], NodeType::FetchRss->value => [ - 'feed_url' => ['required', 'url'], + 'feed_url' => ['required', new ResolvableUrl], + 'discovered_fields' => ['sometimes', 'array'], + 'discovered_fields.*.path' => ['required', 'string'], + 'discovered_fields.*.sample' => ['nullable', 'string'], ], NodeType::HttpRequest->value => [ - 'url' => ['required', 'url'], + 'url' => ['required', new ResolvableUrl], 'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))], 'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))], 'auth_token' => ['nullable', 'string'], @@ -167,7 +171,7 @@ private function dataRulesForNodeType(?string $type, int $i): array 'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'], ], NodeType::Webhook->value => [ - 'url' => ['required', 'url'], + 'url' => ['required', new ResolvableUrl], 'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))], 'payload_template' => ['nullable', 'string'], 'headers' => ['nullable', 'array'], diff --git a/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php new file mode 100644 index 00000000..25967af3 --- /dev/null +++ b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'q' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 47941085..c246ff57 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -6,11 +6,11 @@ use App\Enums\Media\Source; use App\Enums\Post\Status; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; use App\Rules\ContentFitsPlatformLimits; use App\Rules\ContentTypeCompatibleWithMedia; +use App\Support\PostPlatformMetaRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -70,18 +70,7 @@ public function rules(): array Rule::in(array_column(ContentType::cases(), 'value')), Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]), ], - 'platforms.*.meta' => ['nullable', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], - 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])], - 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], - 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], - 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], - 'platforms.*.meta.disclose' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], - 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], - 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], + ...PostPlatformMetaRules::rules(), 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; @@ -102,24 +91,11 @@ public function withValidator(Validator $validator): void ->whereIn('id', $ids) ->pluck('platform', 'id'); - foreach ($platforms as $i => $platform) { - $platformEnum = $platformsById[data_get($platform, 'id')] ?? null; - if ($platformEnum === Platform::TikTok - && blank(data_get($platform, 'meta.privacy_level'))) { - $validator->errors()->add( - "platforms.{$i}.meta.privacy_level", - trans('posts.form.tiktok.privacy_required'), - ); - } - - if ($platformEnum === Platform::Pinterest - && blank(data_get($platform, 'meta.board_id'))) { - $validator->errors()->add( - "platforms.{$i}.meta.board_id", - trans('posts.form.pinterest.board_required'), - ); - } - } + PostPlatformMetaRules::addRequiredOnPublishErrors( + $validator, + $platforms, + fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null, + ); }); } diff --git a/app/Http/Resources/App/Automation/FeedInspectionResource.php b/app/Http/Resources/App/Automation/FeedInspectionResource.php new file mode 100644 index 00000000..7aa2aed0 --- /dev/null +++ b/app/Http/Resources/App/Automation/FeedInspectionResource.php @@ -0,0 +1,69 @@ + $resource The first normalized feed item. + */ +class FeedInspectionResource extends JsonResource +{ + private const SAMPLE_MAX_LENGTH = 120; + + /** + * @return array{fields: list} + */ + public function toArray(Request $request): array + { + return [ + 'fields' => $this->flatten((array) $this->resource), + ]; + } + + /** + * @param array $item + * @return list + */ + private function flatten(array $item, string $prefix = 'fetched'): array + { + $fields = []; + + foreach ($item as $key => $value) { + $path = "{$prefix}.{$key}"; + + if (is_array($value) && $this->isAssoc($value)) { + $fields = array_merge($fields, $this->flatten($value, $path)); + + continue; + } + + $fields[] = ['path' => $path, 'sample' => $this->sample($value)]; + } + + return $fields; + } + + /** + * @param array $value + */ + private function isAssoc(array $value): bool + { + return $value !== [] && ! array_is_list($value); + } + + private function sample(mixed $value): string + { + $text = is_array($value) + ? (json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: '') + : (string) $value; + + return str($text)->squish()->limit(self::SAMPLE_MAX_LENGTH)->value(); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index e4d494d1..5bf96054 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -19,6 +19,7 @@ use App\Models\PostPlatform; use App\Services\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; +use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; @@ -222,7 +223,7 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -237,6 +238,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Bluesky => app(BlueskyPublisher::class), SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), + SocialPlatform::Discord => app(DiscordPublisher::class), }; } diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 35437834..80851f06 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -5,10 +5,10 @@ namespace App\Mcp\Tools\Post; use App\Actions\Post\CreatePost; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Rules\ContentTypeMatchesPlatform; +use App\Support\PostPlatformMetaRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; use Laravel\Mcp\Request; @@ -38,8 +38,7 @@ public function handle(Request $request): ResponseFactory ->where('is_active', true), ], 'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform], - 'platforms.*.meta' => ['sometimes', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), ]); $post = CreatePost::execute($workspace, $request->user(), $validated); @@ -61,7 +60,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'), 'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'), - 'meta' => $p->object()->description('Per-platform metadata (e.g. aspect_ratio: 1:1|4:5|16:9|original for Instagram/Facebook feed images).'), + 'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'), ])) ->description('Platforms to publish on. Accounts not listed remain available but disabled.'), ]; diff --git a/app/Mcp/Tools/Post/PublishPostTool.php b/app/Mcp/Tools/Post/PublishPostTool.php index ee95f439..da2feac9 100644 --- a/app/Mcp/Tools/Post/PublishPostTool.php +++ b/app/Mcp/Tools/Post/PublishPostTool.php @@ -9,6 +9,7 @@ use App\Enums\Post\Status; use App\Http\Resources\Api\PostResource; use App\Models\Post; +use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -41,6 +42,8 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.'); } + PostPlatformMetaRules::assertStoredPostPublishable($post); + $scheduledAt = data_get($validated, 'scheduled_at'); $result = UpdatePost::execute($workspace, $post, [ diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index c3e26aae..055b5942 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -7,11 +7,11 @@ use App\Actions\Post\UpdatePost; use App\Enums\Post\Action as PostAction; use App\Enums\Post\Status; -use App\Enums\PostPlatform\AspectRatio; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\Api\PostResource; use App\Models\Post; use App\Rules\ContentTypeMatchesPostPlatform; +use App\Support\PostPlatformMetaRules; use App\Support\PostStatusRules; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Validation\Rule; @@ -49,8 +49,7 @@ public function handle(Request $request): Response|ResponseFactory Rule::exists('post_platforms', 'id')->where('post_id', $post->id), ], 'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform], - 'platforms.*.meta' => ['sometimes', 'array'], - 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + ...PostPlatformMetaRules::rules(), ]); $payload = collect($validated)->except('post_id')->all(); @@ -84,7 +83,7 @@ public function schema(JsonSchema $schema): array ->items($schema->object(fn ($p) => [ 'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'), 'content_type' => $p->string()->description('New content_type for this platform.'), - 'meta' => $p->object()->description('Per-platform metadata override (e.g. aspect_ratio, board_id for Pinterest).'), + 'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'), ])) ->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'), ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d5ed7d9..1ea3193d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ use App\Models\WorkspaceSignature; use App\Services\PostHogService; use App\Services\PostTemplate\Registry as PostTemplateRegistry; +use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; use Carbon\CarbonImmutable; @@ -186,6 +187,12 @@ protected function configureSocialite(): void return Socialite::buildProvider(InstagramProvider::class, $config); }); + Socialite::extend('discord', function ($app) { + $config = $app['config']['services.discord']; + + return Socialite::buildProvider(DiscordProvider::class, $config); + }); + Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class); diff --git a/app/Rules/ResolvableUrl.php b/app/Rules/ResolvableUrl.php new file mode 100644 index 00000000..45f899bf --- /dev/null +++ b/app/Rules/ResolvableUrl.php @@ -0,0 +1,40 @@ +translate(); + + return; + } + + $candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value); + $scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME)); + + // Require a real http(s) URL once expressions are substituted, so the rule + // is no weaker than the plain `url` rule it replaces (rejects file://, + // javascript://, etc.) while still allowing templated hosts/queries. + if (filter_var($candidate, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) { + $fail('validation.url')->translate(); + } + } +} diff --git a/app/Services/Automation/FeedParser.php b/app/Services/Automation/FeedParser.php new file mode 100644 index 00000000..340c13ee --- /dev/null +++ b/app/Services/Automation/FeedParser.php @@ -0,0 +1,185 @@ + }}`. + * + * Aliases win on key collisions. The HTTP fetch and SSRF guard stay in the caller; + * the body is handed to SimplePie via `set_raw_data()` so the SSRF guard isn't bypassed. + */ +class FeedParser +{ + /** + * Namespace URI → short prefix for the raw layer. + */ + private const NS_PREFIX = [ + 'http://search.yahoo.com/mrss/' => 'media', + 'http://www.youtube.com/xml/schemas/2015' => 'yt', + 'http://purl.org/dc/elements/1.1/' => 'dc', + 'http://purl.org/dc/terms/' => 'dc', + 'http://purl.org/rss/1.0/modules/content/' => 'content', + 'http://www.itunes.com/dtds/podcast-1.0.dtd' => 'itunes', + 'https://podcastindex.org/namespace/1.0' => 'podcast', + 'http://purl.org/rss/1.0/modules/slash/' => 'slash', + 'http://wellformedweb.org/CommentAPI/' => 'wfw', + 'http://www.georss.org/georss' => 'georss', + ]; + + /** + * Namespaces treated as the feed "core" — their tags get no prefix, so RSS and + * Atom land on the same raw key names (and are then overridden by aliases). + */ + private const NS_CORE = [ + '', + 'http://www.w3.org/2005/Atom', + 'http://purl.org/rss/1.0/', + 'http://backend.userland.com/rss2', + 'http://my.netscape.com/rdf/simple/0.9/', + ]; + + /** + * @return list>|null Null when the body is not a usable feed + * (invalid XML, or any parse failure). + */ + public function parse(string $body): ?array + { + try { + $feed = new SimplePie; + $feed->enable_cache(false); + $feed->set_raw_data($body); + + if (! @$feed->init()) { + return null; + } + + return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); + } catch (Throwable) { + return null; + } + } + + /** + * @return array + */ + private function normalize(Item $item): array + { + $enclosure = $item->get_enclosure(); + + $aliases = [ + 'key' => $item->get_id(false, false) ?: $item->get_permalink(), + 'title' => $item->get_title(), + 'link' => $item->get_permalink(), + 'date' => $item->get_date('c'), + 'pubDate' => $item->get_date('c'), + 'content' => $item->get_content(), + 'description' => $item->get_description(), + 'author' => $item->get_author()?->get_name(), + 'id' => $item->get_id(false, false), + 'image' => $enclosure?->get_thumbnail() ?: null, + 'categories' => array_values(array_filter(array_map( + fn ($category) => $category->get_label(), + $item->get_categories() ?? [], + ))), + 'enclosure' => $enclosure === null ? null : array_filter([ + 'url' => $enclosure->get_link(), + 'type' => $enclosure->get_type(), + 'length' => $enclosure->get_length(), + ], fn ($value) => $value !== null), + ]; + + // Aliases win on collision, so merge them over the raw layer. + return array_merge($this->rawFields($item), $aliases); + } + + /** + * @return array + */ + private function rawFields(Item $item): array + { + return $this->flattenChildren((array) data_get($item->data, 'child', [])); + } + + /** + * @param array $children Namespace-URI keyed tag map. + * @return array + */ + private function flattenChildren(array $children): array + { + $out = []; + + foreach ($children as $namespace => $tags) { + $prefix = $this->prefixFor((string) $namespace); + + foreach ($tags as $tag => $nodes) { + $key = $prefix === '' ? (string) $tag : "{$prefix}_{$tag}"; + $values = array_map(fn ($node) => $this->flattenNode((array) $node), $nodes); + $out[$key] = count($values) === 1 ? $values[0] : $values; + } + } + + return $out; + } + + /** + * @param array $node + */ + private function flattenNode(array $node): mixed + { + $attribs = $this->attributes($node); + $children = (array) data_get($node, 'child', []); + + if ($children !== []) { + return array_merge($this->flattenChildren($children), $attribs); + } + + $text = trim((string) data_get($node, 'data', '')); + + return match (true) { + $text !== '' && $attribs === [] => $text, + $text === '' && $attribs !== [] => $attribs, + $text !== '' && $attribs !== [] => array_merge(['_text' => $text], $attribs), + default => $text, + }; + } + + /** + * @param array $node + * @return array + */ + private function attributes(array $node): array + { + $out = []; + + foreach ((array) data_get($node, 'attribs', []) as $attrs) { + foreach ((array) $attrs as $name => $value) { + $out[(string) $name] = (string) $value; + } + } + + return $out; + } + + private function prefixFor(string $namespace): string + { + if (in_array($namespace, self::NS_CORE, true)) { + return ''; + } + + return self::NS_PREFIX[$namespace] ?? ''; + } +} diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index c17e9469..65de20e3 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -202,6 +202,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Discord => [ + 'max_width' => 2048, + 'max_size' => 8 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index de26dff9..b68e3305 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -8,6 +8,7 @@ use App\Models\Post; use App\Models\PostPlatform; use App\Services\Social\BlueskyAnalytics; +use App\Services\Social\Discord\DiscordAnalytics; use App\Services\Social\FacebookAnalytics; use App\Services\Social\InstagramAnalytics; use App\Services\Social\LinkedInPageAnalytics; @@ -66,6 +67,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform), diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 79ea1d50..92f62433 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -8,6 +8,7 @@ use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; +use App\Services\Social\Discord\DiscordClient; use App\Services\Social\Telegram\TelegramApi; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -68,6 +69,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Bluesky => $this->verifyBluesky($account), Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), + Platform::Discord => $this->verifyDiscord($account), }; } @@ -516,6 +518,12 @@ private function verifyTelegram(SocialAccount $account): bool return $response->successful() && data_get($response->json(), 'ok') === true; } + private function verifyDiscord(SocialAccount $account): bool + { + // The guild endpoint succeeds only while the bot is still a member. + return app(DiscordClient::class)->getGuild((string) $account->platform_user_id)->successful(); + } + private function verifyMastodon(SocialAccount $account): bool { $instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance'); diff --git a/app/Services/Social/Discord/DiscordAnalytics.php b/app/Services/Social/Discord/DiscordAnalytics.php new file mode 100644 index 00000000..db0d9f0c --- /dev/null +++ b/app/Services/Social/Discord/DiscordAnalytics.php @@ -0,0 +1,107 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $guildId = (string) $account->platform_user_id; + + if ($guildId === '') { + return []; + } + + try { + $response = $this->client->getGuild($guildId); + } catch (Throwable) { + return []; + } + + if ($response->failed()) { + return []; + } + + $count = data_get($response->json(), 'approximate_member_count'); + + if (! is_int($count)) { + return []; + } + + return [ + ['label' => __('analytics.metrics.members'), 'value' => $count], + ]; + } + + /** + * Post-level metrics: thread replies plus reaction counts per emoji, tagged + * so the UI renders them as pills alongside the member count. + * + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $metrics = $account + ? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account)) + : []; + + $channelId = (string) data_get($postPlatform->meta, 'channel_id'); + $messageId = (string) $postPlatform->platform_post_id; + + if ($channelId === '' || $messageId === '') { + return $metrics; + } + + try { + $response = $this->client->getMessage($channelId, $messageId); + } catch (Throwable) { + return $metrics; + } + + if ($response->failed()) { + return $metrics; + } + + $message = $response->json(); + + foreach ((array) data_get($message, 'reactions', []) as $reaction) { + $name = (string) data_get($reaction, 'emoji.name'); + $label = data_get($reaction, 'emoji.id') ? ":{$name}:" : $name; + + $metrics[] = [ + 'label' => $label !== '' ? $label : __('analytics.metrics.custom_reaction'), + 'value' => (int) data_get($reaction, 'count'), + 'kind' => 'reaction', + ]; + } + + // Thread replies are their own metric (kind "comments") so the UI renders + // them as a 💬 pill next to the member count, with a distinguishing tooltip. + $replies = (int) data_get($message, 'thread.message_count', 0); + + if ($replies > 0) { + $metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $replies, 'kind' => 'comments']; + } + + return $metrics; + } +} diff --git a/app/Services/Social/Discord/DiscordClient.php b/app/Services/Social/Discord/DiscordClient.php new file mode 100644 index 00000000..ac83a243 --- /dev/null +++ b/app/Services/Social/Discord/DiscordClient.php @@ -0,0 +1,258 @@ + + * + * @throws PlatformUnavailableException when the lookup fails transiently, so + * callers (e.g. the publish-time channel guard) retry instead of + * treating an empty result as "channel not found". + */ + public function channels(string $guildId): array + { + // Cached per guild for 5 minutes — channels change rarely and this runs + // both in the composer picker and on every publish (the channel guard), + // all against the shared, rate-limited bot token. A transient failure + // throws and is NOT cached, so the next call retries. + return Cache::remember("discord:channels:{$guildId}", self::CHANNELS_TTL, function () use ($guildId) { + $response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels"); + + if ($response->failed()) { + throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status()); + } + + $channels = $response->json(); + + if (! is_array($channels)) { + return []; + } + + $rolePermissions = $this->guildRolePermissions($guildId); + $botRoleIds = $this->botRoleIds($guildId); + + return collect($channels) + ->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true)) + ->filter(fn ($channel) => $this->botCanPostInChannel($channel, $guildId, $rolePermissions, $botRoleIds)) + ->map(fn ($channel) => [ + 'id' => (string) data_get($channel, 'id'), + 'name' => (string) data_get($channel, 'name'), + ]) + ->values() + ->all(); + }); + } + + /** + * Whether the bot has VIEW_CHANNEL + SEND_MESSAGES in a channel, computed from + * its roles' base permissions and the channel's permission overwrites (Discord's + * standard algorithm). Falls back to `true` when the bot's roles can't be + * resolved, so a transient API hiccup never hides every channel from the picker. + * + * @param array $channel + * @param array $rolePermissions role id => base permission bitfield + * @param list|null $botRoleIds role ids assigned to the bot, or null when unknown + */ + private function botCanPostInChannel(array $channel, string $guildId, array $rolePermissions, ?array $botRoleIds): bool + { + if ($botRoleIds === null || $rolePermissions === []) { + return true; + } + + $base = $rolePermissions[$guildId] ?? 0; // @everyone role id == guild id + foreach ($botRoleIds as $roleId) { + $base |= ($rolePermissions[$roleId] ?? 0); + } + + if (($base & self::PERMISSION_ADMINISTRATOR) !== 0) { + return true; + } + + $permissions = $this->applyChannelOverwrites($base, $channel, $guildId, $botRoleIds); + + return ($permissions & self::PERMISSION_VIEW_CHANNEL) !== 0 + && ($permissions & self::PERMISSION_SEND_MESSAGES) !== 0; + } + + /** + * Applies a channel's permission_overwrites to a base bitfield in Discord's + * documented order: @everyone overwrite, then the union of role overwrites, + * then the bot's member overwrite. + * + * @param array $channel + * @param list $botRoleIds + */ + private function applyChannelOverwrites(int $permissions, array $channel, string $guildId, array $botRoleIds): int + { + $overwrites = collect((array) data_get($channel, 'permission_overwrites', [])); + + $everyone = $overwrites->first(fn ($overwrite) => (string) data_get($overwrite, 'id') === $guildId); + if ($everyone !== null) { + $permissions = ($permissions & ~(int) data_get($everyone, 'deny', 0)) | (int) data_get($everyone, 'allow', 0); + } + + $roleAllow = 0; + $roleDeny = 0; + foreach ($overwrites as $overwrite) { + if ((int) data_get($overwrite, 'type') === 0 && in_array((string) data_get($overwrite, 'id'), $botRoleIds, true)) { + $roleAllow |= (int) data_get($overwrite, 'allow', 0); + $roleDeny |= (int) data_get($overwrite, 'deny', 0); + } + } + $permissions = ($permissions & ~$roleDeny) | $roleAllow; + + $botId = (string) config('services.discord.client_id'); + $member = $overwrites->first(fn ($overwrite) => (int) data_get($overwrite, 'type') === 1 && (string) data_get($overwrite, 'id') === $botId); + if ($member !== null) { + $permissions = ($permissions & ~(int) data_get($member, 'deny', 0)) | (int) data_get($member, 'allow', 0); + } + + return $permissions; + } + + /** + * Base permission bitfield per guild role (role id => permissions). Returns [] + * when the roles can't be fetched, which disables permission filtering. + * + * @return array + */ + private function guildRolePermissions(string $guildId): array + { + $permissions = []; + + foreach ($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles") as $role) { + $permissions[(string) data_get($role, 'id')] = (int) data_get($role, 'permissions', 0); + } + + return $permissions; + } + + /** + * The role ids assigned to the bot in a guild, or null when they can't be + * resolved (no client id configured, or the member lookup failed). + * + * @return list|null + */ + private function botRoleIds(string $guildId): ?array + { + $botId = (string) config('services.discord.client_id'); + + if ($botId === '') { + return null; + } + + $response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/members/{$botId}"); + + if ($response->failed()) { + return null; + } + + $roles = $response->json('roles'); + + return is_array($roles) ? array_map('strval', $roles) : null; + } + + /** + * Mentionable targets matching a query: @everyone/@here, roles, then members. + * + * @return list + */ + public function mentions(string $guildId, string $query): array + { + $query = trim($query); + $needle = mb_strtolower($query); + + $specials = collect([ + ['id' => 'everyone', 'label' => '@everyone', 'type' => 'everyone'], + ['id' => 'here', 'label' => '@here', 'type' => 'here'], + ])->filter(fn ($item) => $needle === '' || str_contains($item['label'], $needle)); + + $roles = collect($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles")) + ->filter(fn ($role) => $needle === '' || str_contains(mb_strtolower((string) data_get($role, 'name')), $needle)) + ->take(10) + ->map(fn ($role) => [ + 'id' => (string) data_get($role, 'id'), + 'label' => '@'.data_get($role, 'name'), + 'type' => 'role', + ]); + + $members = $query === '' ? collect() : collect( + $this->getList("{$this->baseUrl()}/guilds/{$guildId}/members/search", ['query' => $query, 'limit' => 10]) + )->map(fn ($member) => [ + 'id' => (string) data_get($member, 'user.id'), + 'label' => '@'.(data_get($member, 'user.global_name') ?: data_get($member, 'user.username')), + 'type' => 'user', + ]); + + return $specials->concat($roles)->concat($members)->values()->all(); + } + + /** + * GETs a Discord list endpoint, returning [] on failure or a non-list body + * (a failed call returns an error OBJECT, which must not be iterated as rows). + * + * @param array $query + * @return array + */ + private function getList(string $url, array $query = []): array + { + $body = $this->bot()->get($url, $query)->json(); + + return is_array($body) && array_is_list($body) ? $body : []; + } + + public function getGuild(string $guildId): Response + { + return $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}", ['with_counts' => 'true']); + } + + public function getMessage(string $channelId, string $messageId): Response + { + return $this->bot()->get("{$this->baseUrl()}/channels/{$channelId}/messages/{$messageId}"); + } + + private function bot(): PendingRequest + { + return $this->socialHttp() + ->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot'); + } +} diff --git a/app/Services/Social/Discord/DiscordPublisher.php b/app/Services/Social/Discord/DiscordPublisher.php new file mode 100644 index 00000000..aa35d5e3 --- /dev/null +++ b/app/Services/Social/Discord/DiscordPublisher.php @@ -0,0 +1,299 @@ +validateContentLength($postPlatform); + + $account = $postPlatform->socialAccount; + $guildId = (string) $account->platform_user_id; + $channelId = (string) data_get($postPlatform->meta, 'channel_id'); + + if ($channelId === '') { + throw new DiscordPublishException( + userMessage: 'No Discord channel selected for this post.', + category: ErrorCategory::Unknown, + ); + } + + // Posting uses the global bot token, so the channel id is the only thing + // scoping where a message lands — verify it belongs to THIS connected + // server, otherwise a crafted channel_id could post into any server the + // bot is in (including another workspace's). + $this->guardChannelBelongsToGuild($guildId, $channelId); + + $content = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : ''; + + $content = $this->appendMentions($content, $postPlatform); + + if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) { + throw new DiscordPublishException( + userMessage: 'The message (including mentions) exceeds Discord\'s 2000-character limit.', + category: ErrorCategory::Unknown, + ); + } + + $embeds = $this->buildEmbeds($postPlatform); + $media = $postPlatform->post->mediaItems->take($postPlatform->platform->maxImages()); + + $payload = array_filter([ + 'content' => $content !== '' ? $content : null, + 'embeds' => $embeds !== [] ? $embeds : null, + 'allowed_mentions' => $this->allowedMentions($postPlatform), + ], fn ($value) => $value !== null); + + $messageId = $media->isEmpty() + ? $this->send($channelId, $payload) + : $this->sendWithMedia($channelId, $payload, $media); + + return [ + 'id' => $messageId, + 'url' => "https://discord.com/channels/{$guildId}/{$channelId}/{$messageId}", + ]; + } + + private function guardChannelBelongsToGuild(string $guildId, string $channelId): void + { + $allowed = collect(app(DiscordClient::class)->channels($guildId)) + ->pluck('id') + ->contains($channelId); + + if (! $allowed) { + throw new DiscordPublishException( + userMessage: 'The bot can\'t post in the selected channel. Make sure it still exists, belongs to this server, and the bot has permission to send messages there.', + category: ErrorCategory::Permission, + ); + } + } + + /** + * @param array $payload + */ + private function send(string $channelId, array $payload): string + { + return $this->messageId($this->bot()->post($this->endpoint($channelId), $payload)); + } + + /** + * Downloads each media item, optimizes images for Discord, and uploads them + * as multipart attachments alongside the JSON payload. + * + * @param array $payload + * @param Collection $media + */ + private function sendWithMedia(string $channelId, array $payload, Collection $media): string + { + $request = $this->bot(); + $attachments = []; + $tempFiles = []; + + try { + foreach ($media->values() as $index => $item) { + $tempFile = $this->downloadMedia($item); + $tempFiles[] = $tempFile; + + $filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}"); + $request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename); + $attachments[] = ['id' => $index, 'filename' => $filename]; + } + + $payload['attachments'] = $attachments; + + return $this->messageId($request->post($this->endpoint($channelId), [ + 'payload_json' => json_encode($payload), + ])); + } finally { + foreach ($tempFiles as $tempFile) { + @unlink($tempFile); + } + } + } + + private function downloadMedia(MediaItem $item): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'discord_media_'); + + $response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->url); + + if ($response->failed() || (filesize($tempFile) ?: 0) === 0) { + @unlink($tempFile); + + throw new DiscordPublishException( + userMessage: 'Failed to download a media file for this Discord post.', + category: ErrorCategory::MediaFormat, + ); + } + + // Images are downsized to Discord's limit; videos/gifs are sent as-is. + if ($item->mime_type && str_starts_with($item->mime_type, 'image/') && ! str_contains($item->mime_type, 'gif')) { + try { + return app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Discord); + } catch (Throwable) { + throw new DiscordPublishException( + userMessage: 'Failed to process a media image for this Discord post.', + category: ErrorCategory::MediaFormat, + ); + } finally { + @unlink($tempFile); + } + } + + return $tempFile; + } + + /** + * Appends the post's Discord mention tokens to the content. Mentions live in + * meta — not the shared content — so they only ping on Discord and don't leak + * literal `<@id>` markup to other platforms. + */ + private function appendMentions(string $content, PostPlatform $postPlatform): string + { + $tokens = $this->mentionTokens($postPlatform)->implode(' '); + + if ($tokens === '') { + return $content; + } + + return $content === '' ? $tokens : "{$content}\n\n{$tokens}"; + } + + /** + * Builds an explicit allowed_mentions object from the post's mention chips so + * ONLY those targets ping. Defaults to `parse: []` (suppress everything), so a + * literal "@everyone" typed into the shared content never pings by accident. + * + * @return array + */ + private function allowedMentions(PostPlatform $postPlatform): array + { + $parse = []; + $users = []; + $roles = []; + + foreach ($this->mentionTokens($postPlatform) as $token) { + if ($token === '@everyone' || $token === '@here') { + $parse[] = 'everyone'; + } elseif (preg_match('/^<@&(\d+)>$/', $token, $matches)) { + $roles[] = $matches[1]; + } elseif (preg_match('/^<@(\d+)>$/', $token, $matches)) { + $users[] = $matches[1]; + } + } + + $allowed = ['parse' => array_values(array_unique($parse))]; + + if ($users !== []) { + $allowed['users'] = array_values(array_unique($users)); + } + + if ($roles !== []) { + $allowed['roles'] = array_values(array_unique($roles)); + } + + return $allowed; + } + + /** + * @return Collection + */ + private function mentionTokens(PostPlatform $postPlatform): Collection + { + return collect((array) data_get($postPlatform->meta, 'mentions', [])) + ->map(fn ($mention) => trim((string) data_get($mention, 'token'))) + ->filter() + ->values(); + } + + /** + * @return list> + */ + private function buildEmbeds(PostPlatform $postPlatform): array + { + return collect((array) data_get($postPlatform->meta, 'embeds', [])) + ->map(fn ($embed) => array_filter([ + 'title' => data_get($embed, 'title'), + 'description' => data_get($embed, 'description'), + 'url' => data_get($embed, 'url'), + 'color' => $this->color(data_get($embed, 'color')), + 'image' => ($image = data_get($embed, 'image')) ? ['url' => $image] : null, + ], fn ($value) => $value !== null && $value !== '')) + ->filter(fn ($embed) => $embed !== []) + ->values() + ->all(); + } + + private function color(mixed $color): ?int + { + if (! is_string($color) || $color === '') { + return null; + } + + $hex = ltrim($color, '#'); + + if (! ctype_xdigit($hex) || strlen($hex) !== 6) { + return null; + } + + return min((int) hexdec($hex), self::MAX_EMBED_COLOR); + } + + private function endpoint(string $channelId): string + { + return config('trypost.platforms.discord.api')."/channels/{$channelId}/messages"; + } + + private function bot(): PendingRequest + { + return $this->socialHttp() + ->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot'); + } + + private function messageId(Response $response): string + { + if ($response->failed()) { + throw DiscordPublishException::fromApiResponse($response); + } + + $id = (string) data_get($response->json(), 'id'); + + if ($id === '') { + throw new DiscordPublishException( + userMessage: 'Discord returned an unexpected response with no message id.', + category: ErrorCategory::Unknown, + ); + } + + return $id; + } +} diff --git a/app/Socialite/DiscordProvider.php b/app/Socialite/DiscordProvider.php new file mode 100644 index 00000000..41d2bfd1 --- /dev/null +++ b/app/Socialite/DiscordProvider.php @@ -0,0 +1,94 @@ + + */ + private array $tokenResponse = []; + + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state); + } + + /** + * @return array + */ + protected function getCodeFields($state = null): array + { + return array_merge(parent::getCodeFields($state), [ + 'permissions' => (string) config('trypost.platforms.discord.permissions'), + 'integration_type' => 0, + ]); + } + + protected function getTokenUrl(): string + { + return config('trypost.platforms.discord.oauth_api').'/token'; + } + + /** + * @return array + */ + public function getAccessTokenResponse($code): array + { + return $this->tokenResponse = parent::getAccessTokenResponse($code); + } + + /** + * The bot-authorization token response includes the guild the bot joined. + * + * @return array + */ + protected function getUserByToken($token): array + { + return (array) data_get($this->tokenResponse, 'guild', []); + } + + /** + * @param array $user The guild object. + */ + protected function mapUserToObject(array $user): User + { + $guildId = data_get($user, 'id'); + + // No guild means the user authorized without adding the bot to a server + // (or cancelled). Fail loudly so the callback shows an error instead of + // persisting a broken account with a null guild id. + if (blank($guildId)) { + throw new RuntimeException('Discord authorization did not include a server.'); + } + + $icon = data_get($user, 'icon'); + + return (new User)->setRaw($user)->map([ + 'id' => $guildId, + 'nickname' => data_get($user, 'name'), + 'name' => data_get($user, 'name'), + 'avatar' => $guildId && $icon ? "https://cdn.discordapp.com/icons/{$guildId}/{$icon}.png" : null, + ]); + } +} diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php new file mode 100644 index 00000000..d7a12682 --- /dev/null +++ b/app/Support/PostPlatformMetaRules.php @@ -0,0 +1,140 @@ + + */ + public const TIKTOK_PRIVACY_LEVELS = [ + 'PUBLIC_TO_EVERYONE', + 'MUTUAL_FOLLOW_FRIENDS', + 'FOLLOWER_OF_CREATOR', + 'SELF_ONLY', + ]; + + /** + * Validation rules for `platforms.*.meta` and all its per-platform sub-keys. + * Spread into a FormRequest/MCP tool rule set as the complete meta contract. + * + * @return array + */ + public static function rules(): array + { + return [ + 'platforms.*.meta' => ['sometimes', 'nullable', 'array'], + + // Instagram / Facebook + 'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)], + + // TikTok + 'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)], + 'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'], + 'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'], + 'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'], + 'platforms.*.meta.disclose' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], + 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], + + // Pinterest + 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], + + // Discord + '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}$/'], + ]; + } + + /** + * Adds validation errors for per-platform meta that becomes mandatory once a + * post is published or scheduled (TikTok privacy, Pinterest board, Discord + * channel), based on the submitted request platforms. The caller resolves each + * platform row to its Platform enum, since that lookup differs between create + * (by social account) and update (by post platform). + * + * @param array $platforms + * @param callable(mixed, int): ?Platform $resolvePlatform + */ + public static function addRequiredOnPublishErrors(Validator $validator, array $platforms, callable $resolvePlatform): void + { + foreach ($platforms as $index => $platform) { + $violation = self::requiredMetaViolation($resolvePlatform($platform, $index), data_get($platform, 'meta')); + + if ($violation !== null) { + [$field, $message] = $violation; + $validator->errors()->add("platforms.{$index}.meta.{$field}", $message); + } + } + } + + /** + * Asserts that every ENABLED platform already stored on a post has the meta it + * needs to publish. Used by entry points that publish a post's stored state + * without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured + * post fails fast with a clear message instead of only at publish time. + * + * @throws ValidationException + */ + public static function assertStoredPostPublishable(Post $post): void + { + $errors = []; + + foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) { + $violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta); + + if ($violation !== null) { + [$field, $message] = $violation; + $errors["platforms.{$index}.meta.{$field}"] = $message; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + /** + * The missing required meta field for a platform about to publish, or null when + * nothing is missing. Single source of "what each platform requires to publish". + * + * @return array{0: string, 1: string}|null [field, message] + */ + private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array + { + return match (true) { + $platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')], + $platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')], + $platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')], + default => null, + }; + } +} diff --git a/composer.json b/composer.json index 831fcd1f..644ed3ef 100644 --- a/composer.json +++ b/composer.json @@ -56,6 +56,7 @@ "posthog/posthog-php": "^4.1", "predis/predis": "^3.3", "sendkit/sendkit-laravel": "^1.1", + "simplepie/simplepie": "^1.9", "socialiteproviders/facebook": "^4.1", "socialiteproviders/instagram": "^5.1", "socialiteproviders/linkedin": "^5.0", diff --git a/composer.lock b/composer.lock index 77915dd9..a7d03236 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4d71722b650e74b41e1943d3ae9c814f", + "content-hash": "19849eb01cb4d8f93733666b53444c5a", "packages": [ { "name": "aws/aws-crt-php", @@ -7312,6 +7312,87 @@ }, "time": "2026-03-20T00:53:21+00:00" }, + { + "name": "simplepie/simplepie", + "version": "1.9.0", + "source": { + "type": "git", + "url": "https://github.com/simplepie/simplepie.git", + "reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992", + "reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "donatj/mock-webserver": "^2.7", + "friendsofphp/php-cs-fixer": "^2.19 || ^3.8", + "mf2/mf2": "^0.5.0", + "phpstan/phpstan": "~1.12.2", + "phpunit/phpunit": "^8 || ^9 || ^10", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1 || ^2 || ^3" + }, + "suggest": { + "ext-curl": "", + "ext-iconv": "", + "ext-intl": "", + "ext-mbstring": "", + "mf2/mf2": "Microformat module that allows for parsing HTML for microformats" + }, + "type": "library", + "autoload": { + "psr-0": { + "SimplePie": "library" + }, + "psr-4": { + "SimplePie\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Ryan Parman", + "homepage": "http://ryanparman.com/", + "role": "Creator, alumnus developer" + }, + { + "name": "Sam Sneddon", + "homepage": "https://gsnedders.com/", + "role": "Alumnus developer" + }, + { + "name": "Ryan McCue", + "email": "me@ryanmccue.info", + "homepage": "http://ryanmccue.info/", + "role": "Developer" + } + ], + "description": "A simple Atom/RSS parsing library for PHP", + "homepage": "http://simplepie.org/", + "keywords": [ + "atom", + "feeds", + "rss" + ], + "support": { + "issues": "https://github.com/simplepie/simplepie/issues", + "source": "https://github.com/simplepie/simplepie/tree/1.9.0" + }, + "time": "2025-09-12T06:34:27+00:00" + }, { "name": "socialiteproviders/facebook", "version": "4.1.0", diff --git a/config/services.php b/config/services.php index b6c194cc..e72632d6 100644 --- a/config/services.php +++ b/config/services.php @@ -111,6 +111,13 @@ 'redirect' => env('PINTEREST_CLIENT_REDIRECT'), ], + // Discord + 'discord' => [ + 'client_id' => env('DISCORD_CLIENT_ID'), + 'client_secret' => env('DISCORD_CLIENT_SECRET'), + 'redirect' => env('DISCORD_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index e69f375a..16bac3f5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -162,6 +162,20 @@ // Secret-token header Telegram echoes on every webhook call. 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'), ], + 'discord' => [ + 'enabled' => env('DISCORD_ENABLED', true), + // Single shared bot application. OAuth (bot scope) authorizes adding the + // bot to the user's server; channel listing, mentions and posting all + // use this bot token, not the user's OAuth token. + 'bot_token' => env('DISCORD_BOT_TOKEN'), + 'api' => env('DISCORD_API', 'https://discord.com/api/v10'), + 'oauth_api' => env('DISCORD_OAUTH_API', 'https://discord.com/api/oauth2'), + // Permission bitfield requested for the bot: VIEW_CHANNEL (1<<10) + + // SEND_MESSAGES (1<<11) + EMBED_LINKS (1<<14) + ATTACH_FILES (1<<15) + + // READ_MESSAGE_HISTORY (1<<16) + MENTION_EVERYONE (1<<17) = 248832. + 'permissions' => env('DISCORD_PERMISSIONS', '248832'), + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), + ], ], ]; diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 508abec8..cb39e970 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -155,6 +155,14 @@ public function facebook(): static ]); } + public function discord(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Discord, + 'content_type' => ContentType::DiscordMessage, + ]); + } + public function facebookReel(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index e18e27fc..927aba12 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -154,6 +154,19 @@ public function telegram(): static ]); } + public function discord(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Discord, + 'scopes' => Platform::Discord->requiredPublishScopes(), + 'token_expires_at' => null, // posting uses the global bot token + 'access_token' => '', + 'refresh_token' => '', + 'platform_user_id' => '999000111', // guild id + 'display_name' => 'My Server', + ]); + } + public function disconnected(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 71a3369f..52871353 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -52,6 +52,7 @@ 'bluesky' => 'Connect your Bluesky account', 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', + 'discord' => 'Connect a Discord server', ], 'disconnect_modal' => [ diff --git a/lang/en/analytics.php b/lang/en/analytics.php index ee6fbf22..99c8190c 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impressions', 'interactions' => 'Interactions', 'likes' => 'Likes', + 'members' => 'Members', 'minutes_watched' => 'Minutes Watched', 'organic_followers' => 'Organic Followers', 'outbound_clicks' => 'Outbound Clicks', diff --git a/lang/en/automations.php b/lang/en/automations.php index 92089863..308f748e 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'When the post was published', 'fetched_title' => 'Fetched item title', 'fetched_link' => 'Fetched item link', - 'fetched_description' => 'Fetched item description', + 'fetched_date' => 'Fetched item publish date', + 'fetched_content' => 'Fetched item full content', + 'fetched_description' => 'Fetched item summary', + 'fetched_author' => 'Fetched item author', + 'fetched_image' => 'Fetched item image URL', + 'fetched_categories' => 'Fetched item categories', + 'fetched_enclosure' => 'Fetched item media (audio/video/file)', 'fetched_pubdate' => 'Fetched item publish date', 'fetched_http' => 'Fetched HTTP item (append a field)', 'generated_content' => 'AI-generated post content', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'Feed URL', 'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.', + 'inspect' => 'Inspect feed', + 'inspecting' => 'Inspecting…', + 'inspect_hint' => 'Fetch a sample to discover the available fields for use in downstream nodes.', + 'inspect_error' => 'Could not read this feed. Check the URL and try again.', + 'discovered_fields' => 'Available fields', + 'discovered_empty' => 'No fields found in the latest item.', ], 'http_request' => [ 'url' => 'URL', diff --git a/lang/en/posts.php b/lang/en/posts.php index b38b845d..6fe57b73 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -161,6 +161,26 @@ 'no_board_found' => 'No board matches your search.', 'board_required' => 'Select a Pinterest board to publish this post.', ], + 'discord' => [ + 'settings' => 'Discord Settings', + 'posting_to' => 'Posting to', + 'channel' => 'Channel', + 'select_channel' => 'Select a channel', + 'loading_channels' => 'Loading channels…', + 'search_channel' => 'Search channels…', + 'no_channels' => 'No channels found.', + 'channel_required' => 'Select a Discord channel to publish this post.', + 'mentions' => 'Mentions', + 'search_mention' => 'Mention a role or member…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Add embed', + 'embed_title' => 'Embed title', + 'embed_description' => 'Embed description', + 'embed_url' => 'Embed URL', + 'embed_image' => 'Image URL', + 'embed_color' => 'Color', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -488,6 +508,10 @@ 'label' => 'Post', 'description' => 'Text post with optional media', ], + 'discord_message' => [ + 'label' => 'Message', + 'description' => 'Message to a Discord channel with optional media & embeds', + ], ], 'platforms' => [ @@ -591,6 +615,7 @@ 'threads_post' => 'Threads Post', 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', + 'discord_message' => 'Discord Message', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 4fe5eb1e..3a8ed353 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -52,6 +52,7 @@ 'bluesky' => 'Conecta tu cuenta de Bluesky', 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', + 'discord' => 'Conecta un servidor de Discord', ], 'disconnect_modal' => [ diff --git a/lang/es/analytics.php b/lang/es/analytics.php index 4f0a4ab0..da4d2a40 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impresiones', 'interactions' => 'Interacciones', 'likes' => 'Me gusta', + 'members' => 'Miembros', 'minutes_watched' => 'Minutos Vistos', 'organic_followers' => 'Seguidores Orgánicos', 'outbound_clicks' => 'Clics Externos', diff --git a/lang/es/automations.php b/lang/es/automations.php index a4284c34..0371e776 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'Cuándo se publicó el post', 'fetched_title' => 'Título del ítem obtenido', 'fetched_link' => 'Enlace del ítem obtenido', - 'fetched_description' => 'Descripción del ítem obtenido', + 'fetched_date' => 'Fecha de publicación del ítem obtenido', + 'fetched_content' => 'Contenido completo del ítem obtenido', + 'fetched_description' => 'Resumen del ítem obtenido', + 'fetched_author' => 'Autor del ítem obtenido', + 'fetched_image' => 'URL de la imagen del ítem obtenido', + 'fetched_categories' => 'Categorías del ítem obtenido', + 'fetched_enclosure' => 'Multimedia del ítem (audio/vídeo/archivo)', 'fetched_pubdate' => 'Fecha de publicación del ítem obtenido', 'fetched_http' => 'Ítem HTTP obtenido (añade un campo)', 'generated_content' => 'Contenido del post generado por IA', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'URL del feed', 'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.', + 'inspect' => 'Inspeccionar feed', + 'inspecting' => 'Inspeccionando…', + 'inspect_hint' => 'Obtén una muestra para descubrir los campos disponibles para usar en los siguientes nodos.', + 'inspect_error' => 'No se pudo leer este feed. Revisa la URL e inténtalo de nuevo.', + 'discovered_fields' => 'Campos disponibles', + 'discovered_empty' => 'No se encontraron campos en el último ítem.', ], 'http_request' => [ 'url' => 'URL', diff --git a/lang/es/posts.php b/lang/es/posts.php index 220880f2..c1da9111 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -161,6 +161,26 @@ 'no_board_found' => 'Ningún tablero coincide con tu búsqueda.', 'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.', ], + 'discord' => [ + 'settings' => 'Configuración de Discord', + 'posting_to' => 'Publicando en', + 'channel' => 'Canal', + 'select_channel' => 'Selecciona un canal', + 'loading_channels' => 'Cargando canales…', + 'search_channel' => 'Buscar canales…', + 'no_channels' => 'No se encontraron canales.', + 'channel_required' => 'Selecciona un canal de Discord para publicar este post.', + 'mentions' => 'Menciones', + 'search_mention' => 'Menciona un rol o miembro…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Añadir embed', + 'embed_title' => 'Título del embed', + 'embed_description' => 'Descripción del embed', + 'embed_url' => 'URL del embed', + 'embed_image' => 'URL de la imagen', + 'embed_color' => 'Color', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -488,6 +508,10 @@ 'label' => 'Post', 'description' => 'Post de texto con multimedia opcional', ], + 'discord_message' => [ + 'label' => 'Mensaje', + 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', + ], ], 'platforms' => [ @@ -592,6 +616,7 @@ 'threads_post' => 'Post en Threads', 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', + 'discord_message' => 'Mensaje de Discord', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 9323dc74..a4382bb9 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -52,6 +52,7 @@ 'bluesky' => 'Conecte sua conta do Bluesky', 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', + 'discord' => 'Conecte um servidor do Discord', ], 'disconnect_modal' => [ diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index 95e2139d..bad4c550 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -21,6 +21,7 @@ 'impressions' => 'Impressões', 'interactions' => 'Interações', 'likes' => 'Curtidas', + 'members' => 'Membros', 'minutes_watched' => 'Minutos Assistidos', 'organic_followers' => 'Seguidores Orgânicos', 'outbound_clicks' => 'Cliques Externos', diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index 605cba9d..d937d8f0 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -144,7 +144,13 @@ 'trigger_post_published_at' => 'Quando o post foi publicado', 'fetched_title' => 'Título do item buscado', 'fetched_link' => 'Link do item buscado', - 'fetched_description' => 'Descrição do item buscado', + 'fetched_date' => 'Data de publicação do item buscado', + 'fetched_content' => 'Conteúdo completo do item buscado', + 'fetched_description' => 'Resumo do item buscado', + 'fetched_author' => 'Autor do item buscado', + 'fetched_image' => 'URL da imagem do item buscado', + 'fetched_categories' => 'Categorias do item buscado', + 'fetched_enclosure' => 'Mídia do item (áudio/vídeo/arquivo)', 'fetched_pubdate' => 'Data de publicação do item buscado', 'fetched_http' => 'Item HTTP buscado (adicione um campo)', 'generated_content' => 'Conteúdo do post gerado por IA', @@ -330,6 +336,12 @@ 'fetch_rss' => [ 'feed_url' => 'URL do feed', 'feed_url_hint' => 'Na primeira execução, o watermark é setado pra "agora" pra não inundar os próximos nós com items históricos. Execuções seguintes só veem items novos.', + 'inspect' => 'Inspecionar feed', + 'inspecting' => 'Inspecionando…', + 'inspect_hint' => 'Busca uma amostra pra descobrir os campos disponíveis pra usar nos próximos nós.', + 'inspect_error' => 'Não foi possível ler esse feed. Confira a URL e tente de novo.', + 'discovered_fields' => 'Campos disponíveis', + 'discovered_empty' => 'Nenhum campo encontrado no último item.', ], 'http_request' => [ 'url' => 'URL', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e3311e02..831b745e 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -161,6 +161,26 @@ 'no_board_found' => 'Nenhum quadro encontrado.', 'board_required' => 'Selecione um quadro do Pinterest para publicar este post.', ], + 'discord' => [ + 'settings' => 'Configurações do Discord', + 'posting_to' => 'Publicando em', + 'channel' => 'Canal', + 'select_channel' => 'Selecione um canal', + 'loading_channels' => 'Carregando canais…', + 'search_channel' => 'Buscar canais…', + 'no_channels' => 'Nenhum canal encontrado.', + 'channel_required' => 'Selecione um canal do Discord para publicar este post.', + 'mentions' => 'Menções', + 'search_mention' => 'Mencione um cargo ou membro…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Adicionar embed', + 'embed_title' => 'Título do embed', + 'embed_description' => 'Descrição do embed', + 'embed_url' => 'URL do embed', + 'embed_image' => 'URL da imagem', + 'embed_color' => 'Cor', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -488,6 +508,10 @@ 'label' => 'Post', 'description' => 'Post de texto com mídia opcional', ], + 'discord_message' => [ + 'label' => 'Mensagem', + 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', + ], ], 'platforms' => [ @@ -591,6 +615,7 @@ 'threads_post' => 'Post no Threads', 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', + 'discord_message' => 'Mensagem do Discord', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', diff --git a/public/images/accounts/discord.png b/public/images/accounts/discord.png new file mode 100644 index 00000000..90af762a Binary files /dev/null and b/public/images/accounts/discord.png differ diff --git a/releases/v1.0.2/changelog.md b/releases/v1.0.2/changelog.md new file mode 100644 index 00000000..105bee6a --- /dev/null +++ b/releases/v1.0.2/changelog.md @@ -0,0 +1,24 @@ +## What's Changed +* Update GitHub funding username by @paulocastellano in https://github.com/trypostit/trypost/pull/67 +* Update star history section for dark and light themes by @paulocastellano in https://github.com/trypostit/trypost/pull/75 +* Fix workspace name-only update rejected by required brand fields by @paulocastellano in https://github.com/trypostit/trypost/pull/77 +* Fix calendar showing "no content" for every post by @paulocastellano in https://github.com/trypostit/trypost/pull/78 +* Stop persisting instagram_carousel as a content type by @paulocastellano in https://github.com/trypostit/trypost/pull/80 +* fix: Updates symfony packages from 8.0.x to 8.1.0 to address the following security advisories: by @sebastian-works in https://github.com/trypostit/trypost/pull/76 +* fix(tiktok): align UX strings and render processing notice for app review by @paulocastellano in https://github.com/trypostit/trypost/pull/71 +* Add Copilot code review instructions by @paulocastellano in https://github.com/trypostit/trypost/pull/85 +* Aspect ratio selection for Facebook posts (like Instagram) by @paulocastellano in https://github.com/trypostit/trypost/pull/82 +* Post platform meta (aspect ratio) parity across API and MCP by @paulocastellano in https://github.com/trypostit/trypost/pull/86 +* fix(linkedin): drop deprecated r_basicprofile from default scopes by @Falconiere in https://github.com/trypostit/trypost/pull/68 +* Rename post editor 'Schedule' tab to 'Channels' by @paulocastellano in https://github.com/trypostit/trypost/pull/87 +* Automations ⚙️ by @paulocastellano in https://github.com/trypostit/trypost/pull/88 +* Fix: resolve Bluesky @mention handles to DIDs (mentions caused "Invalid post data") by @Schrall in https://github.com/trypostit/trypost/pull/93 +* Harden BlueskyPublisher: link facets, conventions, NSID lexicon, tests by @paulocastellano in https://github.com/trypostit/trypost/pull/94 +* Add Telegram publishing channel by @paulocastellano in https://github.com/trypostit/trypost/pull/95 +* Past-due access, reliable checkout.completed, and complete morph map by @paulocastellano in https://github.com/trypostit/trypost/pull/96 + +## New Contributors +* @Falconiere made their first contribution in https://github.com/trypostit/trypost/pull/68 +* @Schrall made their first contribution in https://github.com/trypostit/trypost/pull/93 + +**Full Changelog**: https://github.com/trypostit/trypost/compare/v1.0.1...v1.0.2 diff --git a/releases/v1.0.2/email.md b/releases/v1.0.2/email.md new file mode 100644 index 00000000..220305d1 --- /dev/null +++ b/releases/v1.0.2/email.md @@ -0,0 +1,47 @@ +--- +subject: "Changelog v1.0.2 — We're on Discord, plus Automations & Telegram" +--- + +# Changelog v1.0.2 — We're on Discord, plus Automations & Telegram + +By TryPost Product Team • [Release v1.0.2](https://github.com/trypostit/trypost/releases/tag/v1.0.2) + +Hey — quick rundown of what shipped this week. But first, the thing I'm most excited about: + +## We have a Discord now + +We just opened the TryPost community on Discord. It's where we'll share what we're building, take your feature requests, and help each other get more out of the tool. I'll be in there myself, so come say hi and tell me what you want us to build next. + +**[Join us on Discord →](https://trypost.it/discord)** + +Or just head to [trypost.it/discord](https://trypost.it/discord) + +## Automations + +You can now hand the posting off to TryPost. Point an automation at a source like an RSS feed, and it'll draft a post in your brand voice and schedule it to your channels on whatever cadence you set. There's a visual editor where you wire up the trigger, conditions, and steps, and tabs to watch each run and see what it actually did. + +## Telegram + +Telegram is a channel now. Add our bot to your channel or group, drop in the one-time connect code, and you're publishing text and media albums. You'll also see per-post reactions and your subscriber count next to the rest of your analytics. + +## Sharper posts on every network + +Facebook posts let you pick an aspect ratio now, the same way Instagram does, so your images aren't cropped in ways you didn't choose. And on Bluesky, @mentions and links finally resolve properly — tagging someone no longer throws an "invalid post" error. + +## New features + +- Build automations that draft and schedule posts for you +- Connect Telegram channels and groups, with reactions and subscriber stats +- Pick the aspect ratio for Facebook image posts +- Mention people and add links on Bluesky + +## Fixes + +- The calendar no longer shows "no content" on posts that clearly have content +- Renaming a workspace doesn't make you re-fill brand details anymore +- If a payment fails, you keep your access and can just update your card instead of starting a new subscription +- The editor's "Schedule" tab is now called "Channels", which is what it always was +- A handful of TikTok and Instagram posting fixes + +Cheers, +Paulo from TryPost.it diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 8df2a0fb..5295d3db 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -2,6 +2,7 @@ import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-vue'; import { computed } from 'vue'; +import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue'; import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; @@ -168,6 +169,14 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :preview-only="previewOnly" @update:content-type="emit('update:contentType', channel.id, $event)" /> + diff --git a/resources/js/components/SearchableSelect.vue b/resources/js/components/SearchableSelect.vue new file mode 100644 index 00000000..fdad0bb2 --- /dev/null +++ b/resources/js/components/SearchableSelect.vue @@ -0,0 +1,83 @@ + + + diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index 0e93b7a1..fdee97f1 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -104,6 +104,11 @@ const platformTheme: Record< rotate: '-rotate-2', image: '/images/accounts/telegram.png', }, + discord: { + bg: 'bg-indigo-200', + rotate: 'rotate-1', + image: '/images/accounts/discord.png', + }, }; const themeFor = (value: string) => diff --git a/resources/js/components/automations/config/FetchRssNodeConfig.vue b/resources/js/components/automations/config/FetchRssNodeConfig.vue index e38e79b5..886ba05b 100644 --- a/resources/js/components/automations/config/FetchRssNodeConfig.vue +++ b/resources/js/components/automations/config/FetchRssNodeConfig.vue @@ -1,12 +1,23 @@ diff --git a/resources/js/components/posts/PostPlatformMetrics.vue b/resources/js/components/posts/PostPlatformMetrics.vue index bf4fb35c..385105d7 100644 --- a/resources/js/components/posts/PostPlatformMetrics.vue +++ b/resources/js/components/posts/PostPlatformMetrics.vue @@ -1,6 +1,6 @@ + +