Merge branch 'main' into main
This commit is contained in:
commit
5ccd00ffa2
88 changed files with 3976 additions and 154 deletions
|
|
@ -89,12 +89,12 @@ #### Structure
|
|||
|
||||
```markdown
|
||||
---
|
||||
subject: "Changelog: TryPost <version> — <theme 1>, <theme 2>, <theme 3>..."
|
||||
subject: "Changelog <version> — <theme 1>, <theme 2>, <theme 3>..."
|
||||
---
|
||||
|
||||
# Changelog: TryPost <version> — <theme 1>, <theme 2>, <theme 3>...
|
||||
# Changelog <version> — <theme 1>, <theme 2>, <theme 3>...
|
||||
|
||||
By Paulo Castellano • Release <version>
|
||||
By TryPost Product Team • [Release <version>](https://github.com/<OWNER>/<REPO>/releases/tag/<version>)
|
||||
|
||||
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 <version>` a link to `https://github.com/<OWNER>/<REPO>/releases/tag/<version>` (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 <version> — <theme 1>, <theme 2>, <theme 3>...`
|
||||
Pattern: `Changelog <version> — <theme 1>, <theme 2>, <theme 3>...`
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,13 @@ TELEGRAM_BOT_TOKEN=
|
|||
TELEGRAM_BOT_USERNAME=
|
||||
TELEGRAM_WEBHOOK_SECRET=
|
||||
|
||||
# Discord (single shared app+bot — create one via https://discord.com/developers/applications)
|
||||
# OAuth2 → add the redirect below; Bot → enable "Server Members Intent" for mention search.
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback"
|
||||
|
||||
# AI Services
|
||||
OPENAI_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
|
|
|
|||
|
|
@ -277,6 +277,14 @@ ## Backend Validation
|
|||
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
|
||||
- Naming: `<Verb><Resource>Request.php` (e.g. `StorePostRequest`, `ApplyPostTemplateRequest`, `IndexPostTemplateRequest`).
|
||||
|
||||
## Per-Platform Post Meta (`PostPlatform.meta`)
|
||||
|
||||
- All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`.
|
||||
- Every post create/update entry point — web (`App\Http\Requests\App\Post\UpdatePostRequest`), public API (`App\Http\Requests\Api\Post\{Store,Update}PostRequest`), and MCP (`App\Mcp\Tools\Post\{Create,Update}PostTool`) — spreads `...PostPlatformMetaRules::rules()`. NEVER add a per-platform meta rule inline to a single request/tool.
|
||||
- Why: `FormRequest::validated()` (and MCP `$request->validate()`) STRIPS any key without a rule. A meta field defined in only one entry point is silently dropped everywhere else — which is exactly how Discord/Pinterest/TikTok meta was lost via API/MCP before this was centralized.
|
||||
- Required-on-publish (meta a platform needs to publish, e.g. Discord `channel_id`) also lives there: `addRequiredOnPublishErrors()` for request-driven flows (web/API update `withValidator`), `assertStoredPostPublishable()` for flows that publish stored state without resubmitting platforms (MCP `PublishPostTool`). Add new required-meta rules to `requiredMetaViolation()`, not inline.
|
||||
- When adding a new platform's meta field, add it (and any publish requirement) to `PostPlatformMetaRules` ONLY, and cover it in `tests/Feature/Api/PostApiPlatformMetaTest.php` + `tests/Feature/Mcp/PostPlatformMetaToolTest.php`.
|
||||
|
||||
## Pest / Feature Tests
|
||||
|
||||
- ALWAYS use named routes via the `route()` helper in feature tests. NEVER hardcode URL strings like `'/posts/ai/create'`.
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
use App\Models\AutomationNodeState;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Automation\FeedParser;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use SimpleXMLElement;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
|
|
@ -40,6 +40,7 @@ public function __construct(
|
|||
private ExpressionResolver $resolver,
|
||||
private SafeHttpFetcher $safeHttp,
|
||||
private AdvanceAutomationRun $advance,
|
||||
private FeedParser $parser,
|
||||
) {}
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
|
|
@ -59,15 +60,15 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
]);
|
||||
}
|
||||
|
||||
$response = Http::get($feedUrl);
|
||||
$response = Http::timeout(10)->get($feedUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_request_failed'), ['status' => $response->status()]);
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = new SimpleXMLElement($response->body());
|
||||
} catch (Throwable) {
|
||||
$items = $this->parser->parse($response->body());
|
||||
|
||||
if ($items === null) {
|
||||
return NodeRunResult::failed(__('automations.errors.fetch_rss_malformed'));
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
? CarbonImmutable::createFromTimestamp(0)
|
||||
: $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
||||
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($items, $watermark);
|
||||
|
||||
if ($state !== null && $newestSeen !== null) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
|
|
@ -95,58 +96,66 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: self::NO_ITEMS_HANDLE);
|
||||
}
|
||||
|
||||
$first = array_shift($newItems);
|
||||
$total = count($newItems);
|
||||
|
||||
if (! $isPreview) {
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
// A preview (manual/dry test) surfaces ONE item and never fans out, so it
|
||||
// shows the newest item — what the user expects to test against. A real run
|
||||
// takes the oldest new item and fans the rest out as siblings, preserving
|
||||
// feed chronology across branches (items are sorted oldest-first).
|
||||
if ($isPreview) {
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => $total, 'spawned' => 0],
|
||||
'fetched' => end($newItems),
|
||||
]);
|
||||
}
|
||||
|
||||
$first = array_shift($newItems);
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)],
|
||||
'fetch' => ['count' => $total, 'spawned' => count($newItems)],
|
||||
'fetched' => $first,
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array
|
||||
/**
|
||||
* @param list<array<string, mixed>> $parsed Normalized items from FeedParser.
|
||||
* @return array{0: list<array<string, mixed>>, 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);
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ enum ContentType: string
|
|||
// Telegram
|
||||
case TelegramPost = 'telegram_post';
|
||||
|
||||
// Discord
|
||||
case DiscordMessage = 'discord_message';
|
||||
|
||||
/**
|
||||
* AI generation format for an Instagram carousel. Not a content type —
|
||||
* carousel posts are persisted as InstagramFeed.
|
||||
|
|
@ -81,6 +84,7 @@ public function label(): string
|
|||
self::BlueskyPost => 'Post',
|
||||
self::MastodonPost => 'Post',
|
||||
self::TelegramPost => 'Post',
|
||||
self::DiscordMessage => 'Message',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +108,7 @@ public function platform(): SocialPlatform
|
|||
self::BlueskyPost => SocialPlatform::Bluesky,
|
||||
self::MastodonPost => SocialPlatform::Mastodon,
|
||||
self::TelegramPost => SocialPlatform::Telegram,
|
||||
self::DiscordMessage => SocialPlatform::Discord,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +178,7 @@ public function maxMediaCount(): int
|
|||
self::BlueskyPost => 4,
|
||||
self::MastodonPost => 4,
|
||||
self::TelegramPost => 10,
|
||||
self::DiscordMessage => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +199,7 @@ public function supportsVideo(): bool
|
|||
self::BlueskyPost => true,
|
||||
self::MastodonPost => true,
|
||||
self::TelegramPost => true,
|
||||
self::DiscordMessage => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -232,6 +239,7 @@ public function requiresMedia(): bool
|
|||
self::TelegramPost => false,
|
||||
self::FacebookPost => false,
|
||||
self::InstagramFeed => false,
|
||||
self::DiscordMessage => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
|
@ -313,6 +321,7 @@ public static function defaultFor(SocialPlatform $platform): self
|
|||
SocialPlatform::Bluesky => self::BlueskyPost,
|
||||
SocialPlatform::Mastodon => self::MastodonPost,
|
||||
SocialPlatform::Telegram => self::TelegramPost,
|
||||
SocialPlatform::Discord => self::DiscordMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ enum Platform: string
|
|||
case Bluesky = 'bluesky';
|
||||
case Mastodon = 'mastodon';
|
||||
case Telegram = 'telegram';
|
||||
case Discord = 'discord';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -38,6 +39,7 @@ public function label(): string
|
|||
self::Bluesky => 'Bluesky',
|
||||
self::Mastodon => 'Mastodon',
|
||||
self::Telegram => 'Telegram',
|
||||
self::Discord => 'Discord',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -56,6 +58,7 @@ public function color(): string
|
|||
self::Bluesky => '#0085FF',
|
||||
self::Mastodon => '#6364FF',
|
||||
self::Telegram => '#26A5E4',
|
||||
self::Discord => '#5865F2',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +76,7 @@ public function allowedMediaTypes(): array
|
|||
self::Bluesky => [MediaType::Image, MediaType::Video],
|
||||
self::Mastodon => [MediaType::Image, MediaType::Video],
|
||||
self::Telegram => [MediaType::Image, MediaType::Video],
|
||||
self::Discord => [MediaType::Image, MediaType::Video],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -90,6 +94,7 @@ public function maxImages(): int
|
|||
self::Bluesky => 4,
|
||||
self::Mastodon => 4,
|
||||
self::Telegram => 10,
|
||||
self::Discord => 10,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -129,6 +134,7 @@ public function maxContentLength(): int
|
|||
self::Bluesky => 300,
|
||||
self::Mastodon => 500,
|
||||
self::Telegram => 4096,
|
||||
self::Discord => 2000,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +178,8 @@ public function recommendedAiContentLength(): int
|
|||
self::YouTube => 80,
|
||||
// Telegram channel posts — short announcements read best
|
||||
self::Telegram => 400,
|
||||
// Discord — conversational community posts read best when concise
|
||||
self::Discord => 280,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +202,7 @@ public function requiredPublishScopes(): array
|
|||
self::Bluesky => [],
|
||||
self::Mastodon => ['write:statuses'],
|
||||
self::Telegram => [],
|
||||
self::Discord => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -211,6 +220,7 @@ public function supportsTextOnly(): bool
|
|||
self::Bluesky => true,
|
||||
self::Mastodon => true,
|
||||
self::Telegram => true,
|
||||
self::Discord => true,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
89
app/Exceptions/Social/DiscordPublishException.php
Normal file
89
app/Exceptions/Social/DiscordPublishException.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class DiscordPublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$status = $response->status();
|
||||
$rawResponse = $response->body();
|
||||
$code = (int) data_get($response->json(), 'code');
|
||||
$message = (string) data_get($response->json(), 'message', 'An unknown Discord error occurred.');
|
||||
|
||||
// Missing access / missing permissions: the bot can't see or post in the channel.
|
||||
if (in_array($code, [50001, 50013], true) || $status === 403) {
|
||||
return new static(
|
||||
userMessage: "The bot can't post in this channel. Make sure it has access and permission to send messages there.",
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $code,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown channel — it was deleted or the id is stale.
|
||||
if ($code === 10003) {
|
||||
return new static(
|
||||
userMessage: 'That Discord channel no longer exists. Pick a different channel.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $code,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
// Attachment too large.
|
||||
if ($code === 40005 || $status === 413) {
|
||||
return new static(
|
||||
userMessage: "A media file exceeds Discord's upload size limit.",
|
||||
category: ErrorCategory::MediaFormat,
|
||||
platformErrorCode: (string) $code,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
// 401: the configured bot token is invalid (operator-level misconfiguration).
|
||||
if ($status === 401) {
|
||||
return new static(
|
||||
userMessage: 'Discord rejected the bot token. Check the DISCORD_BOT_TOKEN configuration.',
|
||||
category: ErrorCategory::Permission,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
return new static(
|
||||
userMessage: 'Discord rate limit reached. Please try again shortly.',
|
||||
category: ErrorCategory::RateLimit,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
if ($status >= 500) {
|
||||
return new static(
|
||||
userMessage: 'Discord is temporarily unavailable. Please try again later.',
|
||||
category: ErrorCategory::ServerError,
|
||||
platformErrorCode: (string) $status,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: ErrorCategory::Unknown,
|
||||
platformErrorCode: (string) $code,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'discord';
|
||||
}
|
||||
}
|
||||
|
|
@ -17,11 +17,13 @@
|
|||
use App\Actions\Automation\Run\TestAutomation;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Automations\ActivateAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\InspectFeedRequest;
|
||||
use App\Http\Requests\App\Automations\PauseAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\RetryRunRequest;
|
||||
use App\Http\Requests\App\Automations\StoreAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\TestAutomationRequest;
|
||||
use App\Http\Requests\App\Automations\UpdateAutomationRequest;
|
||||
use App\Http\Resources\App\Automation\FeedInspectionResource;
|
||||
use App\Http\Resources\App\PlatformConfigResource;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Http\Resources\AutomationInvocationResource;
|
||||
|
|
@ -31,11 +33,16 @@
|
|||
use App\Models\Automation;
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Automation\FeedParser;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response as HttpResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class AutomationController extends Controller
|
||||
{
|
||||
|
|
@ -203,6 +210,38 @@ public function test(TestAutomationRequest $request, Automation $automation, Tes
|
|||
return response()->json(['run_id' => $run->id]);
|
||||
}
|
||||
|
||||
public function inspectFeed(
|
||||
InspectFeedRequest $request,
|
||||
Automation $automation,
|
||||
ExpressionResolver $resolver,
|
||||
SafeHttpFetcher $safeHttp,
|
||||
FeedParser $parser,
|
||||
): JsonResponse|FeedInspectionResource {
|
||||
$this->authorize('update', $automation);
|
||||
|
||||
$feedUrl = $resolver->resolve(
|
||||
(string) $request->validated('feed_url'),
|
||||
['variables' => $automation->resolvedVariables()],
|
||||
);
|
||||
|
||||
// SafeHttpFetcher::get() bundles the SSRF guard, a request timeout, a
|
||||
// redirect cap and a branded user-agent — so a slow or hostile feed can't
|
||||
// hang this synchronous request. It throws on SSRF, timeout or non-2xx.
|
||||
try {
|
||||
$response = $safeHttp->get($feedUrl);
|
||||
} catch (RuntimeException) {
|
||||
return response()->json(['message' => __('automations.errors.fetch_rss_request_failed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
$items = $parser->parse($response->body());
|
||||
|
||||
if ($items === null) {
|
||||
return response()->json(['message' => __('automations.errors.fetch_rss_malformed')], SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
return new FeedInspectionResource($items[0] ?? []);
|
||||
}
|
||||
|
||||
public function showRun(Automation $automation, AutomationRun $run): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $automation);
|
||||
|
|
|
|||
59
app/Http/Controllers/App/DiscordController.php
Normal file
59
app/Http/Controllers/App/DiscordController.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\App;
|
||||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\App\Discord\IndexDiscordMentionRequest;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DiscordController extends Controller
|
||||
{
|
||||
public function __construct(private DiscordClient $discord) {}
|
||||
|
||||
public function channels(Request $request, SocialAccount $account): JsonResponse
|
||||
{
|
||||
$this->authorizeDiscordAccount($request, $account);
|
||||
|
||||
// Degrade to an empty list on a transient Discord outage — the picker
|
||||
// shows "no channels" rather than erroring; the publish path still
|
||||
// retries (channels() throws there).
|
||||
return response()->json([
|
||||
'channels' => rescue(
|
||||
fn () => $this->discord->channels((string) $account->platform_user_id),
|
||||
[],
|
||||
report: false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function mentions(IndexDiscordMentionRequest $request, SocialAccount $account): JsonResponse
|
||||
{
|
||||
$this->authorizeDiscordAccount($request, $account);
|
||||
|
||||
return response()->json([
|
||||
'mentions' => $this->discord->mentions(
|
||||
(string) $account->platform_user_id,
|
||||
(string) $request->validated('q', ''),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizeDiscordAccount(Request $request, SocialAccount $account): void
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
abort_unless(
|
||||
$workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Discord,
|
||||
Response::HTTP_FORBIDDEN,
|
||||
);
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Auth/DiscordController.php
Normal file
38
app/Http/Controllers/Auth/DiscordController.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class DiscordController extends SocialController
|
||||
{
|
||||
protected string $driver = 'discord';
|
||||
|
||||
protected SocialPlatform $platform = SocialPlatform::Discord;
|
||||
|
||||
public function connect(Request $request): Response|RedirectResponse
|
||||
{
|
||||
$this->ensurePlatformEnabled();
|
||||
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
if (! $workspace) {
|
||||
return redirect()->route('app.workspaces.create');
|
||||
}
|
||||
|
||||
$this->authorize('manageAccounts', $workspace);
|
||||
|
||||
return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.discord.scopes'));
|
||||
}
|
||||
|
||||
public function callback(Request $request): View
|
||||
{
|
||||
return $this->handleCallback($request, $this->platform, $this->driver);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,12 +4,12 @@
|
|||
|
||||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeMatchesPlatform;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -50,8 +50,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
new ContentTypeMatchesPlatform,
|
||||
],
|
||||
'platforms.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
'label_ids.*' => [
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@
|
|||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeMatchesPostPlatform;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -51,8 +52,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
new ContentTypeMatchesPostPlatform,
|
||||
],
|
||||
'platforms.*.meta' => ['nullable', 'array'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
'scheduled_at' => [
|
||||
'nullable',
|
||||
'date',
|
||||
|
|
@ -66,6 +66,23 @@ public function rules(): array
|
|||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$platformsById = $this->resolveSelectedPlatforms();
|
||||
|
||||
PostPlatformMetaRules::addRequiredOnPublishErrors(
|
||||
$validator,
|
||||
$this->input('platforms', []),
|
||||
fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int|string, Platform>
|
||||
*/
|
||||
|
|
|
|||
25
app/Http/Requests/App/Automations/InspectFeedRequest.php
Normal file
25
app/Http/Requests/App/Automations/InspectFeedRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Automations;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class InspectFeedRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'feed_url' => ['required', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
use App\Enums\Automation\Publish\Mode as PublishMode;
|
||||
use App\Enums\Automation\ScheduleField;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\Rules\ResolvableUrl;
|
||||
use App\Services\Automation\AutomationConfigValidator;
|
||||
use App\Services\Automation\GenerateNodeValidator;
|
||||
use Illuminate\Contracts\Validation\Validator;
|
||||
|
|
@ -129,10 +130,13 @@ private function dataRulesForNodeType(?string $type, int $i): array
|
|||
'schedule_timezone' => ['sometimes', 'string', 'timezone'],
|
||||
],
|
||||
NodeType::FetchRss->value => [
|
||||
'feed_url' => ['required', 'url'],
|
||||
'feed_url' => ['required', new ResolvableUrl],
|
||||
'discovered_fields' => ['sometimes', 'array'],
|
||||
'discovered_fields.*.path' => ['required', 'string'],
|
||||
'discovered_fields.*.sample' => ['nullable', 'string'],
|
||||
],
|
||||
NodeType::HttpRequest->value => [
|
||||
'url' => ['required', 'url'],
|
||||
'url' => ['required', new ResolvableUrl],
|
||||
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
|
||||
'auth_type' => ['required', Rule::in(array_column(AuthType::cases(), 'value'))],
|
||||
'auth_token' => ['nullable', 'string'],
|
||||
|
|
@ -167,7 +171,7 @@ private function dataRulesForNodeType(?string $type, int $i): array
|
|||
'scheduled_offset' => ['required_if:nodes.'.$i.'.data.mode,'.PublishMode::Scheduled->value, 'integer', 'min:0'],
|
||||
],
|
||||
NodeType::Webhook->value => [
|
||||
'url' => ['required', 'url'],
|
||||
'url' => ['required', new ResolvableUrl],
|
||||
'method' => ['required', Rule::in(array_column(HttpMethod::cases(), 'value'))],
|
||||
'payload_template' => ['nullable', 'string'],
|
||||
'headers' => ['nullable', 'array'],
|
||||
|
|
|
|||
25
app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php
Normal file
25
app/Http/Requests/App/Discord/IndexDiscordMentionRequest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Discord;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexDiscordMentionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'q' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeCompatibleWithMedia;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -70,18 +70,7 @@ public function rules(): array
|
|||
Rule::in(array_column(ContentType::cases(), 'value')),
|
||||
Rule::when($enforcesMediaCompatibility, [new ContentTypeCompatibleWithMedia]),
|
||||
],
|
||||
'platforms.*.meta' => ['nullable', 'array'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'])],
|
||||
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.disclose' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
'label_ids' => ['sometimes', 'array'],
|
||||
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
|
||||
];
|
||||
|
|
@ -102,24 +91,11 @@ public function withValidator(Validator $validator): void
|
|||
->whereIn('id', $ids)
|
||||
->pluck('platform', 'id');
|
||||
|
||||
foreach ($platforms as $i => $platform) {
|
||||
$platformEnum = $platformsById[data_get($platform, 'id')] ?? null;
|
||||
if ($platformEnum === Platform::TikTok
|
||||
&& blank(data_get($platform, 'meta.privacy_level'))) {
|
||||
$validator->errors()->add(
|
||||
"platforms.{$i}.meta.privacy_level",
|
||||
trans('posts.form.tiktok.privacy_required'),
|
||||
);
|
||||
}
|
||||
|
||||
if ($platformEnum === Platform::Pinterest
|
||||
&& blank(data_get($platform, 'meta.board_id'))) {
|
||||
$validator->errors()->add(
|
||||
"platforms.{$i}.meta.board_id",
|
||||
trans('posts.form.pinterest.board_required'),
|
||||
);
|
||||
}
|
||||
}
|
||||
PostPlatformMetaRules::addRequiredOnPublishErrors(
|
||||
$validator,
|
||||
$platforms,
|
||||
fn ($platform) => $platformsById[data_get($platform, 'id')] ?? null,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
69
app/Http/Resources/App/Automation/FeedInspectionResource.php
Normal file
69
app/Http/Resources/App/Automation/FeedInspectionResource.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App\Automation;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Flattens the first parsed feed item into a catalog of `{{ fetched.* }}` paths
|
||||
* with sample values, so the editor can offer them as expression completions.
|
||||
*
|
||||
* @property array<string, mixed> $resource The first normalized feed item.
|
||||
*/
|
||||
class FeedInspectionResource extends JsonResource
|
||||
{
|
||||
private const SAMPLE_MAX_LENGTH = 120;
|
||||
|
||||
/**
|
||||
* @return array{fields: list<array{path: string, sample: string}>}
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'fields' => $this->flatten((array) $this->resource),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return list<array{path: string, sample: string}>
|
||||
*/
|
||||
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<int|string, mixed> $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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
namespace App\Mcp\Tools\Post;
|
||||
|
||||
use App\Actions\Post\CreatePost;
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Rules\ContentTypeMatchesPlatform;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Mcp\Request;
|
||||
|
|
@ -38,8 +38,7 @@ public function handle(Request $request): ResponseFactory
|
|||
->where('is_active', true),
|
||||
],
|
||||
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform],
|
||||
'platforms.*.meta' => ['sometimes', 'array'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
]);
|
||||
|
||||
$post = CreatePost::execute($workspace, $request->user(), $validated);
|
||||
|
|
@ -61,7 +60,7 @@ public function schema(JsonSchema $schema): array
|
|||
->items($schema->object(fn ($p) => [
|
||||
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
|
||||
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
|
||||
'meta' => $p->object()->description('Per-platform metadata (e.g. aspect_ratio: 1:1|4:5|16:9|original for Instagram/Facebook feed images).'),
|
||||
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
|
||||
]))
|
||||
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
use App\Enums\Post\Status;
|
||||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
|
|
@ -41,6 +42,8 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.');
|
||||
}
|
||||
|
||||
PostPlatformMetaRules::assertStoredPostPublishable($post);
|
||||
|
||||
$scheduledAt = data_get($validated, 'scheduled_at');
|
||||
|
||||
$result = UpdatePost::execute($workspace, $post, [
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@
|
|||
use App\Actions\Post\UpdatePost;
|
||||
use App\Enums\Post\Action as PostAction;
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Http\Resources\Api\PostResource;
|
||||
use App\Models\Post;
|
||||
use App\Rules\ContentTypeMatchesPostPlatform;
|
||||
use App\Support\PostPlatformMetaRules;
|
||||
use App\Support\PostStatusRules;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -49,8 +49,7 @@ public function handle(Request $request): Response|ResponseFactory
|
|||
Rule::exists('post_platforms', 'id')->where('post_id', $post->id),
|
||||
],
|
||||
'platforms.*.content_type' => ['sometimes', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPostPlatform],
|
||||
'platforms.*.meta' => ['sometimes', 'array'],
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
...PostPlatformMetaRules::rules(),
|
||||
]);
|
||||
|
||||
$payload = collect($validated)->except('post_id')->all();
|
||||
|
|
@ -84,7 +83,7 @@ public function schema(JsonSchema $schema): array
|
|||
->items($schema->object(fn ($p) => [
|
||||
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
|
||||
'content_type' => $p->string()->description('New content_type for this platform.'),
|
||||
'meta' => $p->object()->description('Per-platform metadata override (e.g. aspect_ratio, board_id for Pinterest).'),
|
||||
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'),
|
||||
]))
|
||||
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
40
app/Rules/ResolvableUrl.php
Normal file
40
app/Rules/ResolvableUrl.php
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* Validates a URL that may contain `{{ ... }}` workflow-variable expressions.
|
||||
* The expressions are resolved at runtime, so they're replaced with a placeholder
|
||||
* before checking the result is a valid URL — letting users template the host or
|
||||
* query (e.g. `https://host/feed.xml?channel_id={{ variables.CHANNEL_ID }}`).
|
||||
*/
|
||||
class ResolvableUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
$fail('validation.url')->translate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$candidate = preg_replace('/\{\{\s*[\w.]+\s*\}\}/', 'placeholder', $value);
|
||||
$scheme = strtolower((string) parse_url($candidate, PHP_URL_SCHEME));
|
||||
|
||||
// Require a real http(s) URL once expressions are substituted, so the rule
|
||||
// is no weaker than the plain `url` rule it replaces (rejects file://,
|
||||
// javascript://, etc.) while still allowing templated hosts/queries.
|
||||
if (filter_var($candidate, FILTER_VALIDATE_URL) === false || ! in_array($scheme, ['http', 'https'], true)) {
|
||||
$fail('validation.url')->translate();
|
||||
}
|
||||
}
|
||||
}
|
||||
185
app/Services/Automation/FeedParser.php
Normal file
185
app/Services/Automation/FeedParser.php
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Automation;
|
||||
|
||||
use SimplePie\Item;
|
||||
use SimplePie\SimplePie;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Parses an RSS 2.0 / Atom 1.0 / RDF feed body into normalized item arrays.
|
||||
*
|
||||
* Each item carries two merged layers:
|
||||
* - **Aliases**: stable cross-format keys (title, link, date, content, author, …)
|
||||
* resolved through SimplePie's normalized getters, so a downstream node works the
|
||||
* same whether the feed is RSS or Atom.
|
||||
* - **Raw**: every namespaced extension tag (yt:videoId, media:thumbnail,
|
||||
* dc:creator, content:encoded, …) flattened with a `prefix_localname` convention,
|
||||
* so feed-specific data stays reachable via `{{ fetched.<field> }}`.
|
||||
*
|
||||
* 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<array<string, mixed>>|null Null when the body is not a usable feed
|
||||
* (invalid XML, or any parse failure).
|
||||
*/
|
||||
public function parse(string $body): ?array
|
||||
{
|
||||
try {
|
||||
$feed = new SimplePie;
|
||||
$feed->enable_cache(false);
|
||||
$feed->set_raw_data($body);
|
||||
|
||||
if (! @$feed->init()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_map(fn (Item $item): array => $this->normalize($item), $feed->get_items());
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
private function rawFields(Item $item): array
|
||||
{
|
||||
return $this->flattenChildren((array) data_get($item->data, 'child', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $children Namespace-URI keyed tag map.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $node
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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] ?? '';
|
||||
}
|
||||
}
|
||||
|
|
@ -202,6 +202,12 @@ private function getImageConfig(Platform $platform): array
|
|||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
Platform::Discord => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 8 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 100,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Services\Social\BlueskyAnalytics;
|
||||
use App\Services\Social\Discord\DiscordAnalytics;
|
||||
use App\Services\Social\FacebookAnalytics;
|
||||
use App\Services\Social\InstagramAnalytics;
|
||||
use App\Services\Social\LinkedInPageAnalytics;
|
||||
|
|
@ -66,6 +67,7 @@ public function forPlatform(PostPlatform $postPlatform): array
|
|||
Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
Platform::Threads => app(ThreadsAnalytics::class)->fetchPostMetrics($postPlatform),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordClient;
|
||||
use App\Services\Social\Telegram\TelegramApi;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
|
@ -68,6 +69,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool
|
|||
Platform::Bluesky => $this->verifyBluesky($account),
|
||||
Platform::Mastodon => $this->verifyMastodon($account),
|
||||
Platform::Telegram => $this->verifyTelegram($account),
|
||||
Platform::Discord => $this->verifyDiscord($account),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -516,6 +518,12 @@ private function verifyTelegram(SocialAccount $account): bool
|
|||
return $response->successful() && data_get($response->json(), 'ok') === true;
|
||||
}
|
||||
|
||||
private function verifyDiscord(SocialAccount $account): bool
|
||||
{
|
||||
// The guild endpoint succeeds only while the bot is still a member.
|
||||
return app(DiscordClient::class)->getGuild((string) $account->platform_user_id)->successful();
|
||||
}
|
||||
|
||||
private function verifyMastodon(SocialAccount $account): bool
|
||||
{
|
||||
$instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance');
|
||||
|
|
|
|||
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal file
107
app/Services/Social/Discord/DiscordAnalytics.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Discord;
|
||||
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Discord engagement metrics. Discord exposes no impressions/reach/views for
|
||||
* bot messages, so the signals are the server member count (account-level) and,
|
||||
* per post, reaction counts and thread replies — read live with the bot token.
|
||||
*/
|
||||
class DiscordAnalytics
|
||||
{
|
||||
public function __construct(private DiscordClient $client) {}
|
||||
|
||||
/**
|
||||
* Account-level metric: the server's approximate member count.
|
||||
*
|
||||
* @return array<int, array{label: string, value: int}>
|
||||
*/
|
||||
public function getMetrics(SocialAccount $account): array
|
||||
{
|
||||
$guildId = (string) $account->platform_user_id;
|
||||
|
||||
if ($guildId === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->getGuild($guildId);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$count = data_get($response->json(), 'approximate_member_count');
|
||||
|
||||
if (! is_int($count)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
['label' => __('analytics.metrics.members'), 'value' => $count],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-level metrics: thread replies plus reaction counts per emoji, tagged
|
||||
* so the UI renders them as pills alongside the member count.
|
||||
*
|
||||
* @return array<int, array{label: string, value: int, kind?: string}>
|
||||
*/
|
||||
public function fetchPostMetrics(PostPlatform $postPlatform): array
|
||||
{
|
||||
$account = $postPlatform->socialAccount;
|
||||
$metrics = $account
|
||||
? array_map(fn (array $metric): array => [...$metric, 'kind' => 'subscribers'], $this->getMetrics($account))
|
||||
: [];
|
||||
|
||||
$channelId = (string) data_get($postPlatform->meta, 'channel_id');
|
||||
$messageId = (string) $postPlatform->platform_post_id;
|
||||
|
||||
if ($channelId === '' || $messageId === '') {
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->getMessage($channelId, $messageId);
|
||||
} catch (Throwable) {
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return $metrics;
|
||||
}
|
||||
|
||||
$message = $response->json();
|
||||
|
||||
foreach ((array) data_get($message, 'reactions', []) as $reaction) {
|
||||
$name = (string) data_get($reaction, 'emoji.name');
|
||||
$label = data_get($reaction, 'emoji.id') ? ":{$name}:" : $name;
|
||||
|
||||
$metrics[] = [
|
||||
'label' => $label !== '' ? $label : __('analytics.metrics.custom_reaction'),
|
||||
'value' => (int) data_get($reaction, 'count'),
|
||||
'kind' => 'reaction',
|
||||
];
|
||||
}
|
||||
|
||||
// Thread replies are their own metric (kind "comments") so the UI renders
|
||||
// them as a 💬 pill next to the member count, with a distinguishing tooltip.
|
||||
$replies = (int) data_get($message, 'thread.message_count', 0);
|
||||
|
||||
if ($replies > 0) {
|
||||
$metrics[] = ['label' => __('analytics.metrics.comments'), 'value' => $replies, 'kind' => 'comments'];
|
||||
}
|
||||
|
||||
return $metrics;
|
||||
}
|
||||
}
|
||||
258
app/Services/Social/Discord/DiscordClient.php
Normal file
258
app/Services/Social/Discord/DiscordClient.php
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Discord;
|
||||
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Read-side Discord API calls made with the global bot token (channel listing,
|
||||
* mention search, guild verification). Posting lives in DiscordPublisher.
|
||||
*/
|
||||
class DiscordClient
|
||||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
/**
|
||||
* Channel types that accept a direct message via POST /channels/{id}/messages:
|
||||
* 0 = text, 5 = announcement. Forum (15) is excluded — it only accepts a thread
|
||||
* (forum post), so a plain message returns "Cannot send messages in a non-text channel".
|
||||
*/
|
||||
private const POSTABLE_CHANNEL_TYPES = [0, 5];
|
||||
|
||||
private const CHANNELS_TTL = 300;
|
||||
|
||||
private const PERMISSION_ADMINISTRATOR = 0x8;
|
||||
|
||||
private const PERMISSION_VIEW_CHANNEL = 0x400;
|
||||
|
||||
private const PERMISSION_SEND_MESSAGES = 0x800;
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return (string) config('trypost.platforms.discord.api');
|
||||
}
|
||||
|
||||
/**
|
||||
* The channels of a guild the bot can actually post into: postable type
|
||||
* (text/announcement) AND the bot has VIEW_CHANNEL + SEND_MESSAGES there.
|
||||
*
|
||||
* @return list<array{id: string, name: string}>
|
||||
*
|
||||
* @throws PlatformUnavailableException when the lookup fails transiently, so
|
||||
* callers (e.g. the publish-time channel guard) retry instead of
|
||||
* treating an empty result as "channel not found".
|
||||
*/
|
||||
public function channels(string $guildId): array
|
||||
{
|
||||
// Cached per guild for 5 minutes — channels change rarely and this runs
|
||||
// both in the composer picker and on every publish (the channel guard),
|
||||
// all against the shared, rate-limited bot token. A transient failure
|
||||
// throws and is NOT cached, so the next call retries.
|
||||
return Cache::remember("discord:channels:{$guildId}", self::CHANNELS_TTL, function () use ($guildId) {
|
||||
$response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/channels");
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new PlatformUnavailableException("Discord channel lookup failed ({$response->status()}).", $response->status());
|
||||
}
|
||||
|
||||
$channels = $response->json();
|
||||
|
||||
if (! is_array($channels)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rolePermissions = $this->guildRolePermissions($guildId);
|
||||
$botRoleIds = $this->botRoleIds($guildId);
|
||||
|
||||
return collect($channels)
|
||||
->filter(fn ($channel) => in_array((int) data_get($channel, 'type'), self::POSTABLE_CHANNEL_TYPES, true))
|
||||
->filter(fn ($channel) => $this->botCanPostInChannel($channel, $guildId, $rolePermissions, $botRoleIds))
|
||||
->map(fn ($channel) => [
|
||||
'id' => (string) data_get($channel, 'id'),
|
||||
'name' => (string) data_get($channel, 'name'),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bot has VIEW_CHANNEL + SEND_MESSAGES in a channel, computed from
|
||||
* its roles' base permissions and the channel's permission overwrites (Discord's
|
||||
* standard algorithm). Falls back to `true` when the bot's roles can't be
|
||||
* resolved, so a transient API hiccup never hides every channel from the picker.
|
||||
*
|
||||
* @param array<string, mixed> $channel
|
||||
* @param array<string, int> $rolePermissions role id => base permission bitfield
|
||||
* @param list<string>|null $botRoleIds role ids assigned to the bot, or null when unknown
|
||||
*/
|
||||
private function botCanPostInChannel(array $channel, string $guildId, array $rolePermissions, ?array $botRoleIds): bool
|
||||
{
|
||||
if ($botRoleIds === null || $rolePermissions === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$base = $rolePermissions[$guildId] ?? 0; // @everyone role id == guild id
|
||||
foreach ($botRoleIds as $roleId) {
|
||||
$base |= ($rolePermissions[$roleId] ?? 0);
|
||||
}
|
||||
|
||||
if (($base & self::PERMISSION_ADMINISTRATOR) !== 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$permissions = $this->applyChannelOverwrites($base, $channel, $guildId, $botRoleIds);
|
||||
|
||||
return ($permissions & self::PERMISSION_VIEW_CHANNEL) !== 0
|
||||
&& ($permissions & self::PERMISSION_SEND_MESSAGES) !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a channel's permission_overwrites to a base bitfield in Discord's
|
||||
* documented order: @everyone overwrite, then the union of role overwrites,
|
||||
* then the bot's member overwrite.
|
||||
*
|
||||
* @param array<string, mixed> $channel
|
||||
* @param list<string> $botRoleIds
|
||||
*/
|
||||
private function applyChannelOverwrites(int $permissions, array $channel, string $guildId, array $botRoleIds): int
|
||||
{
|
||||
$overwrites = collect((array) data_get($channel, 'permission_overwrites', []));
|
||||
|
||||
$everyone = $overwrites->first(fn ($overwrite) => (string) data_get($overwrite, 'id') === $guildId);
|
||||
if ($everyone !== null) {
|
||||
$permissions = ($permissions & ~(int) data_get($everyone, 'deny', 0)) | (int) data_get($everyone, 'allow', 0);
|
||||
}
|
||||
|
||||
$roleAllow = 0;
|
||||
$roleDeny = 0;
|
||||
foreach ($overwrites as $overwrite) {
|
||||
if ((int) data_get($overwrite, 'type') === 0 && in_array((string) data_get($overwrite, 'id'), $botRoleIds, true)) {
|
||||
$roleAllow |= (int) data_get($overwrite, 'allow', 0);
|
||||
$roleDeny |= (int) data_get($overwrite, 'deny', 0);
|
||||
}
|
||||
}
|
||||
$permissions = ($permissions & ~$roleDeny) | $roleAllow;
|
||||
|
||||
$botId = (string) config('services.discord.client_id');
|
||||
$member = $overwrites->first(fn ($overwrite) => (int) data_get($overwrite, 'type') === 1 && (string) data_get($overwrite, 'id') === $botId);
|
||||
if ($member !== null) {
|
||||
$permissions = ($permissions & ~(int) data_get($member, 'deny', 0)) | (int) data_get($member, 'allow', 0);
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base permission bitfield per guild role (role id => permissions). Returns []
|
||||
* when the roles can't be fetched, which disables permission filtering.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function guildRolePermissions(string $guildId): array
|
||||
{
|
||||
$permissions = [];
|
||||
|
||||
foreach ($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles") as $role) {
|
||||
$permissions[(string) data_get($role, 'id')] = (int) data_get($role, 'permissions', 0);
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* The role ids assigned to the bot in a guild, or null when they can't be
|
||||
* resolved (no client id configured, or the member lookup failed).
|
||||
*
|
||||
* @return list<string>|null
|
||||
*/
|
||||
private function botRoleIds(string $guildId): ?array
|
||||
{
|
||||
$botId = (string) config('services.discord.client_id');
|
||||
|
||||
if ($botId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}/members/{$botId}");
|
||||
|
||||
if ($response->failed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $response->json('roles');
|
||||
|
||||
return is_array($roles) ? array_map('strval', $roles) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mentionable targets matching a query: @everyone/@here, roles, then members.
|
||||
*
|
||||
* @return list<array{id: string, label: string, type: string}>
|
||||
*/
|
||||
public function mentions(string $guildId, string $query): array
|
||||
{
|
||||
$query = trim($query);
|
||||
$needle = mb_strtolower($query);
|
||||
|
||||
$specials = collect([
|
||||
['id' => 'everyone', 'label' => '@everyone', 'type' => 'everyone'],
|
||||
['id' => 'here', 'label' => '@here', 'type' => 'here'],
|
||||
])->filter(fn ($item) => $needle === '' || str_contains($item['label'], $needle));
|
||||
|
||||
$roles = collect($this->getList("{$this->baseUrl()}/guilds/{$guildId}/roles"))
|
||||
->filter(fn ($role) => $needle === '' || str_contains(mb_strtolower((string) data_get($role, 'name')), $needle))
|
||||
->take(10)
|
||||
->map(fn ($role) => [
|
||||
'id' => (string) data_get($role, 'id'),
|
||||
'label' => '@'.data_get($role, 'name'),
|
||||
'type' => 'role',
|
||||
]);
|
||||
|
||||
$members = $query === '' ? collect() : collect(
|
||||
$this->getList("{$this->baseUrl()}/guilds/{$guildId}/members/search", ['query' => $query, 'limit' => 10])
|
||||
)->map(fn ($member) => [
|
||||
'id' => (string) data_get($member, 'user.id'),
|
||||
'label' => '@'.(data_get($member, 'user.global_name') ?: data_get($member, 'user.username')),
|
||||
'type' => 'user',
|
||||
]);
|
||||
|
||||
return $specials->concat($roles)->concat($members)->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* GETs a Discord list endpoint, returning [] on failure or a non-list body
|
||||
* (a failed call returns an error OBJECT, which must not be iterated as rows).
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function getList(string $url, array $query = []): array
|
||||
{
|
||||
$body = $this->bot()->get($url, $query)->json();
|
||||
|
||||
return is_array($body) && array_is_list($body) ? $body : [];
|
||||
}
|
||||
|
||||
public function getGuild(string $guildId): Response
|
||||
{
|
||||
return $this->bot()->get("{$this->baseUrl()}/guilds/{$guildId}", ['with_counts' => 'true']);
|
||||
}
|
||||
|
||||
public function getMessage(string $channelId, string $messageId): Response
|
||||
{
|
||||
return $this->bot()->get("{$this->baseUrl()}/channels/{$channelId}/messages/{$messageId}");
|
||||
}
|
||||
|
||||
private function bot(): PendingRequest
|
||||
{
|
||||
return $this->socialHttp()
|
||||
->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot');
|
||||
}
|
||||
}
|
||||
299
app/Services/Social/Discord/DiscordPublisher.php
Normal file
299
app/Services/Social/Discord/DiscordPublisher.php
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Social\Discord;
|
||||
|
||||
use App\DataTransferObjects\MediaItem;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\Social\DiscordPublishException;
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use App\Services\Social\Concerns\HasSocialHttpClient;
|
||||
use App\Services\Social\ContentSanitizer;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
class DiscordPublisher
|
||||
{
|
||||
use HasSocialHttpClient;
|
||||
|
||||
/**
|
||||
* Discord's hard message ceiling — also the maxContentLength() for the
|
||||
* platform, re-checked here because mentions are appended after validation.
|
||||
*/
|
||||
private const MAX_MESSAGE_LENGTH = 2000;
|
||||
|
||||
private const MAX_EMBED_COLOR = 0xFFFFFF;
|
||||
|
||||
public function publish(PostPlatform $postPlatform): array
|
||||
{
|
||||
$this->validateContentLength($postPlatform);
|
||||
|
||||
$account = $postPlatform->socialAccount;
|
||||
$guildId = (string) $account->platform_user_id;
|
||||
$channelId = (string) data_get($postPlatform->meta, 'channel_id');
|
||||
|
||||
if ($channelId === '') {
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'No Discord channel selected for this post.',
|
||||
category: ErrorCategory::Unknown,
|
||||
);
|
||||
}
|
||||
|
||||
// Posting uses the global bot token, so the channel id is the only thing
|
||||
// scoping where a message lands — verify it belongs to THIS connected
|
||||
// server, otherwise a crafted channel_id could post into any server the
|
||||
// bot is in (including another workspace's).
|
||||
$this->guardChannelBelongsToGuild($guildId, $channelId);
|
||||
|
||||
$content = $postPlatform->post->content
|
||||
? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform)
|
||||
: '';
|
||||
|
||||
$content = $this->appendMentions($content, $postPlatform);
|
||||
|
||||
if (mb_strlen($content) > self::MAX_MESSAGE_LENGTH) {
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'The message (including mentions) exceeds Discord\'s 2000-character limit.',
|
||||
category: ErrorCategory::Unknown,
|
||||
);
|
||||
}
|
||||
|
||||
$embeds = $this->buildEmbeds($postPlatform);
|
||||
$media = $postPlatform->post->mediaItems->take($postPlatform->platform->maxImages());
|
||||
|
||||
$payload = array_filter([
|
||||
'content' => $content !== '' ? $content : null,
|
||||
'embeds' => $embeds !== [] ? $embeds : null,
|
||||
'allowed_mentions' => $this->allowedMentions($postPlatform),
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$messageId = $media->isEmpty()
|
||||
? $this->send($channelId, $payload)
|
||||
: $this->sendWithMedia($channelId, $payload, $media);
|
||||
|
||||
return [
|
||||
'id' => $messageId,
|
||||
'url' => "https://discord.com/channels/{$guildId}/{$channelId}/{$messageId}",
|
||||
];
|
||||
}
|
||||
|
||||
private function guardChannelBelongsToGuild(string $guildId, string $channelId): void
|
||||
{
|
||||
$allowed = collect(app(DiscordClient::class)->channels($guildId))
|
||||
->pluck('id')
|
||||
->contains($channelId);
|
||||
|
||||
if (! $allowed) {
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'The bot can\'t post in the selected channel. Make sure it still exists, belongs to this server, and the bot has permission to send messages there.',
|
||||
category: ErrorCategory::Permission,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function send(string $channelId, array $payload): string
|
||||
{
|
||||
return $this->messageId($this->bot()->post($this->endpoint($channelId), $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads each media item, optimizes images for Discord, and uploads them
|
||||
* as multipart attachments alongside the JSON payload.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @param Collection<int, MediaItem> $media
|
||||
*/
|
||||
private function sendWithMedia(string $channelId, array $payload, Collection $media): string
|
||||
{
|
||||
$request = $this->bot();
|
||||
$attachments = [];
|
||||
$tempFiles = [];
|
||||
|
||||
try {
|
||||
foreach ($media->values() as $index => $item) {
|
||||
$tempFile = $this->downloadMedia($item);
|
||||
$tempFiles[] = $tempFile;
|
||||
|
||||
$filename = $item->original_filename ?: (basename($item->path) ?: "media-{$index}");
|
||||
$request = $request->attach("files[{$index}]", file_get_contents($tempFile), $filename);
|
||||
$attachments[] = ['id' => $index, 'filename' => $filename];
|
||||
}
|
||||
|
||||
$payload['attachments'] = $attachments;
|
||||
|
||||
return $this->messageId($request->post($this->endpoint($channelId), [
|
||||
'payload_json' => json_encode($payload),
|
||||
]));
|
||||
} finally {
|
||||
foreach ($tempFiles as $tempFile) {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function downloadMedia(MediaItem $item): string
|
||||
{
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'discord_media_');
|
||||
|
||||
$response = Http::withOptions(['sink' => $tempFile])->timeout(120)->get($item->url);
|
||||
|
||||
if ($response->failed() || (filesize($tempFile) ?: 0) === 0) {
|
||||
@unlink($tempFile);
|
||||
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'Failed to download a media file for this Discord post.',
|
||||
category: ErrorCategory::MediaFormat,
|
||||
);
|
||||
}
|
||||
|
||||
// Images are downsized to Discord's limit; videos/gifs are sent as-is.
|
||||
if ($item->mime_type && str_starts_with($item->mime_type, 'image/') && ! str_contains($item->mime_type, 'gif')) {
|
||||
try {
|
||||
return app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Discord);
|
||||
} catch (Throwable) {
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'Failed to process a media image for this Discord post.',
|
||||
category: ErrorCategory::MediaFormat,
|
||||
);
|
||||
} finally {
|
||||
@unlink($tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the post's Discord mention tokens to the content. Mentions live in
|
||||
* meta — not the shared content — so they only ping on Discord and don't leak
|
||||
* literal `<@id>` markup to other platforms.
|
||||
*/
|
||||
private function appendMentions(string $content, PostPlatform $postPlatform): string
|
||||
{
|
||||
$tokens = $this->mentionTokens($postPlatform)->implode(' ');
|
||||
|
||||
if ($tokens === '') {
|
||||
return $content;
|
||||
}
|
||||
|
||||
return $content === '' ? $tokens : "{$content}\n\n{$tokens}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explicit allowed_mentions object from the post's mention chips so
|
||||
* ONLY those targets ping. Defaults to `parse: []` (suppress everything), so a
|
||||
* literal "@everyone" typed into the shared content never pings by accident.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function allowedMentions(PostPlatform $postPlatform): array
|
||||
{
|
||||
$parse = [];
|
||||
$users = [];
|
||||
$roles = [];
|
||||
|
||||
foreach ($this->mentionTokens($postPlatform) as $token) {
|
||||
if ($token === '@everyone' || $token === '@here') {
|
||||
$parse[] = 'everyone';
|
||||
} elseif (preg_match('/^<@&(\d+)>$/', $token, $matches)) {
|
||||
$roles[] = $matches[1];
|
||||
} elseif (preg_match('/^<@(\d+)>$/', $token, $matches)) {
|
||||
$users[] = $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
$allowed = ['parse' => array_values(array_unique($parse))];
|
||||
|
||||
if ($users !== []) {
|
||||
$allowed['users'] = array_values(array_unique($users));
|
||||
}
|
||||
|
||||
if ($roles !== []) {
|
||||
$allowed['roles'] = array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
return $allowed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
private function mentionTokens(PostPlatform $postPlatform): Collection
|
||||
{
|
||||
return collect((array) data_get($postPlatform->meta, 'mentions', []))
|
||||
->map(fn ($mention) => trim((string) data_get($mention, 'token')))
|
||||
->filter()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function buildEmbeds(PostPlatform $postPlatform): array
|
||||
{
|
||||
return collect((array) data_get($postPlatform->meta, 'embeds', []))
|
||||
->map(fn ($embed) => array_filter([
|
||||
'title' => data_get($embed, 'title'),
|
||||
'description' => data_get($embed, 'description'),
|
||||
'url' => data_get($embed, 'url'),
|
||||
'color' => $this->color(data_get($embed, 'color')),
|
||||
'image' => ($image = data_get($embed, 'image')) ? ['url' => $image] : null,
|
||||
], fn ($value) => $value !== null && $value !== ''))
|
||||
->filter(fn ($embed) => $embed !== [])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function color(mixed $color): ?int
|
||||
{
|
||||
if (! is_string($color) || $color === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hex = ltrim($color, '#');
|
||||
|
||||
if (! ctype_xdigit($hex) || strlen($hex) !== 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return min((int) hexdec($hex), self::MAX_EMBED_COLOR);
|
||||
}
|
||||
|
||||
private function endpoint(string $channelId): string
|
||||
{
|
||||
return config('trypost.platforms.discord.api')."/channels/{$channelId}/messages";
|
||||
}
|
||||
|
||||
private function bot(): PendingRequest
|
||||
{
|
||||
return $this->socialHttp()
|
||||
->withToken((string) config('trypost.platforms.discord.bot_token'), 'Bot');
|
||||
}
|
||||
|
||||
private function messageId(Response $response): string
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw DiscordPublishException::fromApiResponse($response);
|
||||
}
|
||||
|
||||
$id = (string) data_get($response->json(), 'id');
|
||||
|
||||
if ($id === '') {
|
||||
throw new DiscordPublishException(
|
||||
userMessage: 'Discord returned an unexpected response with no message id.',
|
||||
category: ErrorCategory::Unknown,
|
||||
);
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
94
app/Socialite/DiscordProvider.php
Normal file
94
app/Socialite/DiscordProvider.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Socialite;
|
||||
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\ProviderInterface;
|
||||
use Laravel\Socialite\Two\User;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Discord bot-authorization OAuth. The user authorizes adding our shared bot to
|
||||
* one of their servers; the access-token response carries the `guild` the bot
|
||||
* was added to, which becomes the connected account (id = guild id). Channel
|
||||
* listing, posting and mentions all use the global bot token, not this token.
|
||||
*/
|
||||
class DiscordProvider extends AbstractProvider implements ProviderInterface
|
||||
{
|
||||
protected $scopes = ['bot', 'identify', 'guilds'];
|
||||
|
||||
protected $scopeSeparator = ' ';
|
||||
|
||||
/**
|
||||
* The token-exchange response, stashed so the guild it carries can become
|
||||
* the Socialite user without an extra API call.
|
||||
*
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $tokenResponse = [];
|
||||
|
||||
protected function getAuthUrl($state): string
|
||||
{
|
||||
return $this->buildAuthUrlFromBase(config('trypost.platforms.discord.oauth_api').'/authorize', $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getCodeFields($state = null): array
|
||||
{
|
||||
return array_merge(parent::getCodeFields($state), [
|
||||
'permissions' => (string) config('trypost.platforms.discord.permissions'),
|
||||
'integration_type' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getTokenUrl(): string
|
||||
{
|
||||
return config('trypost.platforms.discord.oauth_api').'/token';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAccessTokenResponse($code): array
|
||||
{
|
||||
return $this->tokenResponse = parent::getAccessTokenResponse($code);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bot-authorization token response includes the guild the bot joined.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getUserByToken($token): array
|
||||
{
|
||||
return (array) data_get($this->tokenResponse, 'guild', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user The guild object.
|
||||
*/
|
||||
protected function mapUserToObject(array $user): User
|
||||
{
|
||||
$guildId = data_get($user, 'id');
|
||||
|
||||
// No guild means the user authorized without adding the bot to a server
|
||||
// (or cancelled). Fail loudly so the callback shows an error instead of
|
||||
// persisting a broken account with a null guild id.
|
||||
if (blank($guildId)) {
|
||||
throw new RuntimeException('Discord authorization did not include a server.');
|
||||
}
|
||||
|
||||
$icon = data_get($user, 'icon');
|
||||
|
||||
return (new User)->setRaw($user)->map([
|
||||
'id' => $guildId,
|
||||
'nickname' => data_get($user, 'name'),
|
||||
'name' => data_get($user, 'name'),
|
||||
'avatar' => $guildId && $icon ? "https://cdn.discordapp.com/icons/{$guildId}/{$icon}.png" : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
140
app/Support/PostPlatformMetaRules.php
Normal file
140
app/Support/PostPlatformMetaRules.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\PostPlatform\AspectRatio;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Single source of truth for per-platform `PostPlatform.meta` validation, shared
|
||||
* by the web, public REST API, and MCP post create/update flows so every entry
|
||||
* point accepts the same per-platform settings and enforces the same
|
||||
* required-on-publish rules.
|
||||
*/
|
||||
class PostPlatformMetaRules
|
||||
{
|
||||
/**
|
||||
* TikTok content visibility options.
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
public const TIKTOK_PRIVACY_LEVELS = [
|
||||
'PUBLIC_TO_EVERYONE',
|
||||
'MUTUAL_FOLLOW_FRIENDS',
|
||||
'FOLLOWER_OF_CREATOR',
|
||||
'SELF_ONLY',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation rules for `platforms.*.meta` and all its per-platform sub-keys.
|
||||
* Spread into a FormRequest/MCP tool rule set as the complete meta contract.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
'platforms.*.meta' => ['sometimes', 'nullable', 'array'],
|
||||
|
||||
// Instagram / Facebook
|
||||
'platforms.*.meta.aspect_ratio' => ['sometimes', 'nullable', 'string', Rule::enum(AspectRatio::class)],
|
||||
|
||||
// TikTok
|
||||
'platforms.*.meta.privacy_level' => ['sometimes', 'nullable', 'string', Rule::in(self::TIKTOK_PRIVACY_LEVELS)],
|
||||
'platforms.*.meta.auto_add_music' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_comments' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_duet' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.allow_stitch' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.is_aigc' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.disclose' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
|
||||
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],
|
||||
|
||||
// Pinterest
|
||||
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
|
||||
|
||||
// Discord
|
||||
'platforms.*.meta.channel_id' => ['sometimes', 'nullable', 'string'],
|
||||
'platforms.*.meta.channel_name' => ['sometimes', 'nullable', 'string'],
|
||||
'platforms.*.meta.mentions' => ['sometimes', 'nullable', 'array'],
|
||||
'platforms.*.meta.mentions.*.token' => ['required', 'string'],
|
||||
'platforms.*.meta.mentions.*.label' => ['sometimes', 'nullable', 'string'],
|
||||
'platforms.*.meta.embeds' => ['sometimes', 'nullable', 'array', 'max:10'],
|
||||
'platforms.*.meta.embeds.*.title' => ['sometimes', 'nullable', 'string', 'max:256'],
|
||||
'platforms.*.meta.embeds.*.description' => ['sometimes', 'nullable', 'string', 'max:4096'],
|
||||
'platforms.*.meta.embeds.*.url' => ['sometimes', 'nullable', 'url'],
|
||||
'platforms.*.meta.embeds.*.image' => ['sometimes', 'nullable', 'url'],
|
||||
'platforms.*.meta.embeds.*.color' => ['sometimes', 'nullable', 'string', 'regex:/^#?[0-9A-Fa-f]{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds validation errors for per-platform meta that becomes mandatory once a
|
||||
* post is published or scheduled (TikTok privacy, Pinterest board, Discord
|
||||
* channel), based on the submitted request platforms. The caller resolves each
|
||||
* platform row to its Platform enum, since that lookup differs between create
|
||||
* (by social account) and update (by post platform).
|
||||
*
|
||||
* @param array<int, mixed> $platforms
|
||||
* @param callable(mixed, int): ?Platform $resolvePlatform
|
||||
*/
|
||||
public static function addRequiredOnPublishErrors(Validator $validator, array $platforms, callable $resolvePlatform): void
|
||||
{
|
||||
foreach ($platforms as $index => $platform) {
|
||||
$violation = self::requiredMetaViolation($resolvePlatform($platform, $index), data_get($platform, 'meta'));
|
||||
|
||||
if ($violation !== null) {
|
||||
[$field, $message] = $violation;
|
||||
$validator->errors()->add("platforms.{$index}.meta.{$field}", $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that every ENABLED platform already stored on a post has the meta it
|
||||
* needs to publish. Used by entry points that publish a post's stored state
|
||||
* without resubmitting platforms (e.g. the MCP publish tool), so a misconfigured
|
||||
* post fails fast with a clear message instead of only at publish time.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function assertStoredPostPublishable(Post $post): void
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($post->postPlatforms()->where('enabled', true)->get()->values() as $index => $postPlatform) {
|
||||
$violation = self::requiredMetaViolation($postPlatform->platform, $postPlatform->meta);
|
||||
|
||||
if ($violation !== null) {
|
||||
[$field, $message] = $violation;
|
||||
$errors["platforms.{$index}.meta.{$field}"] = $message;
|
||||
}
|
||||
}
|
||||
|
||||
if ($errors !== []) {
|
||||
throw ValidationException::withMessages($errors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The missing required meta field for a platform about to publish, or null when
|
||||
* nothing is missing. Single source of "what each platform requires to publish".
|
||||
*
|
||||
* @return array{0: string, 1: string}|null [field, message]
|
||||
*/
|
||||
private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array
|
||||
{
|
||||
return match (true) {
|
||||
$platform === Platform::TikTok && blank(data_get($meta, 'privacy_level')) => ['privacy_level', trans('posts.form.tiktok.privacy_required')],
|
||||
$platform === Platform::Pinterest && blank(data_get($meta, 'board_id')) => ['board_id', trans('posts.form.pinterest.board_required')],
|
||||
$platform === Platform::Discord && blank(data_get($meta, 'channel_id')) => ['channel_id', trans('posts.form.discord.channel_required')],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
83
composer.lock
generated
83
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "4d71722b650e74b41e1943d3ae9c814f",
|
||||
"content-hash": "19849eb01cb4d8f93733666b53444c5a",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -7312,6 +7312,87 @@
|
|||
},
|
||||
"time": "2026-03-20T00:53:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "simplepie/simplepie",
|
||||
"version": "1.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/simplepie/simplepie.git",
|
||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/simplepie/simplepie/zipball/76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
||||
"reference": "76cccb1b2c5dcaf44f304c925ab30c0f48643992",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-pcre": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"php": ">=7.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"donatj/mock-webserver": "^2.7",
|
||||
"friendsofphp/php-cs-fixer": "^2.19 || ^3.8",
|
||||
"mf2/mf2": "^0.5.0",
|
||||
"phpstan/phpstan": "~1.12.2",
|
||||
"phpunit/phpunit": "^8 || ^9 || ^10",
|
||||
"psr/http-client": "^1.0",
|
||||
"psr/http-factory": "^1.0",
|
||||
"psr/simple-cache": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "",
|
||||
"ext-iconv": "",
|
||||
"ext-intl": "",
|
||||
"ext-mbstring": "",
|
||||
"mf2/mf2": "Microformat module that allows for parsing HTML for microformats"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"SimplePie": "library"
|
||||
},
|
||||
"psr-4": {
|
||||
"SimplePie\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ryan Parman",
|
||||
"homepage": "http://ryanparman.com/",
|
||||
"role": "Creator, alumnus developer"
|
||||
},
|
||||
{
|
||||
"name": "Sam Sneddon",
|
||||
"homepage": "https://gsnedders.com/",
|
||||
"role": "Alumnus developer"
|
||||
},
|
||||
{
|
||||
"name": "Ryan McCue",
|
||||
"email": "me@ryanmccue.info",
|
||||
"homepage": "http://ryanmccue.info/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "A simple Atom/RSS parsing library for PHP",
|
||||
"homepage": "http://simplepie.org/",
|
||||
"keywords": [
|
||||
"atom",
|
||||
"feeds",
|
||||
"rss"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/simplepie/simplepie/issues",
|
||||
"source": "https://github.com/simplepie/simplepie/tree/1.9.0"
|
||||
},
|
||||
"time": "2025-09-12T06:34:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "socialiteproviders/facebook",
|
||||
"version": "4.1.0",
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@
|
|||
'redirect' => env('PINTEREST_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
// Discord
|
||||
'discord' => [
|
||||
'client_id' => env('DISCORD_CLIENT_ID'),
|
||||
'client_secret' => env('DISCORD_CLIENT_SECRET'),
|
||||
'redirect' => env('DISCORD_CLIENT_REDIRECT'),
|
||||
],
|
||||
|
||||
'gtm' => [
|
||||
'id' => env('GTM_ID'),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -162,6 +162,20 @@
|
|||
// Secret-token header Telegram echoes on every webhook call.
|
||||
'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
|
||||
],
|
||||
'discord' => [
|
||||
'enabled' => env('DISCORD_ENABLED', true),
|
||||
// Single shared bot application. OAuth (bot scope) authorizes adding the
|
||||
// bot to the user's server; channel listing, mentions and posting all
|
||||
// use this bot token, not the user's OAuth token.
|
||||
'bot_token' => env('DISCORD_BOT_TOKEN'),
|
||||
'api' => env('DISCORD_API', 'https://discord.com/api/v10'),
|
||||
'oauth_api' => env('DISCORD_OAUTH_API', 'https://discord.com/api/oauth2'),
|
||||
// Permission bitfield requested for the bot: VIEW_CHANNEL (1<<10) +
|
||||
// SEND_MESSAGES (1<<11) + EMBED_LINKS (1<<14) + ATTACH_FILES (1<<15) +
|
||||
// READ_MESSAGE_HISTORY (1<<16) + MENTION_EVERYONE (1<<17) = 248832.
|
||||
'permissions' => env('DISCORD_PERMISSIONS', '248832'),
|
||||
'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -155,6 +155,14 @@ public function facebook(): static
|
|||
]);
|
||||
}
|
||||
|
||||
public function discord(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Discord,
|
||||
'content_type' => ContentType::DiscordMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
public function facebookReel(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
|
|
|||
|
|
@ -154,6 +154,19 @@ public function telegram(): static
|
|||
]);
|
||||
}
|
||||
|
||||
public function discord(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'platform' => Platform::Discord,
|
||||
'scopes' => Platform::Discord->requiredPublishScopes(),
|
||||
'token_expires_at' => null, // posting uses the global bot token
|
||||
'access_token' => '',
|
||||
'refresh_token' => '',
|
||||
'platform_user_id' => '999000111', // guild id
|
||||
'display_name' => 'My Server',
|
||||
]);
|
||||
}
|
||||
|
||||
public function disconnected(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
'bluesky' => 'Connect your Bluesky account',
|
||||
'mastodon' => 'Connect your Mastodon account',
|
||||
'telegram' => 'Connect a Telegram channel or group',
|
||||
'discord' => 'Connect a Discord server',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
'impressions' => 'Impressions',
|
||||
'interactions' => 'Interactions',
|
||||
'likes' => 'Likes',
|
||||
'members' => 'Members',
|
||||
'minutes_watched' => 'Minutes Watched',
|
||||
'organic_followers' => 'Organic Followers',
|
||||
'outbound_clicks' => 'Outbound Clicks',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -161,6 +161,26 @@
|
|||
'no_board_found' => 'No board matches your search.',
|
||||
'board_required' => 'Select a Pinterest board to publish this post.',
|
||||
],
|
||||
'discord' => [
|
||||
'settings' => 'Discord Settings',
|
||||
'posting_to' => 'Posting to',
|
||||
'channel' => 'Channel',
|
||||
'select_channel' => 'Select a channel',
|
||||
'loading_channels' => 'Loading channels…',
|
||||
'search_channel' => 'Search channels…',
|
||||
'no_channels' => 'No channels found.',
|
||||
'channel_required' => 'Select a Discord channel to publish this post.',
|
||||
'mentions' => 'Mentions',
|
||||
'search_mention' => 'Mention a role or member…',
|
||||
'embeds' => 'Embeds',
|
||||
'embed' => 'Embed',
|
||||
'add_embed' => 'Add embed',
|
||||
'embed_title' => 'Embed title',
|
||||
'embed_description' => 'Embed description',
|
||||
'embed_url' => 'Embed URL',
|
||||
'embed_image' => 'Image URL',
|
||||
'embed_color' => 'Color',
|
||||
],
|
||||
'warnings' => [
|
||||
'no_variant' => 'Pick a post type to continue.',
|
||||
'requires_media' => 'This post type requires at least one image or video.',
|
||||
|
|
@ -488,6 +508,10 @@
|
|||
'label' => 'Post',
|
||||
'description' => 'Text post with optional media',
|
||||
],
|
||||
'discord_message' => [
|
||||
'label' => 'Message',
|
||||
'description' => 'Message to a Discord channel with optional media & embeds',
|
||||
],
|
||||
],
|
||||
|
||||
'platforms' => [
|
||||
|
|
@ -591,6 +615,7 @@
|
|||
'threads_post' => 'Threads Post',
|
||||
'mastodon_post' => 'Mastodon Post',
|
||||
'telegram_post' => 'Telegram Post',
|
||||
'discord_message' => 'Discord Message',
|
||||
'facebook_post' => 'Facebook Post',
|
||||
'pinterest_pin' => 'Pinterest Pin',
|
||||
'instagram_story' => 'Instagram Story',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
'bluesky' => 'Conecta tu cuenta de Bluesky',
|
||||
'mastodon' => 'Conecta tu cuenta de Mastodon',
|
||||
'telegram' => 'Conecta un canal o grupo de Telegram',
|
||||
'discord' => 'Conecta un servidor de Discord',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
'impressions' => 'Impresiones',
|
||||
'interactions' => 'Interacciones',
|
||||
'likes' => 'Me gusta',
|
||||
'members' => 'Miembros',
|
||||
'minutes_watched' => 'Minutos Vistos',
|
||||
'organic_followers' => 'Seguidores Orgánicos',
|
||||
'outbound_clicks' => 'Clics Externos',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -161,6 +161,26 @@
|
|||
'no_board_found' => 'Ningún tablero coincide con tu búsqueda.',
|
||||
'board_required' => 'Selecciona un tablero de Pinterest para publicar este post.',
|
||||
],
|
||||
'discord' => [
|
||||
'settings' => 'Configuración de Discord',
|
||||
'posting_to' => 'Publicando en',
|
||||
'channel' => 'Canal',
|
||||
'select_channel' => 'Selecciona un canal',
|
||||
'loading_channels' => 'Cargando canales…',
|
||||
'search_channel' => 'Buscar canales…',
|
||||
'no_channels' => 'No se encontraron canales.',
|
||||
'channel_required' => 'Selecciona un canal de Discord para publicar este post.',
|
||||
'mentions' => 'Menciones',
|
||||
'search_mention' => 'Menciona un rol o miembro…',
|
||||
'embeds' => 'Embeds',
|
||||
'embed' => 'Embed',
|
||||
'add_embed' => 'Añadir embed',
|
||||
'embed_title' => 'Título del embed',
|
||||
'embed_description' => 'Descripción del embed',
|
||||
'embed_url' => 'URL del embed',
|
||||
'embed_image' => 'URL de la imagen',
|
||||
'embed_color' => 'Color',
|
||||
],
|
||||
'warnings' => [
|
||||
'no_variant' => 'Elige un tipo de publicación para continuar.',
|
||||
'requires_media' => 'Este tipo requiere al menos una imagen o video.',
|
||||
|
|
@ -488,6 +508,10 @@
|
|||
'label' => 'Post',
|
||||
'description' => 'Post de texto con multimedia opcional',
|
||||
],
|
||||
'discord_message' => [
|
||||
'label' => 'Mensaje',
|
||||
'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales',
|
||||
],
|
||||
],
|
||||
|
||||
'platforms' => [
|
||||
|
|
@ -592,6 +616,7 @@
|
|||
'threads_post' => 'Post en Threads',
|
||||
'mastodon_post' => 'Post en Mastodon',
|
||||
'telegram_post' => 'Post en Telegram',
|
||||
'discord_message' => 'Mensaje de Discord',
|
||||
'facebook_post' => 'Post en Facebook',
|
||||
'pinterest_pin' => 'Pin de Pinterest',
|
||||
'instagram_story' => 'Story de Instagram',
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@
|
|||
'bluesky' => 'Conecte sua conta do Bluesky',
|
||||
'mastodon' => 'Conecte sua conta do Mastodon',
|
||||
'telegram' => 'Conecte um canal ou grupo do Telegram',
|
||||
'discord' => 'Conecte um servidor do Discord',
|
||||
],
|
||||
|
||||
'disconnect_modal' => [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
'impressions' => 'Impressões',
|
||||
'interactions' => 'Interações',
|
||||
'likes' => 'Curtidas',
|
||||
'members' => 'Membros',
|
||||
'minutes_watched' => 'Minutos Assistidos',
|
||||
'organic_followers' => 'Seguidores Orgânicos',
|
||||
'outbound_clicks' => 'Cliques Externos',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -161,6 +161,26 @@
|
|||
'no_board_found' => 'Nenhum quadro encontrado.',
|
||||
'board_required' => 'Selecione um quadro do Pinterest para publicar este post.',
|
||||
],
|
||||
'discord' => [
|
||||
'settings' => 'Configurações do Discord',
|
||||
'posting_to' => 'Publicando em',
|
||||
'channel' => 'Canal',
|
||||
'select_channel' => 'Selecione um canal',
|
||||
'loading_channels' => 'Carregando canais…',
|
||||
'search_channel' => 'Buscar canais…',
|
||||
'no_channels' => 'Nenhum canal encontrado.',
|
||||
'channel_required' => 'Selecione um canal do Discord para publicar este post.',
|
||||
'mentions' => 'Menções',
|
||||
'search_mention' => 'Mencione um cargo ou membro…',
|
||||
'embeds' => 'Embeds',
|
||||
'embed' => 'Embed',
|
||||
'add_embed' => 'Adicionar embed',
|
||||
'embed_title' => 'Título do embed',
|
||||
'embed_description' => 'Descrição do embed',
|
||||
'embed_url' => 'URL do embed',
|
||||
'embed_image' => 'URL da imagem',
|
||||
'embed_color' => 'Cor',
|
||||
],
|
||||
'warnings' => [
|
||||
'no_variant' => 'Escolha um tipo de publicação para continuar.',
|
||||
'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.',
|
||||
|
|
@ -488,6 +508,10 @@
|
|||
'label' => 'Post',
|
||||
'description' => 'Post de texto com mídia opcional',
|
||||
],
|
||||
'discord_message' => [
|
||||
'label' => 'Mensagem',
|
||||
'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais',
|
||||
],
|
||||
],
|
||||
|
||||
'platforms' => [
|
||||
|
|
@ -591,6 +615,7 @@
|
|||
'threads_post' => 'Post no Threads',
|
||||
'mastodon_post' => 'Post no Mastodon',
|
||||
'telegram_post' => 'Post no Telegram',
|
||||
'discord_message' => 'Mensagem do Discord',
|
||||
'facebook_post' => 'Post no Facebook',
|
||||
'pinterest_pin' => 'Pin no Pinterest',
|
||||
'instagram_story' => 'Story do Instagram',
|
||||
|
|
|
|||
BIN
public/images/accounts/discord.png
Normal file
BIN
public/images/accounts/discord.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
24
releases/v1.0.2/changelog.md
Normal file
24
releases/v1.0.2/changelog.md
Normal file
|
|
@ -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
|
||||
47
releases/v1.0.2/email.md
Normal file
47
releases/v1.0.2/email.md
Normal file
|
|
@ -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
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import { IconAlertCircle, IconCircleCheck } from '@tabler/icons-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import DiscordSettings from '@/components/posts/editor/DiscordSettings.vue';
|
||||
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
|
||||
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
|
||||
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
|
||||
|
|
@ -168,6 +169,14 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel
|
|||
:preview-only="previewOnly"
|
||||
@update:content-type="emit('update:contentType', channel.id, $event)"
|
||||
/>
|
||||
<DiscordSettings
|
||||
v-else-if="channel.platform === Platform.Discord"
|
||||
:social-account="channel.socialAccount"
|
||||
:meta="channel.meta"
|
||||
:disabled="disabled"
|
||||
:preview-only="previewOnly"
|
||||
@update:meta="emit('update:meta', channel.id, $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
83
resources/js/components/SearchableSelect.vue
Normal file
83
resources/js/components/SearchableSelect.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script setup lang="ts">
|
||||
import { IconCheck, IconChevronDown } from '@tabler/icons-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: 'Select…',
|
||||
searchPlaceholder: 'Search…',
|
||||
emptyText: 'No results.',
|
||||
disabled: false,
|
||||
invalid: false,
|
||||
},
|
||||
);
|
||||
|
||||
const value = defineModel<string>({ default: '' });
|
||||
|
||||
const open = ref(false);
|
||||
const selected = computed(() => props.options.find((option) => option.value === value.value));
|
||||
|
||||
const select = (option: Option) => {
|
||||
value.value = option.value;
|
||||
open.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-model:open="open">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="open"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-between font-normal"
|
||||
:class="invalid ? 'border-rose-500' : ''"
|
||||
>
|
||||
<span :class="selected ? 'text-foreground' : 'text-foreground/50'">
|
||||
{{ selected ? selected.label : placeholder }}
|
||||
</span>
|
||||
<IconChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-(--reka-popover-trigger-width) p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput :placeholder="searchPlaceholder" />
|
||||
<CommandList>
|
||||
<CommandEmpty>{{ emptyText }}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.label"
|
||||
@select="select(option)"
|
||||
>
|
||||
{{ option.label }}
|
||||
<IconCheck :class="cn('ml-auto size-4', value === option.value ? 'opacity-100' : 'opacity-0')" />
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
|
@ -104,6 +104,11 @@ const platformTheme: Record<
|
|||
rotate: '-rotate-2',
|
||||
image: '/images/accounts/telegram.png',
|
||||
},
|
||||
discord: {
|
||||
bg: 'bg-indigo-200',
|
||||
rotate: 'rotate-1',
|
||||
image: '/images/accounts/discord.png',
|
||||
},
|
||||
};
|
||||
|
||||
const themeFor = (value: string) =>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,23 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconLoader2, IconRefresh } from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { inject, ref, watch } from 'vue';
|
||||
|
||||
import { inspectFeed } from '@/actions/App/Http/Controllers/App/AutomationController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface DiscoveredField {
|
||||
path: string;
|
||||
sample: string;
|
||||
}
|
||||
|
||||
interface FetchRssConfig {
|
||||
feed_url: string;
|
||||
discovered_fields: DiscoveredField[];
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -15,20 +26,77 @@ const props = defineProps<{
|
|||
}>();
|
||||
const emit = defineEmits<{ update: [Record<string, unknown>] }>();
|
||||
|
||||
const automationId = inject<string>('automationId', '');
|
||||
|
||||
const local = ref<FetchRssConfig>({
|
||||
feed_url: (props.data.feed_url as string) ?? '',
|
||||
discovered_fields: (props.data.discovered_fields as DiscoveredField[]) ?? [],
|
||||
});
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
const expression = (path: string): string => `{{ ${path} }}`;
|
||||
|
||||
const inspect = async () => {
|
||||
if (isInspecting.value || local.value.feed_url.trim() === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
isInspecting.value = true;
|
||||
inspectError.value = null;
|
||||
|
||||
try {
|
||||
inspectHttp.feed_url = local.value.feed_url;
|
||||
const { fields } = await inspectHttp.post(inspectFeed.url(automationId));
|
||||
local.value.discovered_fields = fields;
|
||||
} catch {
|
||||
inspectError.value = trans('automations.config.fetch_rss.inspect_error');
|
||||
} finally {
|
||||
isInspecting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label class="mb-1 block">{{ $t('automations.config.fetch_rss.feed_url') }}</Label>
|
||||
<Input v-model="local.feed_url" placeholder="https://example.com/feed.xml" />
|
||||
<InputError :message="errors?.feed_url" class="mt-1" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.fetch_rss.feed_url_hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="isInspecting || local.feed_url.trim() === ''" @click="inspect">
|
||||
<IconLoader2 v-if="isInspecting" class="size-4 animate-spin" />
|
||||
<IconRefresh v-else class="size-4" />
|
||||
{{ isInspecting ? $t('automations.config.fetch_rss.inspecting') : $t('automations.config.fetch_rss.inspect') }}
|
||||
</Button>
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.fetch_rss.inspect_hint') }}</p>
|
||||
<InputError v-if="inspectError" :message="inspectError" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div v-if="local.discovered_fields.length > 0">
|
||||
<Label class="mb-1 block">{{ $t('automations.config.fetch_rss.discovered_fields') }}</Label>
|
||||
<ul class="max-h-48 space-y-1.5 overflow-y-auto rounded-md border border-border p-2">
|
||||
<li v-for="field in local.discovered_fields" :key="field.path" class="flex flex-col gap-0.5">
|
||||
<code class="text-xs text-foreground">{{ expression(field.path) }}</code>
|
||||
<span v-if="field.sample" class="truncate text-xs text-foreground/50">{{ field.sample }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconChartBar, IconLoader2, IconUsers } from '@tabler/icons-vue';
|
||||
import { IconChartBar, IconLoader2, IconMessageCircle, IconUsers } from '@tabler/icons-vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
|
|
@ -34,6 +34,9 @@ const stats = computed(() => metrics.value.filter((m) => !m.kind));
|
|||
const subscribers = computed(() =>
|
||||
metrics.value.find((m) => m.kind === 'subscribers'),
|
||||
);
|
||||
const comments = computed(() =>
|
||||
metrics.value.find((m) => m.kind === 'comments'),
|
||||
);
|
||||
const reactions = computed(() =>
|
||||
metrics.value.filter((m) => m.kind === 'reaction'),
|
||||
);
|
||||
|
|
@ -98,18 +101,17 @@ onMounted(async () => {
|
|||
</div>
|
||||
|
||||
<div
|
||||
v-if="subscribers || reactions.length > 0"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
v-if="subscribers || comments || reactions.length > 0"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
:class="{ 'mt-2': stats.length > 0 }"
|
||||
>
|
||||
<!-- Audience counts: monochrome line icons read as stats, not reactions. -->
|
||||
<TooltipProvider v-if="subscribers">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted px-2 py-1 text-xs"
|
||||
>
|
||||
<IconUsers class="size-3.5 text-muted-foreground" />
|
||||
<span class="font-semibold tabular-nums">{{
|
||||
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconUsers class="size-4" :stroke="1.75" />
|
||||
<span class="font-semibold tabular-nums text-foreground">{{
|
||||
formatNumberCompact(subscribers.value)
|
||||
}}</span>
|
||||
</span>
|
||||
|
|
@ -120,19 +122,37 @@ onMounted(async () => {
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider v-if="comments">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span class="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<IconMessageCircle class="size-4" :stroke="1.75" />
|
||||
<span class="font-semibold tabular-nums text-foreground">{{
|
||||
formatNumberCompact(comments.value)
|
||||
}}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ comments.label }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Divider between audience stats and the reactions chip. -->
|
||||
<span
|
||||
v-if="subscribers && reactions.length > 0"
|
||||
class="mx-0.5 h-4 w-px bg-border"
|
||||
v-if="(subscribers || comments) && reactions.length > 0"
|
||||
class="h-3.5 w-px bg-border"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Reactions: each emoji its own clean pill with its count. -->
|
||||
<span
|
||||
v-for="reaction in reactions"
|
||||
:key="reaction.label"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-1 text-xs"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs"
|
||||
>
|
||||
<span class="text-sm leading-none">{{ reaction.label }}</span>
|
||||
<span class="font-semibold tabular-nums">{{
|
||||
<span class="text-[13px] leading-none">{{ reaction.label }}</span>
|
||||
<span class="font-semibold tabular-nums text-foreground/80">{{
|
||||
formatNumberCompact(reaction.value)
|
||||
}}</span>
|
||||
</span>
|
||||
|
|
|
|||
334
resources/js/components/posts/editor/DiscordSettings.vue
Normal file
334
resources/js/components/posts/editor/DiscordSettings.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<script setup lang="ts">
|
||||
import { useHttp } from '@inertiajs/vue3';
|
||||
import { IconChevronDown, IconChevronUp, IconPlus, IconX } from '@tabler/icons-vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { channels as channelsRoute, mentions as mentionsRoute } from '@/actions/App/Http/Controllers/App/DiscordController';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import SearchableSelect from '@/components/SearchableSelect.vue';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePageErrors } from '@/composables/usePageErrors';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface DiscordChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MentionTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'everyone' | 'here' | 'role' | 'user';
|
||||
}
|
||||
|
||||
interface MentionChip {
|
||||
token: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EmbedDraft {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
socialAccount: SocialAccount | null;
|
||||
meta: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
previewOnly?: boolean;
|
||||
}>(),
|
||||
{ disabled: false, previewOnly: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:meta': [value: Record<string, any>] }>();
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const updateMeta = (patch: Record<string, any>) => emit('update:meta', { ...props.meta, ...patch });
|
||||
|
||||
// --- Channel picker (live fetch) ---------------------------------------------
|
||||
const channels = ref<DiscordChannel[]>([]);
|
||||
const channelsLoading = ref(false);
|
||||
const channelsHttp = useHttp<Record<string, never>, { channels: DiscordChannel[] }>();
|
||||
|
||||
const loadChannels = async () => {
|
||||
if (!props.socialAccount || channelsLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
channelsLoading.value = true;
|
||||
|
||||
try {
|
||||
const { channels: list } = await channelsHttp.get(channelsRoute.url(props.socialAccount.id));
|
||||
channels.value = list;
|
||||
} catch {
|
||||
channels.value = [];
|
||||
} finally {
|
||||
channelsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadChannels);
|
||||
|
||||
const channelId = computed({
|
||||
get: () => (props.meta?.channel_id as string) ?? '',
|
||||
set: (value: string) => updateMeta({ channel_id: value || null }),
|
||||
});
|
||||
|
||||
// Keep a saved channel selectable even before the live list loads (or if the
|
||||
// lookup is unavailable), so editing a post never visually "loses" its channel.
|
||||
const channelOptions = computed<DiscordChannel[]>(() => {
|
||||
if (channelId.value && !channels.value.some((channel) => channel.id === channelId.value)) {
|
||||
return [{ id: channelId.value, name: channelId.value }, ...channels.value];
|
||||
}
|
||||
|
||||
return channels.value;
|
||||
});
|
||||
|
||||
const channelSelectOptions = computed(() => channelOptions.value.map((channel) => ({ value: channel.id, label: `#${channel.name}` })));
|
||||
|
||||
// Persist the channel NAME alongside the id (display-only, for the preview) and
|
||||
// keep it fresh as the live list loads or the channel is renamed.
|
||||
watch([channelId, channels], () => {
|
||||
const name = channels.value.find((channel) => channel.id === channelId.value)?.name;
|
||||
|
||||
if (name && props.meta?.channel_name !== name) {
|
||||
updateMeta({ channel_name: name });
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const errors = usePageErrors();
|
||||
const channelError = computed<string | undefined>(() => {
|
||||
if (props.meta?.channel_id) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.entries(errors.value).find(([key]) => key.endsWith('.meta.channel_id'))?.[1];
|
||||
});
|
||||
|
||||
// --- Mentions (autocomplete chips) -------------------------------------------
|
||||
const mentionQuery = ref('');
|
||||
const mentionResults = ref<MentionTarget[]>([]);
|
||||
const mentionsHttp = useHttp<Record<string, never>, { mentions: MentionTarget[] }>();
|
||||
const mentions = computed<MentionChip[]>(() => {
|
||||
const value = props.meta?.mentions;
|
||||
return Array.isArray(value) ? (value as MentionChip[]) : [];
|
||||
});
|
||||
|
||||
const tokenFor = (target: MentionTarget): string =>
|
||||
({
|
||||
everyone: '@everyone',
|
||||
here: '@here',
|
||||
role: `<@&${target.id}>`,
|
||||
user: `<@${target.id}>`,
|
||||
})[target.type];
|
||||
|
||||
let mentionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(mentionQuery, (query) => {
|
||||
clearTimeout(mentionTimer);
|
||||
if (!props.socialAccount || query.trim() === '') {
|
||||
mentionResults.value = [];
|
||||
return;
|
||||
}
|
||||
mentionTimer = setTimeout(async () => {
|
||||
try {
|
||||
const { mentions: list } = await mentionsHttp.get(
|
||||
mentionsRoute.url(props.socialAccount!.id, { query: { q: query } }),
|
||||
);
|
||||
mentionResults.value = list;
|
||||
} catch {
|
||||
mentionResults.value = [];
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
|
||||
onUnmounted(() => clearTimeout(mentionTimer));
|
||||
|
||||
const addMention = (target: MentionTarget) => {
|
||||
const token = tokenFor(target);
|
||||
if (mentions.value.some((mention) => mention.token === token)) {
|
||||
return;
|
||||
}
|
||||
updateMeta({ mentions: [...mentions.value, { token, label: target.label }] });
|
||||
mentionQuery.value = '';
|
||||
mentionResults.value = [];
|
||||
};
|
||||
|
||||
const removeMention = (token: string) =>
|
||||
updateMeta({ mentions: mentions.value.filter((mention) => mention.token !== token) });
|
||||
|
||||
// --- Embeds (repeater) -------------------------------------------------------
|
||||
// Derived straight from meta (like channel/mentions) so it never diverges from
|
||||
// the persisted/auto-saved state. Inputs are controlled (one-way :value + emit),
|
||||
// so index keys are safe — Vue patches each reused row to the correct values.
|
||||
const embeds = computed<EmbedDraft[]>(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
|
||||
|
||||
const addEmbed = () => updateMeta({ embeds: [...embeds.value, {}] });
|
||||
const removeEmbed = (index: number) => updateMeta({ embeds: embeds.value.filter((_, i) => i !== index) });
|
||||
const updateEmbed = (index: number, patch: Partial<EmbedDraft>) =>
|
||||
updateMeta({ embeds: embeds.value.map((embed, i) => (i === index ? { ...embed, ...patch } : embed)) });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
|
||||
<img :src="getPlatformLogo('discord')" alt="Discord" class="size-full object-cover" />
|
||||
</span>
|
||||
<span class="truncate font-bold text-foreground">{{ $t('posts.form.discord.settings') }}</span>
|
||||
<span v-if="socialAccount?.display_name" class="truncate font-medium text-foreground/60">· {{ socialAccount.display_name }}</span>
|
||||
</span>
|
||||
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
|
||||
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
|
||||
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
|
||||
<Avatar
|
||||
:src="socialAccount.avatar_url"
|
||||
:name="socialAccount.display_name"
|
||||
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.posting_to') }}</p>
|
||||
<p class="truncate text-sm font-bold text-foreground">{{ socialAccount.display_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.channel') }}</p>
|
||||
<SearchableSelect
|
||||
v-model="channelId"
|
||||
:options="channelSelectOptions"
|
||||
:placeholder="channelsLoading ? $t('posts.form.discord.loading_channels') : $t('posts.form.discord.select_channel')"
|
||||
:search-placeholder="$t('posts.form.discord.search_channel')"
|
||||
:empty-text="$t('posts.form.discord.no_channels')"
|
||||
:disabled="disabled || channelsLoading"
|
||||
:invalid="!!channelError"
|
||||
/>
|
||||
<InputError :message="channelError" />
|
||||
</div>
|
||||
|
||||
<!-- Mentions -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.mentions') }}</p>
|
||||
<div v-if="mentions.length" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="mention in mentions"
|
||||
:key="mention.token"
|
||||
class="inline-flex items-center gap-1 rounded-full border-2 border-foreground/30 bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-foreground"
|
||||
>
|
||||
{{ mention.label }}
|
||||
<button type="button" :disabled="disabled" class="text-foreground/50 hover:text-foreground" @click="removeMention(mention.token)">
|
||||
<IconX class="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="mentionQuery"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('posts.form.discord.search_mention')"
|
||||
/>
|
||||
<ul
|
||||
v-if="mentionResults.length"
|
||||
class="absolute z-10 mt-1 max-h-48 w-full overflow-y-auto rounded-lg border-2 border-foreground bg-card shadow-2xs"
|
||||
>
|
||||
<li v-for="target in mentionResults" :key="target.type + target.id">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center px-3 py-1.5 text-left text-sm hover:bg-foreground/5"
|
||||
@click="addMention(target)"
|
||||
>
|
||||
{{ target.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embeds -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.discord.embeds') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="inline-flex cursor-pointer items-center gap-1 text-xs font-bold text-foreground/70 hover:text-foreground disabled:opacity-50"
|
||||
@click="addEmbed"
|
||||
>
|
||||
<IconPlus class="size-3.5" />
|
||||
{{ $t('posts.form.discord.add_embed') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(embed, index) in embeds"
|
||||
:key="index"
|
||||
class="space-y-2 rounded-lg border-2 border-foreground/20 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] font-black uppercase tracking-widest text-foreground/50">{{ $t('posts.form.discord.embed') }} {{ index + 1 }}</span>
|
||||
<button type="button" :disabled="disabled" class="text-foreground/50 hover:text-rose-600" @click="removeEmbed(index)">
|
||||
<IconX class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
:model-value="embed.title"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('posts.form.discord.embed_title')"
|
||||
@update:model-value="updateEmbed(index, { title: String($event) })"
|
||||
/>
|
||||
<textarea
|
||||
:value="embed.description"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('posts.form.discord.embed_description')"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border-2 border-foreground/30 bg-card px-3 py-2 text-sm transition-colors hover:border-foreground focus:border-foreground focus:outline-none disabled:opacity-50"
|
||||
@input="updateEmbed(index, { description: ($event.target as HTMLTextAreaElement).value })"
|
||||
/>
|
||||
<Input
|
||||
:model-value="embed.url"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('posts.form.discord.embed_url')"
|
||||
@update:model-value="updateEmbed(index, { url: String($event) })"
|
||||
/>
|
||||
<Input
|
||||
:model-value="embed.image"
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('posts.form.discord.embed_image')"
|
||||
@update:model-value="updateEmbed(index, { image: String($event) })"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
:value="embed.color || '#5865F2'"
|
||||
:disabled="disabled"
|
||||
class="h-8 w-12 cursor-pointer rounded border-2 border-foreground/30 disabled:opacity-50"
|
||||
@input="updateEmbed(index, { color: ($event.target as HTMLInputElement).value })"
|
||||
/>
|
||||
<span class="text-xs font-medium text-foreground/60">{{ $t('posts.form.discord.embed_color') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
128
resources/js/components/posts/previews/DiscordPreview.vue
Normal file
128
resources/js/components/posts/previews/DiscordPreview.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
|
||||
import { isVideoMedia } from '@/composables/useMedia';
|
||||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
display_name: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
interface EmbedDraft {
|
||||
title?: string;
|
||||
description?: string;
|
||||
url?: string;
|
||||
image?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface MentionChip {
|
||||
token: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
socialAccount: SocialAccount;
|
||||
content: string;
|
||||
media: MediaItem[];
|
||||
meta?: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const embeds = computed<EmbedDraft[]>(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
|
||||
const channelName = computed<string>(() => (props.meta?.channel_name as string) || 'channel');
|
||||
const mentions = computed<MentionChip[]>(() => (Array.isArray(props.meta?.mentions) ? (props.meta!.mentions as MentionChip[]) : []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col overflow-hidden bg-white">
|
||||
<!-- Channel header -->
|
||||
<div class="flex items-center gap-2 border-b border-black/10 px-4 py-2.5 text-[#313338]">
|
||||
<span class="text-xl leading-none text-[#80848e]">#</span>
|
||||
<span class="truncate text-[15px] font-semibold">{{ channelName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4">
|
||||
<div class="flex gap-3">
|
||||
<img
|
||||
v-if="socialAccount.avatar_url"
|
||||
:src="socialAccount.avatar_url"
|
||||
:alt="socialAccount.display_name"
|
||||
class="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#5865F2] font-semibold text-white"
|
||||
>
|
||||
{{ socialAccount.display_name?.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-[15px] font-medium text-[#060607]">{{ socialAccount.display_name || 'TryPost' }}</span>
|
||||
<span class="rounded bg-[#5865F2] px-1 text-[10px] font-bold uppercase tracking-wide text-white">Bot</span>
|
||||
<span class="text-[11px] text-[#5c5e66]">Today at 4:30 PM</span>
|
||||
</div>
|
||||
|
||||
<p v-if="content" class="mt-0.5 whitespace-pre-wrap text-[15px] leading-[1.375] text-[#313338]">{{ content }}</p>
|
||||
|
||||
<!-- Mentions (Discord renders them as blurple pills) -->
|
||||
<div v-if="mentions.length" class="mt-0.5 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="mention in mentions"
|
||||
:key="mention.token"
|
||||
class="rounded bg-[#e6e9ff] px-1 text-[15px] font-medium text-[#444ec1]"
|
||||
>
|
||||
{{ mention.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Media -->
|
||||
<div
|
||||
v-if="media.length > 0"
|
||||
class="mt-2 overflow-hidden rounded-lg"
|
||||
:class="{ 'grid grid-cols-2 gap-0.5': media.length >= 2 }"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in media.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="relative overflow-hidden"
|
||||
:class="media.length === 1 ? 'aspect-[4/3] max-w-sm' : 'aspect-square'"
|
||||
>
|
||||
<img
|
||||
v-if="!isVideoMedia(item)"
|
||||
:src="item.url"
|
||||
:alt="item.original_filename"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<VideoPreview v-else :src="item.url" video-class="w-full h-full object-cover bg-black" />
|
||||
<div
|
||||
v-if="media.length > 4 && index === 3"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/60"
|
||||
>
|
||||
<span class="text-xl font-semibold text-white">+{{ media.length - 4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embeds -->
|
||||
<div
|
||||
v-for="(embed, index) in embeds"
|
||||
:key="index"
|
||||
class="mt-2 max-w-md overflow-hidden rounded border-l-4 bg-[#f2f3f5] p-3"
|
||||
:style="{ borderColor: embed.color || '#5865F2' }"
|
||||
>
|
||||
<p v-if="embed.title" class="text-[15px] font-semibold text-[#0866d8]">{{ embed.title }}</p>
|
||||
<p v-if="embed.description" class="mt-1 whitespace-pre-wrap text-[14px] leading-[1.3] text-[#313338]">{{ embed.description }}</p>
|
||||
<img v-if="embed.image" :src="embed.image" alt="" class="mt-2 max-h-48 rounded object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -4,6 +4,7 @@ import { computed } from 'vue';
|
|||
import type { MediaItem } from '@/types/media';
|
||||
|
||||
import BlueskyPreview from './BlueskyPreview.vue';
|
||||
import DiscordPreview from './DiscordPreview.vue';
|
||||
import FacebookPreview from './FacebookPreview.vue';
|
||||
import InstagramPreview from './InstagramPreview.vue';
|
||||
import LinkedInPreview from './LinkedInPreview.vue';
|
||||
|
|
@ -68,6 +69,8 @@ const previewComponent = computed(() => {
|
|||
return MastodonPreview;
|
||||
case 'telegram':
|
||||
return TelegramPreview;
|
||||
case 'discord':
|
||||
return DiscordPreview;
|
||||
default:
|
||||
return LinkedInPreview;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const PLATFORM_LOGOS: Record<string, string> = {
|
|||
pinterest: '/images/accounts/pinterest.png',
|
||||
mastodon: '/images/accounts/mastodon.png',
|
||||
telegram: '/images/accounts/telegram.png',
|
||||
discord: '/images/accounts/discord.png',
|
||||
};
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
|
|
@ -28,6 +29,7 @@ const PLATFORM_LABELS: Record<string, string> = {
|
|||
pinterest: 'Pinterest',
|
||||
mastodon: 'Mastodon',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
};
|
||||
|
||||
const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
|
||||
|
|
@ -48,6 +50,7 @@ const PLATFORM_CONTENT_TYPES: Record<string, string[]> = {
|
|||
bluesky: ['bluesky_post'],
|
||||
mastodon: ['mastodon_post'],
|
||||
telegram: ['telegram_post'],
|
||||
discord: ['discord_message'],
|
||||
};
|
||||
|
||||
export interface ContentTypeOption {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ const PLATFORM_META_RULES: Record<string, MetaRule> = {
|
|||
valid: Boolean(meta.board_id),
|
||||
tooltipKey: meta.board_id ? null : 'posts.form.pinterest.board_required',
|
||||
}),
|
||||
[Platform.Discord]: (meta) => ({
|
||||
valid: Boolean(meta.channel_id),
|
||||
tooltipKey: meta.channel_id ? null : 'posts.form.discord.channel_required',
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -205,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;
|
||||
|
|
@ -482,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
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const ContentType = {
|
|||
BlueskyPost: 'bluesky_post',
|
||||
MastodonPost: 'mastodon_post',
|
||||
TelegramPost: 'telegram_post',
|
||||
DiscordMessage: 'discord_message',
|
||||
} as const;
|
||||
|
||||
export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType];
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const Platform = {
|
|||
Bluesky: 'bluesky',
|
||||
Mastodon: 'mastodon',
|
||||
Telegram: 'telegram',
|
||||
Discord: 'discord',
|
||||
} as const;
|
||||
|
||||
export type PlatformValue = (typeof Platform)[keyof typeof Platform];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
use App\Http\Controllers\App\AssetController;
|
||||
use App\Http\Controllers\App\AutomationController;
|
||||
use App\Http\Controllers\App\BillingController;
|
||||
use App\Http\Controllers\App\DiscordController as AppDiscordController;
|
||||
use App\Http\Controllers\App\GiphyController;
|
||||
use App\Http\Controllers\App\NotificationController;
|
||||
use App\Http\Controllers\App\PostAiCreateController;
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
use App\Http\Controllers\App\WorkspaceLabelController;
|
||||
use App\Http\Controllers\App\WorkspaceSignatureController;
|
||||
use App\Http\Controllers\Auth\BlueskyController;
|
||||
use App\Http\Controllers\Auth\DiscordController;
|
||||
use App\Http\Controllers\Auth\FacebookController;
|
||||
use App\Http\Controllers\Auth\InstagramController;
|
||||
use App\Http\Controllers\Auth\InstagramFacebookController;
|
||||
|
|
@ -120,10 +122,22 @@
|
|||
Route::get('accounts/mastodon/callback', [MastodonController::class, 'callback'])->name('app.social.mastodon.callback');
|
||||
|
||||
Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect');
|
||||
|
||||
Route::get('connect/discord', [DiscordController::class, 'connect'])->name('app.social.discord.connect');
|
||||
Route::get('accounts/discord/callback', [DiscordController::class, 'callback'])->name('app.social.discord.callback');
|
||||
});
|
||||
|
||||
// Routes that require active subscription and completed onboarding
|
||||
Route::middleware(['auth', EnsureAccountReady::class])->group(function () {
|
||||
// Discord — live lookups for the composer (channel picker + mention autocomplete).
|
||||
// Throttled because they proxy the shared bot's (rate-limited) Discord API.
|
||||
Route::get('discord/accounts/{account}/channels', [AppDiscordController::class, 'channels'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('app.discord.channels');
|
||||
Route::get('discord/accounts/{account}/mentions', [AppDiscordController::class, 'mentions'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('app.discord.mentions');
|
||||
|
||||
// Workspaces
|
||||
Route::get('workspaces', [WorkspaceController::class, 'index'])->name('app.workspaces.index');
|
||||
Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('app.workspaces.switch');
|
||||
|
|
@ -224,6 +238,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
|
||||
|
|
|
|||
158
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
158
tests/Feature/Api/PostApiPlatformMetaTest.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Jobs\PublishPost;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
$result = createApiTestToken();
|
||||
$this->user = $result['user'];
|
||||
$this->workspace = $result['workspace'];
|
||||
$this->plainToken = $result['plain_token'];
|
||||
|
||||
$this->headers = ['Authorization' => 'Bearer '.$this->plainToken];
|
||||
|
||||
$this->discordAccount = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111222333',
|
||||
]);
|
||||
});
|
||||
|
||||
it('persists Discord channel, mentions and embeds meta on store', function () {
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson(route('api.posts.store'), [
|
||||
'content' => 'Hello Discord',
|
||||
'platforms' => [[
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'content_type' => ContentType::DiscordMessage->value,
|
||||
'meta' => [
|
||||
'channel_id' => '444555666',
|
||||
'mentions' => [['token' => '@everyone', 'label' => '@everyone']],
|
||||
'embeds' => [['title' => 'Release', 'color' => '#5865F2']],
|
||||
],
|
||||
]],
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$meta = PostPlatform::where('social_account_id', $this->discordAccount->id)->sole()->meta;
|
||||
|
||||
// Assert nested keys survive validated() — the exact stripping bug this PR fixes.
|
||||
expect(data_get($meta, 'channel_id'))->toBe('444555666')
|
||||
->and(data_get($meta, 'mentions.0.token'))->toBe('@everyone')
|
||||
->and(data_get($meta, 'mentions.0.label'))->toBe('@everyone')
|
||||
->and(data_get($meta, 'embeds.0.title'))->toBe('Release')
|
||||
->and(data_get($meta, 'embeds.0.color'))->toBe('#5865F2');
|
||||
});
|
||||
|
||||
it('persists per-platform meta across networks on store', function () {
|
||||
$instagram = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram]);
|
||||
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
|
||||
$tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->postJson(route('api.posts.store'), [
|
||||
'content' => 'Cross-platform',
|
||||
'platforms' => [
|
||||
['social_account_id' => $instagram->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => ['aspect_ratio' => '4:5']],
|
||||
['social_account_id' => $pinterest->id, 'content_type' => ContentType::PinterestPin->value, 'meta' => ['board_id' => 'board-99']],
|
||||
['social_account_id' => $tiktok->id, 'content_type' => ContentType::TikTokVideo->value, 'meta' => ['privacy_level' => 'SELF_ONLY', 'allow_comments' => true]],
|
||||
],
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
expect(PostPlatform::where('social_account_id', $instagram->id)->sole()->meta['aspect_ratio'])->toBe('4:5')
|
||||
->and(PostPlatform::where('social_account_id', $pinterest->id)->sole()->meta['board_id'])->toBe('board-99')
|
||||
->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['privacy_level'])->toBe('SELF_ONLY')
|
||||
->and(PostPlatform::where('social_account_id', $tiktok->id)->sole()->meta['allow_comments'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows saving a Discord draft without a channel', function () {
|
||||
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
|
||||
$platform = PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Draft->value,
|
||||
'platforms' => [['id' => $platform->id]],
|
||||
])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('rejects publishing without TikTok privacy and Pinterest board', function () {
|
||||
$pinterest = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::Pinterest]);
|
||||
$tiktok = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::TikTok]);
|
||||
|
||||
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
|
||||
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
||||
'post_id' => $post->id, 'social_account_id' => $pinterest->id, 'enabled' => true, 'meta' => [],
|
||||
]);
|
||||
$tiktokPlatform = PostPlatform::factory()->tiktok()->create([
|
||||
'post_id' => $post->id, 'social_account_id' => $tiktok->id, 'enabled' => true, 'meta' => [],
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Publishing->value,
|
||||
'platforms' => [
|
||||
['id' => $pinterestPlatform->id],
|
||||
['id' => $tiktokPlatform->id],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'platforms.0.meta.board_id',
|
||||
'platforms.1.meta.privacy_level',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects publishing a Discord post without a channel', function () {
|
||||
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
|
||||
$platform = PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Publishing->value,
|
||||
'platforms' => [['id' => $platform->id]],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['platforms.0.meta.channel_id']);
|
||||
});
|
||||
|
||||
it('publishes a Discord post when the channel is set', function () {
|
||||
Queue::fake();
|
||||
|
||||
$post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
|
||||
$platform = PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$this->withHeaders($this->headers)
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Publishing->value,
|
||||
'platforms' => [['id' => $platform->id, 'meta' => ['channel_id' => '444555666']]],
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($platform->fresh()->meta['channel_id'])->toBe('444555666');
|
||||
Queue::assertPushed(PublishPost::class);
|
||||
});
|
||||
192
tests/Feature/Automation/FeedInspectionTest.php
Normal file
192
tests/Feature/Automation/FeedInspectionTest.php
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Automation;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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(feedFixture('youtube_atom'), 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(feedFixture('youtube_atom'), 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('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('<rss version="2.0"><channel></channel></rss>', 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();
|
||||
});
|
||||
|
|
@ -15,37 +15,11 @@
|
|||
|
||||
afterEach(fn () => Carbon::setTestNow());
|
||||
|
||||
const FETCH_RSS_OLD = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Ancient</title><link>https://1.1.1.1/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Old</title><link>https://1.1.1.1/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
const FETCH_RSS_MIXED = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Older</title><link>https://1.1.1.1/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer1</title><link>https://1.1.1.1/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer2</title><link>https://1.1.1.1/c</link><guid>c</guid><pubDate>Mon, 15 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
const FETCH_RSS_THREE_NEW = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>New1</title><link>https://1.1.1.1/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New2</title><link>https://1.1.1.1/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New3</title><link>https://1.1.1.1/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
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' => [
|
||||
|
|
@ -219,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');
|
||||
|
|
@ -226,7 +205,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' => [
|
||||
|
|
@ -256,7 +235,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(feedFixture('youtube_atom'), 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 () {
|
||||
|
|
@ -293,14 +296,7 @@
|
|||
|
||||
it('skips items without a publish date', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
$feed = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Dated</title><link>https://1.1.1.1/d</link><guid>d</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>NoDate</title><link>https://1.1.1.1/n</link><guid>n</guid></item>
|
||||
</channel></rss>
|
||||
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([
|
||||
|
|
@ -319,13 +315,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'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>LinkKey</title><link>https://1.1.1.1/only-link</link><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
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([
|
||||
|
|
|
|||
137
tests/Feature/Discord/DiscordLookupTest.php
Normal file
137
tests/Feature/Discord/DiscordLookupTest.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush(); // channel list is cached per guild — isolate between tests
|
||||
config([
|
||||
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
||||
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id, 'user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->user->refresh();
|
||||
|
||||
$this->account = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111222333',
|
||||
]);
|
||||
});
|
||||
|
||||
test('lists only postable channels (text + announcement) the bot can send in', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
|
||||
['id' => '1', 'name' => 'general', 'type' => 0],
|
||||
['id' => '2', 'name' => 'voice', 'type' => 2], // voice — excluded
|
||||
['id' => '3', 'name' => 'news', 'type' => 5],
|
||||
['id' => '4', 'name' => 'category', 'type' => 4], // category — excluded
|
||||
['id' => '5', 'name' => 'forum', 'type' => 15], // forum — excluded (only accepts threads)
|
||||
], 200),
|
||||
// @everyone grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048) everywhere.
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.discord.channels', $this->account->id));
|
||||
|
||||
$response->assertOk();
|
||||
expect(collect($response->json('channels'))->pluck('name')->all())->toBe(['general', 'news']);
|
||||
});
|
||||
|
||||
test('excludes channels where the bot lacks send permission', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
|
||||
['id' => '1', 'name' => 'open', 'type' => 0],
|
||||
['id' => '2', 'name' => 'locked', 'type' => 0, 'permission_overwrites' => [
|
||||
// @everyone overwrite denies SEND_MESSAGES (2048) on this channel.
|
||||
['id' => '111222333', 'type' => 0, 'allow' => '0', 'deny' => '2048'],
|
||||
]],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['roles' => []], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.discord.channels', $this->account->id));
|
||||
|
||||
$response->assertOk();
|
||||
expect(collect($response->json('channels'))->pluck('name')->all())->toBe(['open']);
|
||||
});
|
||||
|
||||
test('does not hide channels when the bot role lookup is unavailable', function () {
|
||||
// A failed members lookup must not blank the picker — fall back to type filtering.
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/channels' => Http::response([
|
||||
['id' => '1', 'name' => 'general', 'type' => 0],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/999000111' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.discord.channels', $this->account->id));
|
||||
|
||||
$response->assertOk();
|
||||
expect(collect($response->json('channels'))->pluck('name')->all())->toBe(['general']);
|
||||
});
|
||||
|
||||
test('returns mention targets: specials, roles and members', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response([
|
||||
['id' => '10', 'name' => 'moderators'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/search*' => Http::response([
|
||||
['user' => ['id' => '20', 'username' => 'mod_jane', 'global_name' => 'Jane']],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.discord.mentions', $this->account->id).'?q=mod');
|
||||
|
||||
$response->assertOk();
|
||||
$types = collect($response->json('mentions'))->pluck('type')->all();
|
||||
expect($types)->toContain('role')->toContain('user');
|
||||
});
|
||||
|
||||
test('degrades to specials only when the roles/members lookup fails', function () {
|
||||
// A failed Discord call returns an error OBJECT, which must not be iterated
|
||||
// into bogus mention rows.
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/roles' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333/members/search*' => Http::response(['message' => 'Missing Access', 'code' => 50001], 403),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.discord.mentions', $this->account->id).'?q=mod');
|
||||
|
||||
$response->assertOk();
|
||||
$types = collect($response->json('mentions'))->pluck('type');
|
||||
expect($types)->not->toContain('role')->not->toContain('user');
|
||||
});
|
||||
|
||||
test('forbids looking up a discord account in another workspace', function () {
|
||||
$other = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => Workspace::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->getJson(route('app.discord.channels', $other->id))
|
||||
->assertForbidden();
|
||||
});
|
||||
180
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
180
tests/Feature/Mcp/PostPlatformMetaToolTest.php
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Jobs\PublishPost;
|
||||
use App\Mcp\Servers\TryPostServer;
|
||||
use App\Mcp\Tools\Post\CreatePostTool;
|
||||
use App\Mcp\Tools\Post\PublishPostTool;
|
||||
use App\Mcp\Tools\Post\UpdatePostTool;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
|
||||
$this->discordAccount = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111222333',
|
||||
]);
|
||||
});
|
||||
|
||||
test('create post persists Discord channel + embeds meta', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(CreatePostTool::class, [
|
||||
'content' => 'Hello Discord',
|
||||
'platforms' => [[
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'content_type' => ContentType::DiscordMessage->value,
|
||||
'meta' => [
|
||||
'channel_id' => '444555666',
|
||||
'embeds' => [['title' => 'Release']],
|
||||
],
|
||||
]],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$meta = PostPlatform::where('social_account_id', $this->discordAccount->id)->sole()->meta;
|
||||
|
||||
expect($meta['channel_id'])->toBe('444555666')
|
||||
->and(data_get($meta, 'embeds.0.title'))->toBe('Release');
|
||||
});
|
||||
|
||||
test('update post merges per-platform meta', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Draft,
|
||||
]);
|
||||
$platform = PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => ['channel_name' => 'general'],
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(UpdatePostTool::class, [
|
||||
'post_id' => $post->id,
|
||||
'platforms' => [[
|
||||
'id' => $platform->id,
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$meta = $platform->fresh()->meta;
|
||||
expect($meta['channel_id'])->toBe('444555666')
|
||||
->and($meta['channel_name'])->toBe('general'); // merged, not overwritten
|
||||
});
|
||||
|
||||
test('publish guard ignores disabled platforms missing meta', function () {
|
||||
Queue::fake();
|
||||
|
||||
$linkedin = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn]);
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Draft,
|
||||
]);
|
||||
PostPlatform::factory()->linkedin()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $linkedin->id,
|
||||
'enabled' => true,
|
||||
]);
|
||||
// Disabled Discord with no channel must not block the publish.
|
||||
PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => false,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertOk();
|
||||
Queue::assertPushed(PublishPost::class);
|
||||
});
|
||||
|
||||
test('publish post rejects a Discord platform without a channel', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Draft,
|
||||
]);
|
||||
PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertHasErrors([__('posts.form.discord.channel_required')]);
|
||||
});
|
||||
|
||||
test('publish guard enforces required meta for TikTok and Pinterest', function (string $factoryState, string $field, string $messageKey) {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => $factoryState === 'tiktok' ? Platform::TikTok : Platform::Pinterest,
|
||||
]);
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Draft,
|
||||
]);
|
||||
PostPlatform::factory()->{$factoryState}()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $account->id,
|
||||
'enabled' => true,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertHasErrors([__($messageKey)]);
|
||||
})->with([
|
||||
'tiktok' => ['tiktok', 'privacy_level', 'posts.form.tiktok.privacy_required'],
|
||||
'pinterest' => ['pinterest', 'board_id', 'posts.form.pinterest.board_required'],
|
||||
]);
|
||||
|
||||
test('publish post succeeds for a Discord platform with a channel', function () {
|
||||
Queue::fake();
|
||||
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => PostStatus::Draft,
|
||||
]);
|
||||
PostPlatform::factory()->discord()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $this->discordAccount->id,
|
||||
'enabled' => true,
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(PublishPostTool::class, ['post_id' => $post->id]);
|
||||
|
||||
$response->assertOk();
|
||||
Queue::assertPushed(PublishPost::class);
|
||||
});
|
||||
|
|
@ -448,3 +448,31 @@
|
|||
|
||||
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('verifies discord by checking the bot is still in the guild', function () {
|
||||
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*' => Http::response(['id' => '999000111'], 200),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->discord()->create([
|
||||
'platform_user_id' => '999000111',
|
||||
]);
|
||||
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeTrue();
|
||||
});
|
||||
|
||||
test('reports discord disconnected when the bot was removed from the guild', function () {
|
||||
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*' => Http::response(['message' => 'Unknown Guild'], 404),
|
||||
]);
|
||||
|
||||
$account = SocialAccount::factory()->discord()->create([
|
||||
'platform_user_id' => '999000111',
|
||||
]);
|
||||
|
||||
expect((new ConnectionVerifier)->verify($account))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal file
85
tests/Feature/Services/Social/DiscordAnalyticsTest.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\Status;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\Discord\DiscordAnalytics;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['trypost.platforms.discord.bot_token' => 'BOTTOKEN']);
|
||||
});
|
||||
|
||||
it('returns the server member count as an account metric', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['id' => '111222333', 'approximate_member_count' => 4200], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->getMetrics($account))
|
||||
->toBe([['label' => 'Members', 'value' => 4200]]);
|
||||
});
|
||||
|
||||
it('returns no account metrics when the guild lookup fails', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['message' => 'Unknown Guild'], 404),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->getMetrics($account))->toBe([]);
|
||||
});
|
||||
|
||||
it('maps message reactions and thread replies to post metrics', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => '777',
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
|
||||
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response([
|
||||
'id' => '777',
|
||||
'thread' => ['message_count' => 8],
|
||||
'reactions' => [
|
||||
['count' => 12, 'emoji' => ['id' => null, 'name' => '🔥']],
|
||||
['count' => 3, 'emoji' => ['id' => '999', 'name' => 'partyblob']],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
|
||||
->toBe([
|
||||
['label' => 'Members', 'value' => 50, 'kind' => 'subscribers'],
|
||||
['label' => '🔥', 'value' => 12, 'kind' => 'reaction'],
|
||||
['label' => ':partyblob:', 'value' => 3, 'kind' => 'reaction'],
|
||||
['label' => 'Comments', 'value' => 8, 'kind' => 'comments'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only the member count when the message has no engagement yet', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['platform_user_id' => '111222333']);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => '777',
|
||||
'meta' => ['channel_id' => '444555666'],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/111222333*' => Http::response(['approximate_member_count' => 50], 200),
|
||||
config('trypost.platforms.discord.api').'/channels/444555666/messages/777' => Http::response(['id' => '777'], 200),
|
||||
]);
|
||||
|
||||
expect(app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform))
|
||||
->toBe([['label' => 'Members', 'value' => 50, 'kind' => 'subscribers']]);
|
||||
});
|
||||
200
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal file
200
tests/Feature/Services/Social/DiscordPublisherTest.php
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Exceptions\PlatformUnavailableException;
|
||||
use App\Exceptions\Social\DiscordPublishException;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use App\Services\Social\Discord\DiscordPublisher;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush(); // channel list is cached per guild — isolate between tests
|
||||
config([
|
||||
'trypost.platforms.discord.bot_token' => 'BOTTOKEN',
|
||||
'services.discord.client_id' => '999000111', // bot user id, used by the channel permission check
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$this->socialAccount = SocialAccount::factory()->discord()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform_user_id' => '111222333', // guild id
|
||||
]);
|
||||
|
||||
$this->post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
'content' => 'Hello Discord',
|
||||
]);
|
||||
|
||||
$this->makePostPlatform = fn (array $meta = ['channel_id' => '444555666']) => PostPlatform::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $this->socialAccount->id,
|
||||
'platform' => Platform::Discord,
|
||||
'content_type' => ContentType::DiscordMessage,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
|
||||
$this->publisher = new DiscordPublisher;
|
||||
});
|
||||
|
||||
function fakeDiscord(array $messageResponse = ['id' => '777'], int $status = 200): void
|
||||
{
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([
|
||||
['id' => '444555666', 'name' => 'general', 'type' => 0],
|
||||
], 200),
|
||||
// @everyone (role id == guild id) grants VIEW_CHANNEL + SEND_MESSAGES (1024 + 2048).
|
||||
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([
|
||||
['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072'],
|
||||
], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
||||
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response($messageResponse, $status),
|
||||
]);
|
||||
}
|
||||
|
||||
test('publishes a text-only message and suppresses accidental pings', function () {
|
||||
fakeDiscord();
|
||||
|
||||
$result = $this->publisher->publish(($this->makePostPlatform)());
|
||||
|
||||
expect($result['id'])->toBe('777');
|
||||
expect($result['url'])->toBe('https://discord.com/channels/111222333/444555666/777');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/messages')) {
|
||||
return false; // only the message POST may satisfy this assertion
|
||||
}
|
||||
|
||||
return $request->hasHeader('Authorization', 'Bot BOTTOKEN')
|
||||
&& data_get($request->data(), 'content') === 'Hello Discord'
|
||||
// No mention chips → parse is empty, so typed "@everyone" never pings.
|
||||
&& data_get($request->data(), 'allowed_mentions.parse') === [];
|
||||
});
|
||||
});
|
||||
|
||||
test('builds allowed_mentions from explicit mention chips only', function () {
|
||||
fakeDiscord();
|
||||
|
||||
$this->publisher->publish(($this->makePostPlatform)([
|
||||
'channel_id' => '444555666',
|
||||
'mentions' => [
|
||||
['token' => '@everyone', 'label' => '@everyone'],
|
||||
['token' => '<@&10>', 'label' => '@moderators'],
|
||||
],
|
||||
]));
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/messages')) {
|
||||
return false;
|
||||
}
|
||||
$allowed = data_get($request->data(), 'allowed_mentions');
|
||||
|
||||
return data_get($allowed, 'parse') === ['everyone']
|
||||
&& data_get($allowed, 'roles') === ['10']
|
||||
&& data_get($request->data(), 'content') === "Hello Discord\n\n@everyone <@&10>";
|
||||
});
|
||||
});
|
||||
|
||||
test('includes rich embeds with clamped color', function () {
|
||||
fakeDiscord(['id' => '888']);
|
||||
|
||||
$this->publisher->publish(($this->makePostPlatform)([
|
||||
'channel_id' => '444555666',
|
||||
'embeds' => [[
|
||||
'title' => 'Release v2',
|
||||
'description' => 'Shipped today',
|
||||
'color' => '#5865F2',
|
||||
'image' => 'https://example.com/banner.png',
|
||||
]],
|
||||
]));
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/messages')) {
|
||||
return false;
|
||||
}
|
||||
$embed = data_get($request->data(), 'embeds.0');
|
||||
|
||||
return data_get($embed, 'title') === 'Release v2'
|
||||
&& data_get($embed, 'color') === hexdec('5865F2')
|
||||
&& data_get($embed, 'image.url') === 'https://example.com/banner.png';
|
||||
});
|
||||
});
|
||||
|
||||
test('uploads media as a multipart attachment', function () {
|
||||
$this->post->update([
|
||||
'media' => [[
|
||||
'id' => 'm1',
|
||||
'path' => 'media/2026-01/pic.jpg',
|
||||
'url' => 'https://example.com/media/2026-01/pic.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'original_filename' => 'pic.jpg',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->mock(MediaOptimizer::class)
|
||||
->shouldReceive('optimizeImage')
|
||||
->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'discord_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024))));
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response([['id' => '444555666', 'name' => 'general', 'type' => 0]], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/roles' => Http::response([['id' => '111222333', 'name' => '@everyone', 'permissions' => '3072']], 200),
|
||||
config('trypost.platforms.discord.api').'/guilds/*/members/*' => Http::response(['roles' => []], 200),
|
||||
'example.com/*' => Http::response(str_repeat('x', 1024), 200),
|
||||
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '901'], 200),
|
||||
]);
|
||||
|
||||
$result = $this->publisher->publish(($this->makePostPlatform)());
|
||||
|
||||
expect($result['id'])->toBe('901');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
if (! str_contains($request->url(), '/messages')) {
|
||||
return false;
|
||||
}
|
||||
$names = collect($request->data())->pluck('name');
|
||||
|
||||
return $names->contains('payload_json') && $names->contains('files[0]');
|
||||
});
|
||||
});
|
||||
|
||||
test('throws when no channel is selected', function () {
|
||||
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(meta: [])))
|
||||
->toThrow(DiscordPublishException::class);
|
||||
});
|
||||
|
||||
test('throws when the channel is not part of the guild', function () {
|
||||
fakeDiscord();
|
||||
|
||||
expect(fn () => $this->publisher->publish(($this->makePostPlatform)(['channel_id' => '999999999'])))
|
||||
->toThrow(DiscordPublishException::class);
|
||||
});
|
||||
|
||||
test('retries (does not permanently fail) when the channel lookup is transiently down', function () {
|
||||
Http::fake([
|
||||
config('trypost.platforms.discord.api').'/guilds/*/channels' => Http::response('upstream down', 500),
|
||||
config('trypost.platforms.discord.api').'/channels/*/messages' => Http::response(['id' => '777'], 200),
|
||||
]);
|
||||
|
||||
// A 5xx on the channel guard must surface as PlatformUnavailableException so
|
||||
// the publish job reschedules — not a permanent "channel not in server" fail.
|
||||
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
||||
->toThrow(PlatformUnavailableException::class);
|
||||
});
|
||||
|
||||
test('maps a missing-permission error to a publish exception', function () {
|
||||
fakeDiscord(['code' => 50013, 'message' => 'Missing Permissions'], 403);
|
||||
|
||||
expect(fn () => $this->publisher->publish(($this->makePostPlatform)()))
|
||||
->toThrow(DiscordPublishException::class);
|
||||
});
|
||||
80
tests/Feature/Social/DiscordControllerTest.php
Normal file
80
tests/Feature/Social/DiscordControllerTest.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\SocialAccount\Status;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
});
|
||||
|
||||
test('discord connect redirects to the oauth provider', function () {
|
||||
$driverMock = Mockery::mock();
|
||||
$driverMock->shouldReceive('scopes')->andReturnSelf();
|
||||
$driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([
|
||||
'getTargetUrl' => 'https://discord.com/api/oauth2/authorize?test=1',
|
||||
]));
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn($driverMock);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withHeader('X-Inertia', 'true')
|
||||
->get(route('app.social.discord.connect'))
|
||||
->assertStatus(409); // Inertia::location
|
||||
|
||||
expect(session('social_connect_workspace'))->toBe($this->workspace->id);
|
||||
});
|
||||
|
||||
test('discord oauth callback creates the server account', function () {
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn('999000111'); // guild id
|
||||
$socialiteUser->shouldReceive('getNickname')->andReturn('My Server');
|
||||
$socialiteUser->shouldReceive('getName')->andReturn('My Server');
|
||||
$socialiteUser->shouldReceive('getAvatar')->andReturn(null);
|
||||
$socialiteUser->token = 'discord-access-token';
|
||||
$socialiteUser->refreshToken = 'discord-refresh-token';
|
||||
$socialiteUser->expiresIn = null;
|
||||
$socialiteUser->approvedScopes = ['bot', 'identify', 'guilds'];
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser]));
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', true);
|
||||
|
||||
$this->assertDatabaseHas('social_accounts', [
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Discord->value,
|
||||
'platform_user_id' => '999000111',
|
||||
'status' => Status::Connected->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('discord callback fails gracefully when no server was authorized', function () {
|
||||
session(['social_connect_workspace' => $this->workspace->id]);
|
||||
|
||||
// DiscordProvider throws when the token response carries no guild.
|
||||
$mock = Mockery::mock();
|
||||
$mock->shouldReceive('user')->andThrow(new RuntimeException('Discord authorization did not include a server.'));
|
||||
|
||||
Socialite::shouldReceive('driver')->with('discord')->andReturn($mock);
|
||||
|
||||
$response = $this->actingAs($this->user)->get(route('app.social.discord.callback'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertViewHas('success', false);
|
||||
|
||||
expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(0);
|
||||
});
|
||||
|
|
@ -541,3 +541,47 @@
|
|||
|
||||
$response->assertSessionHasErrors('platforms.0.content_type');
|
||||
});
|
||||
|
||||
test('publishing a discord post without a channel is rejected', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'content_type' => ContentType::DiscordMessage,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Publishing->value,
|
||||
'platforms' => [[
|
||||
'id' => $postPlatform->id,
|
||||
'content_type' => ContentType::DiscordMessage->value,
|
||||
'meta' => [],
|
||||
]],
|
||||
])
|
||||
->assertSessionHasErrors('platforms.0.meta.channel_id');
|
||||
});
|
||||
|
||||
test('saving a discord draft without a channel is allowed', function () {
|
||||
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
|
||||
$postPlatform = PostPlatform::factory()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $account->id,
|
||||
'platform' => Platform::Discord,
|
||||
'content_type' => ContentType::DiscordMessage,
|
||||
'meta' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Draft->value,
|
||||
'platforms' => [[
|
||||
'id' => $postPlatform->id,
|
||||
'content_type' => ContentType::DiscordMessage->value,
|
||||
'meta' => [],
|
||||
]],
|
||||
])
|
||||
->assertSessionDoesntHaveErrors('platforms.0.meta.channel_id');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
76
tests/Unit/Automation/FeedParserTest.php
Normal file
76
tests/Unit/Automation/FeedParserTest.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Automation\FeedParser;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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 <published>, author from <author><name>.
|
||||
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('<p>You can now serve your changelogs fully translated in Polish.</p>');
|
||||
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];
|
||||
|
||||
// <itunes:image href="..."/> 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 = '<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>';
|
||||
|
||||
expect($this->parser->parse($empty))->toBe([]);
|
||||
});
|
||||
20
tests/fixtures/feeds/podcast.xml
vendored
Normal file
20
tests/fixtures/feeds/podcast.xml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
|
||||
<channel>
|
||||
<title>The Changelog</title>
|
||||
<link>https://changelog.com/podcast</link>
|
||||
<description>Software news</description>
|
||||
<item>
|
||||
<title>From open source hits to OpenAI</title>
|
||||
<guid isPermaLink="false">changelog.com/1/2829</guid>
|
||||
<link>https://changelog.com/podcast/682</link>
|
||||
<pubDate>Fri, 05 Jun 2026 19:00:00 +0000</pubDate>
|
||||
<enclosure type="audio/mpeg" length="153465091" url="https://cdn.changelog.com/the-changelog-682.mp3"/>
|
||||
<description>This week I am talking with Max Stoiber.</description>
|
||||
<itunes:episodeType>full</itunes:episodeType>
|
||||
<itunes:image href="https://cdn.changelog.com/cover.png"/>
|
||||
<itunes:duration>1:46:28</itunes:duration>
|
||||
<itunes:explicit>false</itunes:explicit>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
5
tests/fixtures/feeds/rss_dateless.xml
vendored
Normal file
5
tests/fixtures/feeds/rss_dateless.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Dated</title><link>https://1.1.1.1/d</link><guid>d</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>NoDate</title><link>https://1.1.1.1/n</link><guid>n</guid></item>
|
||||
</channel></rss>
|
||||
6
tests/fixtures/feeds/rss_mixed.xml
vendored
Normal file
6
tests/fixtures/feeds/rss_mixed.xml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Older</title><link>https://1.1.1.1/a</link><guid>a</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer1</title><link>https://1.1.1.1/b</link><guid>b</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Newer2</title><link>https://1.1.1.1/c</link><guid>c</guid><pubDate>Sun, 15 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
20
tests/fixtures/feeds/rss_namespaces.xml
vendored
Normal file
20
tests/fixtures/feeds/rss_namespaces.xml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
|
||||
<channel>
|
||||
<title>Changelogfy</title>
|
||||
<description>Changelogfy</description>
|
||||
<link>https://feedback.changelogfy.com</link>
|
||||
<atom:link href="https://feedback.changelogfy.com/rss" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<title><![CDATA[Polish is here]]></title>
|
||||
<description><![CDATA[You can now serve your changelogs in Polish.]]></description>
|
||||
<link>https://feedback.changelogfy.com/en/polish-is-here</link>
|
||||
<guid>16092</guid>
|
||||
<dc:creator>Paulo Castellano</dc:creator>
|
||||
<pubDate>Thu, 05 Mar 2026 16:20:09 +0000</pubDate>
|
||||
<category><![CDATA[Feature]]></category>
|
||||
<category><![CDATA[i18n]]></category>
|
||||
<content:encoded><![CDATA[<p>You can now serve your changelogs fully translated in Polish.</p>]]></content:encoded>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
4
tests/fixtures/feeds/rss_no_guid.xml
vendored
Normal file
4
tests/fixtures/feeds/rss_no_guid.xml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>LinkKey</title><link>https://1.1.1.1/only-link</link><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
5
tests/fixtures/feeds/rss_old.xml
vendored
Normal file
5
tests/fixtures/feeds/rss_old.xml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>Ancient</title><link>https://1.1.1.1/1</link><guid>1</guid><pubDate>Mon, 01 Jan 2024 12:00:00 +0000</pubDate></item>
|
||||
<item><title>Old</title><link>https://1.1.1.1/2</link><guid>2</guid><pubDate>Sat, 01 Feb 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
6
tests/fixtures/feeds/rss_three_new.xml
vendored
Normal file
6
tests/fixtures/feeds/rss_three_new.xml
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>New1</title><link>https://1.1.1.1/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New2</title><link>https://1.1.1.1/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New3</title><link>https://1.1.1.1/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
36
tests/fixtures/feeds/youtube_atom.xml
vendored
Normal file
36
tests/fixtures/feeds/youtube_atom.xml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns:yt="http://www.youtube.com/xml/schemas/2015" xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">
|
||||
<link rel="self" href="http://www.youtube.com/feeds/videos.xml?channel_id=UC123"/>
|
||||
<id>yt:channel:123</id>
|
||||
<yt:channelId>123</yt:channelId>
|
||||
<title>Beta Boechat</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/channel/UC123"/>
|
||||
<author>
|
||||
<name>Beta Boechat</name>
|
||||
<uri>https://www.youtube.com/channel/UC123</uri>
|
||||
</author>
|
||||
<published>2015-08-11T18:07:31+00:00</published>
|
||||
<entry>
|
||||
<id>yt:video:bIQVeW4sTcE</id>
|
||||
<yt:videoId>bIQVeW4sTcE</yt:videoId>
|
||||
<yt:channelId>123</yt:channelId>
|
||||
<title>Ninguém aguenta o TikTok Shop</title>
|
||||
<link rel="alternate" href="https://www.youtube.com/watch?v=bIQVeW4sTcE"/>
|
||||
<author>
|
||||
<name>Beta Boechat</name>
|
||||
<uri>https://www.youtube.com/channel/UC123</uri>
|
||||
</author>
|
||||
<published>2026-06-15T20:24:32+00:00</published>
|
||||
<updated>2026-06-15T21:15:11+00:00</updated>
|
||||
<media:group>
|
||||
<media:title>Ninguém aguenta o TikTok Shop</media:title>
|
||||
<media:content url="https://www.youtube.com/v/bIQVeW4sTcE?version=3" type="application/x-shockwave-flash" width="640" height="390"/>
|
||||
<media:thumbnail url="https://i3.ytimg.com/vi/bIQVeW4sTcE/hqdefault.jpg" width="480" height="360"/>
|
||||
<media:description>Descrição completa do vídeo aqui.</media:description>
|
||||
<media:community>
|
||||
<media:starRating count="3389" average="5.00" min="1" max="5"/>
|
||||
<media:statistics views="20085"/>
|
||||
</media:community>
|
||||
</media:group>
|
||||
</entry>
|
||||
</feed>
|
||||
Loading…
Reference in a new issue