From 246a159f34fb63a38c7d70c9a7be39766c0621f5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 10:56:08 -0300 Subject: [PATCH 01/19] feat(automations): multi-format RSS/Atom feeds with dynamic variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable cross-format aliases (title, link, date, content, author, …) plus every namespaced field flattened for use as {{ fetched.* }}. Add a feed-inspection endpoint that discovers a feed's real fields and feeds them into the editor's expression autocomplete. Allow {{ }} expressions in the feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser service; the fetch keeps the SSRF guard and gains XXE-safe parsing. --- .../Automation/Node/RunFetchRssNode.php | 45 +++-- .../Controllers/App/AutomationController.php | 43 +++++ .../App/Automations/InspectFeedRequest.php | 25 +++ .../Automations/UpdateAutomationRequest.php | 6 +- .../App/Automation/FeedInspectionResource.php | 69 +++++++ app/Rules/ResolvableUrl.php | 36 ++++ app/Services/Automation/FeedParser.php | 179 ++++++++++++++++++ composer.json | 1 + composer.lock | 83 +++++++- lang/en/automations.php | 14 +- lang/es/automations.php | 14 +- lang/pt-BR/automations.php | 14 +- .../automations/config/FetchRssNodeConfig.vue | 63 +++++- .../composables/useExpressionCompletions.ts | 22 ++- resources/js/pages/automations/Form.vue | 1 + routes/app.php | 1 + .../Feature/Automation/FeedInspectionTest.php | 138 ++++++++++++++ .../Automation/Node/FetchRssNodeTest.php | 28 ++- tests/Unit/Automation/FeedParserTest.php | 81 ++++++++ tests/fixtures/feeds/podcast.xml | 20 ++ tests/fixtures/feeds/rss_namespaces.xml | 20 ++ tests/fixtures/feeds/youtube_atom.xml | 36 ++++ 22 files changed, 904 insertions(+), 35 deletions(-) create mode 100644 app/Http/Requests/App/Automations/InspectFeedRequest.php create mode 100644 app/Http/Resources/App/Automation/FeedInspectionResource.php create mode 100644 app/Rules/ResolvableUrl.php create mode 100644 app/Services/Automation/FeedParser.php create mode 100644 tests/Feature/Automation/FeedInspectionTest.php create mode 100644 tests/Unit/Automation/FeedParserTest.php create mode 100644 tests/fixtures/feeds/podcast.xml create mode 100644 tests/fixtures/feeds/rss_namespaces.xml create mode 100644 tests/fixtures/feeds/youtube_atom.xml diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index cc0b02b3..a8394ee7 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 @@ -65,9 +66,9 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult 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 ?? [], [ @@ -107,46 +108,44 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult ]); } - 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/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index cfd6b898..b31c74c4 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,17 @@ 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 Illuminate\Support\Facades\Http; use Inertia\Inertia; use Inertia\Response; +use RuntimeException; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class AutomationController extends Controller { @@ -203,6 +211,41 @@ 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()], + ); + + try { + $safeHttp->guardAgainstSsrf($feedUrl); + } catch (RuntimeException) { + return response()->json(['message' => __('automations.errors.url_not_allowed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); + } + + $response = Http::get($feedUrl); + + if (! $response->successful()) { + 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/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..ed94fa4b 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,7 +130,10 @@ 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'], 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/Rules/ResolvableUrl.php b/app/Rules/ResolvableUrl.php new file mode 100644 index 00000000..af5db97d --- /dev/null +++ b/app/Rules/ResolvableUrl.php @@ -0,0 +1,36 @@ +translate(); + + return; + } + + $candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value); + + if (filter_var($candidate, FILTER_VALIDATE_URL) === false) { + $fail('validation.url')->translate(); + } + } +} diff --git a/app/Services/Automation/FeedParser.php b/app/Services/Automation/FeedParser.php new file mode 100644 index 00000000..52bc9ae6 --- /dev/null +++ b/app/Services/Automation/FeedParser.php @@ -0,0 +1,179 @@ + }}`. + * + * 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 valid feed. + */ + public function parse(string $body): ?array + { + $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()); + } + + /** + * @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/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 4f28b729..4b5bb064 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", @@ -7402,6 +7402,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/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/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/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/resources/js/components/automations/config/FetchRssNodeConfig.vue b/resources/js/components/automations/config/FetchRssNodeConfig.vue index e38e79b5..74249ae5 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/composables/useExpressionCompletions.ts b/resources/js/composables/useExpressionCompletions.ts index 88c5e28e..1fac1b8a 100644 --- a/resources/js/composables/useExpressionCompletions.ts +++ b/resources/js/composables/useExpressionCompletions.ts @@ -43,13 +43,29 @@ const providedBy = (node: GraphNode): ExpressionSuggestion[] => { } return [suggestion('trigger.event', 'trigger_event'), suggestion('trigger.fired_at', 'trigger_fired_at')]; } - case 'fetch_rss': - return [ + case 'fetch_rss': { + const aliases = [ suggestion('fetched.title', 'fetched_title'), suggestion('fetched.link', 'fetched_link'), + suggestion('fetched.date', 'fetched_date'), + suggestion('fetched.content', 'fetched_content'), suggestion('fetched.description', 'fetched_description'), - suggestion('fetched.pubDate', 'fetched_pubdate'), + suggestion('fetched.author', 'fetched_author'), + suggestion('fetched.image', 'fetched_image'), + suggestion('fetched.categories', 'fetched_categories'), + suggestion('fetched.enclosure', 'fetched_enclosure'), ]; + // Fields discovered by inspecting the real feed — the dynamic raw layer + // (yt_videoId, media_group.*, dc_creator, …) that aliases can't enumerate. + const discovered = Array.isArray(node.data?.discovered_fields) + ? (node.data.discovered_fields as Array<{ path: string; sample?: string }>).map( + (field): ExpressionSuggestion => ({ label: field.path, info: field.sample ?? '' }), + ) + : []; + const known = new Set(aliases.map((alias) => alias.label)); + + return [...aliases, ...discovered.filter((field) => !known.has(field.label))]; + } case 'http_request': return [suggestion('fetched', 'fetched_http')]; case 'generate': diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index 891ec93f..b3afe367 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -137,6 +137,7 @@ provide( 'automationExpressionCompletions', computed(() => buildExpressionCatalog(selectedNodeId.value, nodes.value, edges.value, variables.value)), ); +provide('automationId', props.automation.id); // Toggled by an expandable CodeEditor (see CodeEditor.vue) to slide out the // side-by-side editing panel, pushing the sidebar left to make room. diff --git a/routes/app.php b/routes/app.php index 403d9a60..39affce2 100644 --- a/routes/app.php +++ b/routes/app.php @@ -224,6 +224,7 @@ Route::post('automations/{automation}/pause', [AutomationController::class, 'pause'])->name('app.automations.pause'); Route::post('automations/{automation}/runs/{run}/retry', [AutomationController::class, 'retryRun'])->name('app.automations.runs.retry'); Route::post('automations/{automation}/test', [AutomationController::class, 'test'])->name('app.automations.test'); + Route::post('automations/{automation}/feed/inspect', [AutomationController::class, 'inspectFeed'])->name('app.automations.feed.inspect'); Route::get('automations/{automation}/runs/{run}', [AutomationController::class, 'showRun'])->name('app.automations.runs.show'); // API Keys diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php new file mode 100644 index 00000000..7d31baed --- /dev/null +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -0,0 +1,138 @@ +workspace = Workspace::factory()->create(); + $this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->refresh(); +}); + +it('returns the discovered fields for a feed', function () { + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->for($this->workspace)->create(); + + $response = $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]); + + $response->assertOk(); + + $paths = collect($response->json('fields'))->pluck('path'); + expect($paths)->toContain('fetched.title') + ->toContain('fetched.link') + ->toContain('fetched.yt_videoId') + ->toContain('fetched.media_group.media_thumbnail.url'); +}); + +it('resolves workspace variables in the feed url before fetching', function () { + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->for($this->workspace)->create([ + 'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']], + ]); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://{{ variables.HOST }}/feed.xml', + ]) + ->assertOk(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '1.1.1.1/feed.xml')); +}); + +it('returns 422 for a malformed feed', function () { + Http::fake(['1.1.1.1/*' => Http::response('not a feed', 200)]); + + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]) + ->assertStatus(422); +}); + +it('validates that feed_url is required', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), []) + ->assertStatus(422) + ->assertJsonValidationErrorFor('feed_url'); +}); + +it('persists discovered_fields stored on a fetch_rss node through an update', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'feed_url' => 'https://example.com/feed.xml', + 'discovered_fields' => [ + ['path' => 'fetched.yt_videoId', 'sample' => 'abc123'], + ], + ], + ]], + ]) + ->assertRedirect(); + + $node = collect($automation->fresh()->nodes)->firstWhere('id', 'fetch_1'); + expect(data_get($node, 'data.discovered_fields.0.path'))->toBe('fetched.yt_videoId'); +}); + +it('accepts a feed_url containing a workflow-variable expression', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'https://www.youtube.com/feeds/videos.xml?channel_id={{ variables.CHANNEL_ID }}'], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + +it('rejects a feed_url that is not a URL even after stripping expressions', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'not a url {{ variables.X }}'], + ]], + ]) + ->assertSessionHasErrors('nodes.0.data.feed_url'); +}); + +it('forbids inspecting a feed for an automation in another workspace', function () { + Http::fake(['1.1.1.1/*' => Http::response('', 200)]); + + $automation = Automation::factory()->for(Workspace::factory()->create())->create(); + + $this->actingAs($this->user) + ->postJson(route('app.automations.feed.inspect', $automation->id), [ + 'feed_url' => 'https://1.1.1.1/feed.xml', + ]) + ->assertForbidden(); +}); diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index 48ae91f2..f915098e 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -28,7 +28,7 @@ Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 - Newer2https://1.1.1.1/ccMon, 15 Jun 2025 12:00:00 +0000 + Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 XML; @@ -256,7 +256,31 @@ $state = AutomationNodeState::for($automation->id, 'fetch_1'); expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String()) - ->toBe(CarbonImmutable::parse('Mon, 15 Jun 2025 12:00:00 +0000')->toIso8601String()); + ->toBe(CarbonImmutable::parse('Sun, 15 Jun 2025 12:00:00 +0000')->toIso8601String()); +}); + +it('processes an Atom feed (YouTube) that the old RSS-only parser dropped', function () { + Carbon::setTestNow('2026-06-16 10:00:00'); + Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + + $automation = Automation::factory()->active()->create(); + + // Watermark predates the single entry, so it counts as new. + AutomationNodeState::create([ + 'automation_id' => $automation->id, + 'node_id' => 'fetch_1', + 'data' => ['last_item_date' => '2026-01-01T00:00:00+00:00'], + ]); + + $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']); + + $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']); + + expect($result->status)->toBe(NodeRunStatus::Completed); + expect($result->output['fetch']['count'])->toBe(1); + expect($result->output['fetched']['title'])->toBe('Ninguém aguenta o TikTok Shop'); + expect($result->output['fetched']['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE'); + expect($result->output['fetched']['yt_videoId'])->toBe('bIQVeW4sTcE'); }); it('fails when feed_url is missing', function () { diff --git a/tests/Unit/Automation/FeedParserTest.php b/tests/Unit/Automation/FeedParserTest.php new file mode 100644 index 00000000..d12e948d --- /dev/null +++ b/tests/Unit/Automation/FeedParserTest.php @@ -0,0 +1,81 @@ +parser = new FeedParser; +}); + +it('parses an Atom (YouTube) feed with aliases and raw namespaced fields', function () { + $item = $this->parser->parse(feedFixture('youtube_atom'))[0]; + + // Aliases resolve cross-format: link comes from the Atom href attribute, + // date from , author from . + expect($item['title'])->toBe('Ninguém aguenta o TikTok Shop'); + expect($item['link'])->toBe('https://www.youtube.com/watch?v=bIQVeW4sTcE'); + expect($item['date'])->toBe('2026-06-15T20:24:32+00:00'); + expect($item['author'])->toBe('Beta Boechat'); + expect($item['key'])->toBe('yt:video:bIQVeW4sTcE'); + + // Raw namespaced fields stay reachable. + expect($item['yt_videoId'])->toBe('bIQVeW4sTcE'); + expect(data_get($item, 'media_group.media_thumbnail.url')) + ->toBe('https://i3.ytimg.com/vi/bIQVeW4sTcE/hqdefault.jpg'); + expect(data_get($item, 'media_group.media_description')) + ->toBe('Descrição completa do vídeo aqui.'); +}); + +it('parses an RSS 2.0 feed with dc and content namespaces', function () { + $item = $this->parser->parse(feedFixture('rss_namespaces'))[0]; + + expect($item['title'])->toBe('Polish is here'); + expect($item['link'])->toBe('https://feedback.changelogfy.com/en/polish-is-here'); + expect($item['author'])->toBe('Paulo Castellano'); + expect($item['key'])->toBe('16092'); + + // Extension fields the old parser threw away. + expect($item['dc_creator'])->toBe('Paulo Castellano'); + expect($item['content_encoded'])->toBe('

You can now serve your changelogs fully translated in Polish.

'); + expect($item['categories'])->toBe(['Feature', 'i18n']); +}); + +it('parses a podcast feed exposing the enclosure and itunes fields', function () { + $item = $this->parser->parse(feedFixture('podcast'))[0]; + + expect(data_get($item, 'enclosure.url'))->toBe('https://cdn.changelog.com/the-changelog-682.mp3'); + expect(data_get($item, 'enclosure.type'))->toBe('audio/mpeg'); + expect($item['itunes_duration'])->toBe('1:46:28'); + expect($item['key'])->toBe('changelog.com/1/2829'); +}); + +it('preserves backward-compatible RSS 2.0 alias keys', function () { + $item = $this->parser->parse(feedFixture('rss_namespaces'))[0]; + + // Automations saved before multi-format support reference these exact keys. + expect($item)->toHaveKeys(['title', 'link', 'description', 'pubDate', 'key']); + expect($item['pubDate'])->toBe($item['date']); +}); + +it('keeps a falsy element from a tag that only carries attributes', function () { + $item = $this->parser->parse(feedFixture('podcast'))[0]; + + // has no text — the attribute object is exposed. + expect(data_get($item, 'itunes_image.href'))->toBe('https://cdn.changelog.com/cover.png'); +}); + +it('returns null for a body that is not a valid feed', function () { + expect($this->parser->parse('this is not xml at all'))->toBeNull(); +}); + +it('returns an empty list for a valid but empty feed', function () { + $empty = ''; + + expect($this->parser->parse($empty))->toBe([]); +}); diff --git a/tests/fixtures/feeds/podcast.xml b/tests/fixtures/feeds/podcast.xml new file mode 100644 index 00000000..fa8c2068 --- /dev/null +++ b/tests/fixtures/feeds/podcast.xml @@ -0,0 +1,20 @@ + + + + The Changelog + https://changelog.com/podcast + Software news + + From open source hits to OpenAI + changelog.com/1/2829 + https://changelog.com/podcast/682 + Fri, 05 Jun 2026 19:00:00 +0000 + + This week I am talking with Max Stoiber. + full + + 1:46:28 + false + + + diff --git a/tests/fixtures/feeds/rss_namespaces.xml b/tests/fixtures/feeds/rss_namespaces.xml new file mode 100644 index 00000000..6a2157ec --- /dev/null +++ b/tests/fixtures/feeds/rss_namespaces.xml @@ -0,0 +1,20 @@ + + + + Changelogfy + Changelogfy + https://feedback.changelogfy.com + + + <![CDATA[Polish is here]]> + + https://feedback.changelogfy.com/en/polish-is-here + 16092 + Paulo Castellano + Thu, 05 Mar 2026 16:20:09 +0000 + + + You can now serve your changelogs fully translated in Polish.

]]>
+
+
+
diff --git a/tests/fixtures/feeds/youtube_atom.xml b/tests/fixtures/feeds/youtube_atom.xml new file mode 100644 index 00000000..b3370058 --- /dev/null +++ b/tests/fixtures/feeds/youtube_atom.xml @@ -0,0 +1,36 @@ + + + + yt:channel:123 + 123 + Beta Boechat + + + Beta Boechat + https://www.youtube.com/channel/UC123 + + 2015-08-11T18:07:31+00:00 + + yt:video:bIQVeW4sTcE + bIQVeW4sTcE + 123 + Ninguém aguenta o TikTok Shop + + + Beta Boechat + https://www.youtube.com/channel/UC123 + + 2026-06-15T20:24:32+00:00 + 2026-06-15T21:15:11+00:00 + + Ninguém aguenta o TikTok Shop + + + Descrição completa do vídeo aqui. + + + + + + + From 3acaafb81e41d764bf30d734dabe354a90ba4dde Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:06:46 -0300 Subject: [PATCH 02/19] test(automations): extract inline feed XML into shared fixtures Move the RSS sample feeds out of inline heredocs/constants in FetchRssNodeTest into tests/fixtures/feeds/, matching the parser fixtures. Centralize the feedFixture() loader in tests/Pest.php and use it across the feed tests. --- .../Feature/Automation/FeedInspectionTest.php | 4 +- .../Automation/Node/FetchRssNodeTest.php | 57 +++---------------- tests/Pest.php | 5 ++ tests/Unit/Automation/FeedParserTest.php | 5 -- tests/fixtures/feeds/rss_dateless.xml | 5 ++ tests/fixtures/feeds/rss_mixed.xml | 6 ++ tests/fixtures/feeds/rss_no_guid.xml | 4 ++ tests/fixtures/feeds/rss_old.xml | 5 ++ tests/fixtures/feeds/rss_three_new.xml | 6 ++ 9 files changed, 42 insertions(+), 55 deletions(-) create mode 100644 tests/fixtures/feeds/rss_dateless.xml create mode 100644 tests/fixtures/feeds/rss_mixed.xml create mode 100644 tests/fixtures/feeds/rss_no_guid.xml create mode 100644 tests/fixtures/feeds/rss_old.xml create mode 100644 tests/fixtures/feeds/rss_three_new.xml diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php index 7d31baed..c5ab6d00 100644 --- a/tests/Feature/Automation/FeedInspectionTest.php +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -16,7 +16,7 @@ }); it('returns the discovered fields for a feed', function () { - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->for($this->workspace)->create(); @@ -35,7 +35,7 @@ }); it('resolves workspace variables in the feed url before fetching', function () { - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->for($this->workspace)->create([ 'variables' => [['key' => 'HOST', 'value' => '1.1.1.1']], diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index f915098e..dc83b8e3 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -15,37 +15,11 @@ afterEach(fn () => Carbon::setTestNow()); -const FETCH_RSS_OLD = <<<'XML' - - - Ancienthttps://1.1.1.1/11Mon, 01 Jan 2024 12:00:00 +0000 - Oldhttps://1.1.1.1/22Sat, 01 Feb 2025 12:00:00 +0000 - -XML; - -const FETCH_RSS_MIXED = <<<'XML' - - - Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 - Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 - Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 - -XML; - -const FETCH_RSS_THREE_NEW = <<<'XML' - - - New1https://1.1.1.1/xxSun, 01 Jun 2025 12:00:00 +0000 - New2https://1.1.1.1/yyMon, 02 Jun 2025 12:00:00 +0000 - New3https://1.1.1.1/zzTue, 03 Jun 2025 12:00:00 +0000 - -XML; - beforeEach(fn () => Bus::fake()); it('first execution dispatches nothing and stores watermark from newest item', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_OLD, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_old'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -74,7 +48,7 @@ it('processes the first new item on the current run and spawns siblings for the rest', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -111,7 +85,7 @@ it('spawns sibling runs down the item edge for each remaining new item', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -151,7 +125,7 @@ it('fans every spawned item out across all branches wired to the fetch node', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_three_new'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -185,7 +159,7 @@ it('does not persist the production watermark on a manual real-data test', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -226,7 +200,7 @@ it('advances the production watermark on a non-manual real-data run', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_mixed'), 200)]); $automation = Automation::factory()->active()->create([ 'nodes' => [ @@ -261,7 +235,7 @@ it('processes an Atom feed (YouTube) that the old RSS-only parser dropped', function () { Carbon::setTestNow('2026-06-16 10:00:00'); - Http::fake(['1.1.1.1/*' => Http::response(file_get_contents(base_path('tests/fixtures/feeds/youtube_atom.xml')), 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('youtube_atom'), 200)]); $automation = Automation::factory()->active()->create(); @@ -317,14 +291,7 @@ it('skips items without a publish date', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - $feed = <<<'XML' - - - Datedhttps://1.1.1.1/ddSun, 01 Jun 2025 12:00:00 +0000 - NoDatehttps://1.1.1.1/nn - - XML; - Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_dateless'), 200)]); $automation = Automation::factory()->active()->create(); AutomationNodeState::create([ @@ -343,13 +310,7 @@ it('falls back to the link as the dedup key when an item has no guid', function () { Carbon::setTestNow('2026-01-15 10:00:00'); - $feed = <<<'XML' - - - LinkKeyhttps://1.1.1.1/only-linkSun, 01 Jun 2025 12:00:00 +0000 - - XML; - Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]); + Http::fake(['1.1.1.1/*' => Http::response(feedFixture('rss_no_guid'), 200)]); $automation = Automation::factory()->active()->create(); AutomationNodeState::create([ diff --git a/tests/Pest.php b/tests/Pest.php index 373a3c94..523a1605 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -103,3 +103,8 @@ function createApiTestToken(array $overrides = []): array 'user' => $user, ]; } + +function feedFixture(string $name): string +{ + return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml")); +} diff --git a/tests/Unit/Automation/FeedParserTest.php b/tests/Unit/Automation/FeedParserTest.php index d12e948d..cda1a74b 100644 --- a/tests/Unit/Automation/FeedParserTest.php +++ b/tests/Unit/Automation/FeedParserTest.php @@ -4,11 +4,6 @@ use App\Services\Automation\FeedParser; -function feedFixture(string $name): string -{ - return file_get_contents(base_path("tests/fixtures/feeds/{$name}.xml")); -} - beforeEach(function () { $this->parser = new FeedParser; }); diff --git a/tests/fixtures/feeds/rss_dateless.xml b/tests/fixtures/feeds/rss_dateless.xml new file mode 100644 index 00000000..354740d3 --- /dev/null +++ b/tests/fixtures/feeds/rss_dateless.xml @@ -0,0 +1,5 @@ + + + Datedhttps://1.1.1.1/ddSun, 01 Jun 2025 12:00:00 +0000 + NoDatehttps://1.1.1.1/nn + diff --git a/tests/fixtures/feeds/rss_mixed.xml b/tests/fixtures/feeds/rss_mixed.xml new file mode 100644 index 00000000..e0ff0f35 --- /dev/null +++ b/tests/fixtures/feeds/rss_mixed.xml @@ -0,0 +1,6 @@ + + + Olderhttps://1.1.1.1/aaSat, 01 Feb 2025 12:00:00 +0000 + Newer1https://1.1.1.1/bbSun, 01 Jun 2025 12:00:00 +0000 + Newer2https://1.1.1.1/ccSun, 15 Jun 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_no_guid.xml b/tests/fixtures/feeds/rss_no_guid.xml new file mode 100644 index 00000000..50da87ed --- /dev/null +++ b/tests/fixtures/feeds/rss_no_guid.xml @@ -0,0 +1,4 @@ + + + LinkKeyhttps://1.1.1.1/only-linkSun, 01 Jun 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_old.xml b/tests/fixtures/feeds/rss_old.xml new file mode 100644 index 00000000..89742385 --- /dev/null +++ b/tests/fixtures/feeds/rss_old.xml @@ -0,0 +1,5 @@ + + + Ancienthttps://1.1.1.1/11Mon, 01 Jan 2024 12:00:00 +0000 + Oldhttps://1.1.1.1/22Sat, 01 Feb 2025 12:00:00 +0000 + diff --git a/tests/fixtures/feeds/rss_three_new.xml b/tests/fixtures/feeds/rss_three_new.xml new file mode 100644 index 00000000..6d44e06d --- /dev/null +++ b/tests/fixtures/feeds/rss_three_new.xml @@ -0,0 +1,6 @@ + + + New1https://1.1.1.1/xxSun, 01 Jun 2025 12:00:00 +0000 + New2https://1.1.1.1/yyMon, 02 Jun 2025 12:00:00 +0000 + New3https://1.1.1.1/zzTue, 03 Jun 2025 12:00:00 +0000 + From 4bb7d450a3d99e61e4ae098dae38df27194544f8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:09:25 -0300 Subject: [PATCH 03/19] chore(release): link GitHub release in changelog email + add v1.0.2 artifacts Tweak the /release command's email template (GitHub release link in the byline, "Paulo from TryPost.it" signature) and add the generated v1.0.2 release notes. --- .claude/commands/release.md | 18 ++++++++------ releases/v1.0.2/changelog.md | 24 ++++++++++++++++++ releases/v1.0.2/email.md | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 releases/v1.0.2/changelog.md create mode 100644 releases/v1.0.2/email.md 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/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 From 109ad7dfc041657451bed75e97e2e809a279916a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:26:05 -0300 Subject: [PATCH 04/19] fix(automations): preview shows the newest feed item, not the oldest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual/dry "test with real data" run surfaces a single item and never fans out, but it was taking the oldest new item (items are sorted oldest-first for production fan-out). Surface the newest item in preview instead — what the user expects to test against. Real runs are unchanged. Works for every feed format. --- .../Automation/Node/RunFetchRssNode.php | 18 ++++++++++++++---- .../Automation/Node/FetchRssNodeTest.php | 5 +++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index a8394ee7..3482d985 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -96,14 +96,24 @@ 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, ]); } diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php index dc83b8e3..69189545 100644 --- a/tests/Feature/Automation/Node/FetchRssNodeTest.php +++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php @@ -193,6 +193,11 @@ expect($result->status)->toBe(NodeRunStatus::Completed); expect($result->output['fetch']['count'])->toBeGreaterThan(0); + // ...surfaces the NEWEST item (key 'c', 15 Jun) — not the oldest — so the + // test reflects what the user just published, and spawns no siblings. + expect($result->output['fetched']['key'])->toBe('c'); + expect($result->output['fetch']['spawned'])->toBe(0); + // ...and never advances the persisted watermark. $state = AutomationNodeState::for($automation->id, 'fetch_1'); expect($state->data['last_item_date'])->toBe('2030-01-01T00:00:00+00:00'); From b2932262cf6561af8a3d5dadbf084af8630e9234 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 11:26:05 -0300 Subject: [PATCH 05/19] fix(automations): reliably open a node's config when switching nodes A click with a few px of jitter crossed Vue Flow's 1px drag threshold and was treated as a drag, so onNodeClick never fired and the config panel didn't switch between nodes. Raise nodeDragThreshold to 5 so normal clicks stay clicks, and also select the node on drag start as a safety net. --- resources/js/pages/automations/Form.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/js/pages/automations/Form.vue b/resources/js/pages/automations/Form.vue index b3afe367..6a2748f6 100644 --- a/resources/js/pages/automations/Form.vue +++ b/resources/js/pages/automations/Form.vue @@ -206,6 +206,11 @@ onConnect((connection: Connection) => { let dragStartPosition: XYPosition | null = null; onNodeDragStart(({ node }) => { dragStartPosition = { ...node.position }; + // A click that moves more than Vue Flow's drag threshold (1px) registers as a + // drag, not a click, so onNodeClick never fires. Select here too, otherwise + // switching between nodes intermittently fails to open the grabbed node's config. + selectedNodeId.value = node.id; + selectedEdgeId.value = null; }); onNodeDragStop(({ node }) => { if (!dragStartPosition) return; @@ -483,6 +488,7 @@ const defaultEdgeOptions = { :default-edge-options="defaultEdgeOptions" :connection-mode="ConnectionMode.Loose" :delete-key-code="null" + :node-drag-threshold="5" :snap-to-grid="true" :snap-grid="[16, 16]" fit-view-on-init From f55d35363f3c4f9fd7e5637f31e4c31651cc1d22 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 12:09:16 -0300 Subject: [PATCH 06/19] fix(automations): return null on any feed parse failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap FeedParser::parse in try/catch so an unexpected Throwable from third-party feed content (not just invalid XML) maps to the clean fetch_rss_malformed failure instead of bubbling up as an unhandled exception — matching the failed()-not-throw pattern of the sibling HTTP node. --- app/Services/Automation/FeedParser.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/app/Services/Automation/FeedParser.php b/app/Services/Automation/FeedParser.php index 52bc9ae6..340c13ee 100644 --- a/app/Services/Automation/FeedParser.php +++ b/app/Services/Automation/FeedParser.php @@ -6,6 +6,7 @@ use SimplePie\Item; use SimplePie\SimplePie; +use Throwable; /** * Parses an RSS 2.0 / Atom 1.0 / RDF feed body into normalized item arrays. @@ -52,19 +53,24 @@ class FeedParser ]; /** - * @return list>|null Null when the body is not a valid feed. + * @return list>|null Null when the body is not a usable feed + * (invalid XML, or any parse failure). */ public function parse(string $body): ?array { - $feed = new SimplePie; - $feed->enable_cache(false); - $feed->set_raw_data($body); + try { + $feed = new SimplePie; + $feed->enable_cache(false); + $feed->set_raw_data($body); - if (! @$feed->init()) { + if (! @$feed->init()) { + return null; + } + + return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); + } catch (Throwable) { return null; } - - return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items()); } /** From 45da024fc682850bd5b20257e91399d8d644d75f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 12:09:16 -0300 Subject: [PATCH 07/19] fix(automations): code-review hardening for feed fetch & URL validation - inspectFeed fetches via SafeHttpFetcher::get (SSRF guard + timeout + redirect cap + UA) instead of a raw, timeout-less Http::get; add a timeout to the node fetch. - ResolvableUrl now requires an http(s) scheme, so it's no weaker than the plain url rule it replaced (rejects file://, javascript://). - Apply ResolvableUrl to the HTTP Request and Webhook node URLs too, so templated {{ }} URLs validate consistently across every URL field. - Clear a fetch_rss node's discovered_fields when its feed_url changes, so stale fields from the previous feed stop showing up as autocomplete suggestions. --- .../Automation/Node/RunFetchRssNode.php | 2 +- .../Controllers/App/AutomationController.php | 12 ++--- .../Automations/UpdateAutomationRequest.php | 4 +- app/Rules/ResolvableUrl.php | 6 ++- .../automations/config/FetchRssNodeConfig.vue | 9 ++++ .../Feature/Automation/FeedInspectionTest.php | 54 +++++++++++++++++++ 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index 3482d985..6bf2a388 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -60,7 +60,7 @@ 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()]); diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php index b31c74c4..e691eb8c 100644 --- a/app/Http/Controllers/App/AutomationController.php +++ b/app/Http/Controllers/App/AutomationController.php @@ -39,7 +39,6 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Response as HttpResponse; -use Illuminate\Support\Facades\Http; use Inertia\Inertia; use Inertia\Response; use RuntimeException; @@ -225,15 +224,12 @@ public function inspectFeed( ['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 { - $safeHttp->guardAgainstSsrf($feedUrl); + $response = $safeHttp->get($feedUrl); } catch (RuntimeException) { - return response()->json(['message' => __('automations.errors.url_not_allowed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); - } - - $response = Http::get($feedUrl); - - if (! $response->successful()) { return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY); } diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index ed94fa4b..0b0a3677 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -136,7 +136,7 @@ private function dataRulesForNodeType(?string $type, int $i): array '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'], @@ -171,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/Rules/ResolvableUrl.php b/app/Rules/ResolvableUrl.php index af5db97d..45f899bf 100644 --- a/app/Rules/ResolvableUrl.php +++ b/app/Rules/ResolvableUrl.php @@ -28,8 +28,12 @@ public function validate(string $attribute, mixed $value, Closure $fail): void } $candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value); + $scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME)); - if (filter_var($candidate, FILTER_VALIDATE_URL) === false) { + // 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/resources/js/components/automations/config/FetchRssNodeConfig.vue b/resources/js/components/automations/config/FetchRssNodeConfig.vue index 74249ae5..886ba05b 100644 --- a/resources/js/components/automations/config/FetchRssNodeConfig.vue +++ b/resources/js/components/automations/config/FetchRssNodeConfig.vue @@ -35,6 +35,15 @@ const local = ref({ watch(local, (val) => emit('update', val), { deep: true }); +// Changing the feed invalidates the discovered fields — they belong to the old +// feed and would otherwise keep being offered as stale autocomplete suggestions. +watch( + () => local.value.feed_url, + () => { + local.value.discovered_fields = []; + }, +); + const inspectHttp = useHttp<{ feed_url: string }, { fields: DiscoveredField[] }>({ feed_url: '' }); const isInspecting = ref(false); const inspectError = ref(null); diff --git a/tests/Feature/Automation/FeedInspectionTest.php b/tests/Feature/Automation/FeedInspectionTest.php index c5ab6d00..1889529a 100644 --- a/tests/Feature/Automation/FeedInspectionTest.php +++ b/tests/Feature/Automation/FeedInspectionTest.php @@ -125,6 +125,60 @@ ->assertSessionHasErrors('nodes.0.data.feed_url'); }); +it('rejects a non-http(s) scheme in feed_url', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'fetch_1', + 'type' => 'fetch_rss', + 'position' => ['x' => 0, 'y' => 0], + 'data' => ['feed_url' => 'file:///etc/passwd'], + ]], + ]) + ->assertSessionHasErrors('nodes.0.data.feed_url'); +}); + +it('accepts a templated url on an http_request node', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'http_1', + 'type' => 'http_request', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'url' => 'https://api.example.com/{{ variables.PATH }}/feed', + 'method' => 'GET', + 'auth_type' => 'none', + ], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + +it('accepts a templated url on a webhook node', function () { + $automation = Automation::factory()->for($this->workspace)->create(); + + $this->actingAs($this->user) + ->put(route('app.automations.update', $automation->id), [ + 'nodes' => [[ + 'id' => 'webhook_1', + 'type' => 'webhook', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'url' => 'https://hooks.example.com/{{ variables.TOKEN }}', + 'method' => 'POST', + ], + ]], + ]) + ->assertRedirect() + ->assertSessionHasNoErrors(); +}); + it('forbids inspecting a feed for an automation in another workspace', function () { Http::fake(['1.1.1.1/*' => Http::response('', 200)]); From c2dd4515b2406f5927ade915af97d53cd9a7f928 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 16 Jun 2026 14:44:00 -0300 Subject: [PATCH 08/19] feat(channels): add Discord as a social channel Connect a Discord server via OAuth (bot authorization) and schedule/publish messages to its channels, with mentions and rich embeds. - Connect: custom Socialite Discord provider (bot scope) maps the authorized guild to a SocialAccount; throws if no server was authorized. - Publish: DiscordPublisher posts via the global bot token, validates the chosen channel belongs to the connected guild (anti cross-guild), optimizes media, builds allowed_mentions only from explicit mention chips (no accidental pings), and renders rich embeds. - Compose: per-post channel picker (live lookup), mention autocomplete and an embed editor, gated by a required-channel compliance rule; Discord post preview. - Enum/config/content-type wiring, ConnectionVerifier health check, throttled lookup endpoints, i18n (en/es/pt-BR), and tests. Operators must create a Discord application and set DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT. --- app/Enums/PostPlatform/ContentType.php | 9 + app/Enums/SocialAccount/Platform.php | 10 + .../Social/DiscordPublishException.php | 89 +++++ .../Controllers/App/DiscordController.php | 59 ++++ .../Controllers/Auth/DiscordController.php | 38 +++ .../Discord/IndexDiscordMentionRequest.php | 25 ++ .../Requests/App/Post/UpdatePostRequest.php | 18 + app/Jobs/PublishToSocialPlatform.php | 4 +- app/Providers/AppServiceProvider.php | 7 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Social/ConnectionVerifier.php | 8 + app/Services/Social/Discord/DiscordClient.php | 122 +++++++ .../Social/Discord/DiscordPublisher.php | 299 +++++++++++++++++ app/Socialite/DiscordProvider.php | 94 ++++++ config/services.php | 7 + config/trypost.php | 14 + database/factories/SocialAccountFactory.php | 13 + lang/en/accounts.php | 1 + lang/en/posts.php | 23 ++ lang/es/accounts.php | 1 + lang/es/posts.php | 23 ++ lang/pt-BR/accounts.php | 1 + lang/pt-BR/posts.php | 23 ++ .../js/components/ChannelConfigurator.vue | 9 + .../components/accounts/AddSocialDialog.vue | 5 + .../posts/editor/DiscordSettings.vue | 313 ++++++++++++++++++ .../posts/previews/DiscordPreview.vue | 110 ++++++ .../posts/previews/PlatformPreview.vue | 3 + resources/js/composables/usePlatformLogo.ts | 3 + resources/js/composables/usePostCompliance.ts | 4 + resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + routes/app.php | 14 + tests/Feature/Discord/DiscordLookupTest.php | 85 +++++ .../Social/ConnectionVerifierTest.php | 28 ++ .../Services/Social/DiscordPublisherTest.php | 152 +++++++++ .../Feature/Social/DiscordControllerTest.php | 80 +++++ tests/Feature/UpdatePostRequestTest.php | 44 +++ 38 files changed, 1745 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/Social/DiscordPublishException.php create mode 100644 app/Http/Controllers/App/DiscordController.php create mode 100644 app/Http/Controllers/Auth/DiscordController.php create mode 100644 app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php create mode 100644 app/Services/Social/Discord/DiscordClient.php create mode 100644 app/Services/Social/Discord/DiscordPublisher.php create mode 100644 app/Socialite/DiscordProvider.php create mode 100644 resources/js/components/posts/editor/DiscordSettings.vue create mode 100644 resources/js/components/posts/previews/DiscordPreview.vue create mode 100644 tests/Feature/Discord/DiscordLookupTest.php create mode 100644 tests/Feature/Services/Social/DiscordPublisherTest.php create mode 100644 tests/Feature/Social/DiscordControllerTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 4c3214ff..0c525df9 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -53,6 +53,9 @@ enum ContentType: string // Telegram case TelegramPost = 'telegram_post'; + // Discord + case DiscordMessage = 'discord_message'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -81,6 +84,7 @@ public function label(): string self::BlueskyPost => 'Post', self::MastodonPost => 'Post', self::TelegramPost => 'Post', + self::DiscordMessage => 'Message', }; } @@ -104,6 +108,7 @@ public function platform(): SocialPlatform self::BlueskyPost => SocialPlatform::Bluesky, self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, + self::DiscordMessage => SocialPlatform::Discord, }; } @@ -173,6 +178,7 @@ public function maxMediaCount(): int self::BlueskyPost => 4, self::MastodonPost => 4, self::TelegramPost => 10, + self::DiscordMessage => 10, }; } @@ -193,6 +199,7 @@ public function supportsVideo(): bool self::BlueskyPost => true, self::MastodonPost => true, self::TelegramPost => true, + self::DiscordMessage => true, }; } @@ -232,6 +239,7 @@ public function requiresMedia(): bool self::TelegramPost => false, self::FacebookPost => false, self::InstagramFeed => false, + self::DiscordMessage => false, default => true, }; } @@ -313,6 +321,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Bluesky => self::BlueskyPost, SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, + SocialPlatform::Discord => self::DiscordMessage, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 25337147..0d645c97 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -21,6 +21,7 @@ enum Platform: string case Bluesky = 'bluesky'; case Mastodon = 'mastodon'; case Telegram = 'telegram'; + case Discord = 'discord'; public function label(): string { @@ -38,6 +39,7 @@ public function label(): string self::Bluesky => 'Bluesky', self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', + self::Discord => 'Discord', }; } @@ -56,6 +58,7 @@ public function color(): string self::Bluesky => '#0085FF', self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', + self::Discord => '#5865F2', }; } @@ -73,6 +76,7 @@ public function allowedMediaTypes(): array self::Bluesky => [MediaType::Image, MediaType::Video], self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], + self::Discord => [MediaType::Image, MediaType::Video], }; } @@ -90,6 +94,7 @@ public function maxImages(): int self::Bluesky => 4, self::Mastodon => 4, self::Telegram => 10, + self::Discord => 10, }; } @@ -129,6 +134,7 @@ public function maxContentLength(): int self::Bluesky => 300, self::Mastodon => 500, self::Telegram => 4096, + self::Discord => 2000, }; } @@ -172,6 +178,8 @@ public function recommendedAiContentLength(): int self::YouTube => 80, // Telegram channel posts — short announcements read best self::Telegram => 400, + // Discord — conversational community posts read best when concise + self::Discord => 280, }; } @@ -194,6 +202,7 @@ public function requiredPublishScopes(): array self::Bluesky => [], self::Mastodon => ['write:statuses'], self::Telegram => [], + self::Discord => [], }; } @@ -211,6 +220,7 @@ public function supportsTextOnly(): bool self::Bluesky => true, self::Mastodon => true, self::Telegram => true, + self::Discord => true, }; } diff --git a/app/Exceptions/Social/DiscordPublishException.php b/app/Exceptions/Social/DiscordPublishException.php new file mode 100644 index 00000000..3aaa40cf --- /dev/null +++ b/app/Exceptions/Social/DiscordPublishException.php @@ -0,0 +1,89 @@ +status(); + $rawResponse = $response->body(); + $code = (int) data_get($response->json(), 'code'); + $message = (string) data_get($response->json(), 'message', 'An unknown Discord error occurred.'); + + // Missing access / missing permissions: the bot can't see or post in the channel. + if (in_array($code, [50001, 50013], true) || $status === 403) { + return new static( + userMessage: "The bot can't post in this channel. Make sure it has access and permission to send messages there.", + category: ErrorCategory::Permission, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // Unknown channel — it was deleted or the id is stale. + if ($code === 10003) { + return new static( + userMessage: 'That Discord channel no longer exists. Pick a different channel.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // Attachment too large. + if ($code === 40005 || $status === 413) { + return new static( + userMessage: "A media file exceeds Discord's upload size limit.", + category: ErrorCategory::MediaFormat, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + // 401: the configured bot token is invalid (operator-level misconfiguration). + if ($status === 401) { + return new static( + userMessage: 'Discord rejected the bot token. Check the DISCORD_BOT_TOKEN configuration.', + category: ErrorCategory::Permission, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status === 429) { + return new static( + userMessage: 'Discord rate limit reached. Please try again shortly.', + category: ErrorCategory::RateLimit, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + if ($status >= 500) { + return new static( + userMessage: 'Discord is temporarily unavailable. Please try again later.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + return new static( + userMessage: $message, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $code, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'discord'; + } +} diff --git a/app/Http/Controllers/App/DiscordController.php b/app/Http/Controllers/App/DiscordController.php new file mode 100644 index 00000000..83d230e4 --- /dev/null +++ b/app/Http/Controllers/App/DiscordController.php @@ -0,0 +1,59 @@ +authorizeDiscordAccount($request, $account); + + // Degrade to an empty list on a transient Discord outage — the picker + // shows "no channels" rather than erroring; the publish path still + // retries (channels() throws there). + return response()->json([ + 'channels' => rescue( + fn () => $this->discord->channels((string) $account->platform_user_id), + [], + report: false, + ), + ]); + } + + public function mentions(IndexDiscordMentionRequest $request, SocialAccount $account): JsonResponse + { + $this->authorizeDiscordAccount($request, $account); + + return response()->json([ + 'mentions' => $this->discord->mentions( + (string) $account->platform_user_id, + (string) $request->validated('q', ''), + ), + ]); + } + + private function authorizeDiscordAccount(Request $request, SocialAccount $account): void + { + $workspace = $request->user()->currentWorkspace; + + abort_unless( + $workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Discord, + Response::HTTP_FORBIDDEN, + ); + + $this->authorize('view', $workspace); + } +} diff --git a/app/Http/Controllers/Auth/DiscordController.php b/app/Http/Controllers/Auth/DiscordController.php new file mode 100644 index 00000000..87ff8c25 --- /dev/null +++ b/app/Http/Controllers/Auth/DiscordController.php @@ -0,0 +1,38 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.discord.scopes')); + } + + public function callback(Request $request): View + { + return $this->handleCallback($request, $this->platform, $this->driver); + } +} diff --git a/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php new file mode 100644 index 00000000..25967af3 --- /dev/null +++ b/app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'q' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 47941085..1f70fcd8 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -82,6 +82,16 @@ public function rules(): array 'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'], 'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'], 'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.mentions.*.token' => ['required', 'string'], + 'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'], + 'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'], + 'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'], + 'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'], + 'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'], + 'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; @@ -119,6 +129,14 @@ public function withValidator(Validator $validator): void trans('posts.form.pinterest.board_required'), ); } + + if ($platformEnum === Platform::Discord + && blank(data_get($platform, 'meta.channel_id'))) { + $validator->errors()->add( + "platforms.{$i}.meta.channel_id", + trans('posts.form.discord.channel_required'), + ); + } } }); } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index e4d494d1..5bf96054 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -19,6 +19,7 @@ use App\Models\PostPlatform; use App\Services\Social\BlueskyPublisher; use App\Services\Social\ConnectionVerifier; +use App\Services\Social\Discord\DiscordPublisher; use App\Services\Social\FacebookPublisher; use App\Services\Social\InstagramPublisher; use App\Services\Social\LinkedInPagePublisher; @@ -222,7 +223,7 @@ private function broadcastStatus(): void PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -237,6 +238,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Bluesky => app(BlueskyPublisher::class), SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), + SocialPlatform::Discord => app(DiscordPublisher::class), }; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8d5ed7d9..1ea3193d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -31,6 +31,7 @@ use App\Models\WorkspaceSignature; use App\Services\PostHogService; use App\Services\PostTemplate\Registry as PostTemplateRegistry; +use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; use Carbon\CarbonImmutable; @@ -186,6 +187,12 @@ protected function configureSocialite(): void return Socialite::buildProvider(InstagramProvider::class, $config); }); + Socialite::extend('discord', function ($app) { + $config = $app['config']['services.discord']; + + return Socialite::buildProvider(DiscordProvider::class, $config); + }); + Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class); diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index c17e9469..65de20e3 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -202,6 +202,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Discord => [ + 'max_width' => 2048, + 'max_size' => 8 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/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/DiscordClient.php b/app/Services/Social/Discord/DiscordClient.php new file mode 100644 index 00000000..70e7b9b0 --- /dev/null +++ b/app/Services/Social/Discord/DiscordClient.php @@ -0,0 +1,122 @@ + + * + * @throws PlatformUnavailableException when the lookup fails transiently, so + * callers (e.g. the publish-time channel guard) retry instead of + * treating an empty result as "channel not found". + */ + public function channels(string $guildId): array + { + $response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels"); + + if ($response->failed()) { + throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status()); + } + + $channels = $response->json(); + + if (! is_array($channels)) { + return []; + } + + return collect($channels) + ->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true)) + ->map(fn ($channel) => [ + 'id' => (string) data_get($channel, 'id'), + 'name' => (string) data_get($channel, 'name'), + ]) + ->values() + ->all(); + } + + /** + * Mentionable targets matching a query: @everyone/@here, roles, then members. + * + * @return list + */ + 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}"); + } + + 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..14e8f1eb --- /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 selected channel is not part of this Discord server.', + category: ErrorCategory::Permission, + ); + } + } + + /** + * @param array $payload + */ + private function send(string $channelId, array $payload): string + { + return $this->messageId($this->bot()->post($this->endpoint($channelId), $payload)); + } + + /** + * Downloads each media item, optimizes images for Discord, and uploads them + * as multipart attachments alongside the JSON payload. + * + * @param array $payload + * @param Collection $media + */ + private function sendWithMedia(string $channelId, array $payload, Collection $media): string + { + $request = $this->bot(); + $attachments = []; + $tempFiles = []; + + try { + foreach ($media->values() as $index => $item) { + $tempFile = $this->downloadMedia($item); + $tempFiles[] = $tempFile; + + $filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}"); + $request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename); + $attachments[] = ['id' => $index, 'filename' => $filename]; + } + + $payload['attachments'] = $attachments; + + return $this->messageId($request->post($this->endpoint($channelId), [ + 'payload_json' => json_encode($payload), + ])); + } finally { + foreach ($tempFiles as $tempFile) { + @unlink($tempFile); + } + } + } + + private function downloadMedia(MediaItem $item): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'discord_media_'); + + $response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->url); + + if ($response->failed() || (filesize($tempFile) ?: 0) === 0) { + @unlink($tempFile); + + throw new DiscordPublishException( + userMessage: 'Failed to download a media file for this Discord post.', + category: ErrorCategory::MediaFormat, + ); + } + + // Images are downsized to Discord's limit; videos/gifs are sent as-is. + if ($item->mime_type && str_starts_with($item->mime_type, 'image/') && ! str_contains($item->mime_type, 'gif')) { + try { + return app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Discord); + } catch (Throwable) { + throw new DiscordPublishException( + userMessage: 'Failed to process a media image for this Discord post.', + category: ErrorCategory::MediaFormat, + ); + } finally { + @unlink($tempFile); + } + } + + return $tempFile; + } + + /** + * Appends the post's Discord mention tokens to the content. Mentions live in + * meta — not the shared content — so they only ping on Discord and don't leak + * literal `<@id>` markup to other platforms. + */ + private function appendMentions(string $content, PostPlatform $postPlatform): string + { + $tokens = $this->mentionTokens($postPlatform)->implode(' '); + + if ($tokens === '') { + return $content; + } + + return $content === '' ? $tokens : "{$content}\n\n{$tokens}"; + } + + /** + * Builds an explicit allowed_mentions object from the post's mention chips so + * ONLY those targets ping. Defaults to `parse: []` (suppress everything), so a + * literal "@everyone" typed into the shared content never pings by accident. + * + * @return array + */ + private function allowedMentions(PostPlatform $postPlatform): array + { + $parse = []; + $users = []; + $roles = []; + + foreach ($this->mentionTokens($postPlatform) as $token) { + if ($token === '@everyone' || $token === '@here') { + $parse[] = 'everyone'; + } elseif (preg_match('/^<@&(\d+)>$/', $token, $matches)) { + $roles[] = $matches[1]; + } elseif (preg_match('/^<@(\d+)>$/', $token, $matches)) { + $users[] = $matches[1]; + } + } + + $allowed = ['parse' => array_values(array_unique($parse))]; + + if ($users !== []) { + $allowed['users'] = array_values(array_unique($users)); + } + + if ($roles !== []) { + $allowed['roles'] = array_values(array_unique($roles)); + } + + return $allowed; + } + + /** + * @return Collection + */ + private function mentionTokens(PostPlatform $postPlatform): Collection + { + return collect((array) data_get($postPlatform->meta, 'mentions', [])) + ->map(fn ($mention) => trim((string) data_get($mention, 'token'))) + ->filter() + ->values(); + } + + /** + * @return list> + */ + private function buildEmbeds(PostPlatform $postPlatform): array + { + return collect((array) data_get($postPlatform->meta, 'embeds', [])) + ->map(fn ($embed) => array_filter([ + 'title' => data_get($embed, 'title'), + 'description' => data_get($embed, 'description'), + 'url' => data_get($embed, 'url'), + 'color' => $this->color(data_get($embed, 'color')), + 'image' => ($image = data_get($embed, 'image')) ? ['url' => $image] : null, + ], fn ($value) => $value !== null && $value !== '')) + ->filter(fn ($embed) => $embed !== []) + ->values() + ->all(); + } + + private function color(mixed $color): ?int + { + if (! is_string($color) || $color === '') { + return null; + } + + $hex = ltrim($color, '#'); + + if (! ctype_xdigit($hex) || strlen($hex) !== 6) { + return null; + } + + return min((int) hexdec($hex), self::MAX_EMBED_COLOR); + } + + private function endpoint(string $channelId): string + { + return config('trypost.platforms.discord.api')."/channels/{$channelId}/messages"; + } + + private function bot(): PendingRequest + { + return $this->socialHttp() + ->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot'); + } + + private function messageId(Response $response): string + { + if ($response->failed()) { + throw DiscordPublishException::fromApiResponse($response); + } + + $id = (string) data_get($response->json(), 'id'); + + if ($id === '') { + throw new DiscordPublishException( + userMessage: 'Discord returned an unexpected response with no message id.', + category: ErrorCategory::Unknown, + ); + } + + return $id; + } +} diff --git a/app/Socialite/DiscordProvider.php b/app/Socialite/DiscordProvider.php new file mode 100644 index 00000000..41d2bfd1 --- /dev/null +++ b/app/Socialite/DiscordProvider.php @@ -0,0 +1,94 @@ + + */ + private array $tokenResponse = []; + + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state); + } + + /** + * @return array + */ + protected function getCodeFields($state = null): array + { + return array_merge(parent::getCodeFields($state), [ + 'permissions' => (string) config('trypost.platforms.discord.permissions'), + 'integration_type' => 0, + ]); + } + + protected function getTokenUrl(): string + { + return config('trypost.platforms.discord.oauth_api').'/token'; + } + + /** + * @return array + */ + public function getAccessTokenResponse($code): array + { + return $this->tokenResponse = parent::getAccessTokenResponse($code); + } + + /** + * The bot-authorization token response includes the guild the bot joined. + * + * @return array + */ + protected function getUserByToken($token): array + { + return (array) data_get($this->tokenResponse, 'guild', []); + } + + /** + * @param array $user The guild object. + */ + protected function mapUserToObject(array $user): User + { + $guildId = data_get($user, 'id'); + + // No guild means the user authorized without adding the bot to a server + // (or cancelled). Fail loudly so the callback shows an error instead of + // persisting a broken account with a null guild id. + if (blank($guildId)) { + throw new RuntimeException('Discord authorization did not include a server.'); + } + + $icon = data_get($user, 'icon'); + + return (new User)->setRaw($user)->map([ + 'id' => $guildId, + 'nickname' => data_get($user, 'name'), + 'name' => data_get($user, 'name'), + 'avatar' => $guildId && $icon ? "https://cdn.discordapp.com/icons/{$guildId}/{$icon}.png" : null, + ]); + } +} diff --git a/config/services.php b/config/services.php index b6c194cc..e72632d6 100644 --- a/config/services.php +++ b/config/services.php @@ -111,6 +111,13 @@ 'redirect' => env('PINTEREST_CLIENT_REDIRECT'), ], + // Discord + 'discord' => [ + 'client_id' => env('DISCORD_CLIENT_ID'), + 'client_secret' => env('DISCORD_CLIENT_SECRET'), + 'redirect' => env('DISCORD_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index e69f375a..16bac3f5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -162,6 +162,20 @@ // Secret-token header Telegram echoes on every webhook call. 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'), ], + 'discord' => [ + 'enabled' => env('DISCORD_ENABLED', true), + // Single shared bot application. OAuth (bot scope) authorizes adding the + // bot to the user's server; channel listing, mentions and posting all + // use this bot token, not the user's OAuth token. + 'bot_token' => env('DISCORD_BOT_TOKEN'), + 'api' => env('DISCORD_API', 'https://discord.com/api/v10'), + 'oauth_api' => env('DISCORD_OAUTH_API', 'https://discord.com/api/oauth2'), + // Permission bitfield requested for the bot: VIEW_CHANNEL (1<<10) + + // SEND_MESSAGES (1<<11) + EMBED_LINKS (1<<14) + ATTACH_FILES (1<<15) + + // READ_MESSAGE_HISTORY (1<<16) + MENTION_EVERYONE (1<<17) = 248832. + 'permissions' => env('DISCORD_PERMISSIONS', '248832'), + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), + ], ], ]; diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index e18e27fc..927aba12 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -154,6 +154,19 @@ public function telegram(): static ]); } + public function discord(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Discord, + 'scopes' => Platform::Discord->requiredPublishScopes(), + 'token_expires_at' => null, // posting uses the global bot token + 'access_token' => '', + 'refresh_token' => '', + 'platform_user_id' => '999000111', // guild id + 'display_name' => 'My Server', + ]); + } + public function disconnected(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 71a3369f..52871353 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -52,6 +52,7 @@ 'bluesky' => 'Connect your Bluesky account', 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', + 'discord' => 'Connect a Discord server', ], 'disconnect_modal' => [ diff --git a/lang/en/posts.php b/lang/en/posts.php index b38b845d..93781fce 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -161,6 +161,24 @@ 'no_board_found' => 'No board matches your search.', 'board_required' => 'Select a Pinterest board to publish this post.', ], + 'discord' => [ + 'settings' => 'Discord Settings', + 'posting_to' => 'Posting to', + 'channel' => 'Channel', + 'select_channel' => 'Select a channel', + 'loading_channels' => 'Loading channels…', + 'channel_required' => 'Select a Discord channel to publish this post.', + 'mentions' => 'Mentions', + 'search_mention' => 'Mention a role or member…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Add embed', + 'embed_title' => 'Embed title', + 'embed_description' => 'Embed description', + 'embed_url' => 'Embed URL', + 'embed_image' => 'Image URL', + 'embed_color' => 'Color', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -488,6 +506,10 @@ 'label' => 'Post', 'description' => 'Text post with optional media', ], + 'discord_message' => [ + 'label' => 'Message', + 'description' => 'Message to a Discord channel with optional media & embeds', + ], ], 'platforms' => [ @@ -591,6 +613,7 @@ 'threads_post' => 'Threads Post', 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', + 'discord_message' => 'Discord Message', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', 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/posts.php b/lang/es/posts.php index 220880f2..1537a9c0 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -161,6 +161,24 @@ 'no_board_found' => 'Ningún tablero coincide con tu búsqueda.', 'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.', ], + 'discord' => [ + 'settings' => 'Configuración de Discord', + 'posting_to' => 'Publicando en', + 'channel' => 'Canal', + 'select_channel' => 'Selecciona un canal', + 'loading_channels' => 'Cargando canales…', + 'channel_required' => 'Selecciona un canal de Discord para publicar este post.', + 'mentions' => 'Menciones', + 'search_mention' => 'Menciona un rol o miembro…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Añadir embed', + 'embed_title' => 'Título del embed', + 'embed_description' => 'Descripción del embed', + 'embed_url' => 'URL del embed', + 'embed_image' => 'URL de la imagen', + 'embed_color' => 'Color', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -488,6 +506,10 @@ 'label' => 'Post', 'description' => 'Post de texto con multimedia opcional', ], + 'discord_message' => [ + 'label' => 'Mensaje', + 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', + ], ], 'platforms' => [ @@ -592,6 +614,7 @@ 'threads_post' => 'Post en Threads', 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', + 'discord_message' => 'Mensaje de Discord', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', 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/posts.php b/lang/pt-BR/posts.php index e3311e02..01f95f25 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -161,6 +161,24 @@ 'no_board_found' => 'Nenhum quadro encontrado.', 'board_required' => 'Selecione um quadro do Pinterest para publicar este post.', ], + 'discord' => [ + 'settings' => 'Configurações do Discord', + 'posting_to' => 'Publicando em', + 'channel' => 'Canal', + 'select_channel' => 'Selecione um canal', + 'loading_channels' => 'Carregando canais…', + 'channel_required' => 'Selecione um canal do Discord para publicar este post.', + 'mentions' => 'Menções', + 'search_mention' => 'Mencione um cargo ou membro…', + 'embeds' => 'Embeds', + 'embed' => 'Embed', + 'add_embed' => 'Adicionar embed', + 'embed_title' => 'Título do embed', + 'embed_description' => 'Descrição do embed', + 'embed_url' => 'URL do embed', + 'embed_image' => 'URL da imagem', + 'embed_color' => 'Cor', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -488,6 +506,10 @@ 'label' => 'Post', 'description' => 'Post de texto com mídia opcional', ], + 'discord_message' => [ + 'label' => 'Mensagem', + 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', + ], ], 'platforms' => [ @@ -591,6 +613,7 @@ 'threads_post' => 'Post no Threads', 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', + 'discord_message' => 'Mensagem do Discord', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', 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/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index 0e93b7a1..fdee97f1 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -104,6 +104,11 @@ const platformTheme: Record< rotate: '-rotate-2', image: '/images/accounts/telegram.png', }, + discord: { + bg: 'bg-indigo-200', + rotate: 'rotate-1', + image: '/images/accounts/discord.png', + }, }; const themeFor = (value: string) => diff --git a/resources/js/components/posts/editor/DiscordSettings.vue b/resources/js/components/posts/editor/DiscordSettings.vue new file mode 100644 index 00000000..45141a0d --- /dev/null +++ b/resources/js/components/posts/editor/DiscordSettings.vue @@ -0,0 +1,313 @@ + + +