Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
63f393a
feat(reddit): register reddit platform enum
paulocastellano Jun 17, 2026
a742840
feat(reddit): register reddit_post content type
paulocastellano Jun 17, 2026
587b06c
feat(reddit): add reddit platform + oauth config
paulocastellano Jun 17, 2026
47e5093
feat(reddit): add reddit socialite provider
paulocastellano Jun 17, 2026
a2409d1
feat(reddit): oauth connect + callback
paulocastellano Jun 17, 2026
9d46070
feat(reddit): token verify + refresh
paulocastellano Jun 17, 2026
ba39808
feat(reddit): read-side api client (search, restrictions, flair, info)
paulocastellano Jun 17, 2026
6129d95
feat(reddit): publish exception
paulocastellano Jun 17, 2026
bb545f7
feat(reddit): publisher for self/link + multi-subreddit + partial fai…
paulocastellano Jun 17, 2026
982a1ba
fix(reddit): guard empty title, correct gallery kind, cover meta fiel…
paulocastellano Jun 17, 2026
e124108
feat(reddit): single-image upload via asset lease (video/gallery defe…
paulocastellano Jun 17, 2026
2f37305
feat(reddit): dispatch reddit posts to RedditPublisher
paulocastellano Jun 17, 2026
039983f
feat(reddit): per-platform meta rules + required-on-publish
paulocastellano Jun 17, 2026
c15bb6b
feat(reddit): subreddit search + restrictions endpoints
paulocastellano Jun 17, 2026
5ec17d1
feat(reddit): post + account metrics (upvotes, comments, karma)
paulocastellano Jun 17, 2026
8d6c535
feat(reddit): i18n keys (en, es, pt-BR)
paulocastellano Jun 17, 2026
9002e35
feat(reddit): connect-account prompt i18n
paulocastellano Jun 17, 2026
5ff696d
feat(reddit): frontend platform registration + logo
paulocastellano Jun 17, 2026
798ece4
feat(reddit): composer subreddit settings panel
paulocastellano Jun 17, 2026
1033567
feat(reddit): post preview
paulocastellano Jun 17, 2026
622e364
feat(reddit): media optimizer profile for reddit images
paulocastellano Jun 17, 2026
972bb37
fix(reddit): allow text-only posts, drop deferred video, profile url,…
paulocastellano Jun 17, 2026
5be02b3
chore: remove competitor reference from media optimizer comment
paulocastellano Jun 17, 2026
c4b7537
fix(reddit): row-state alignment, debounce race, preview link/media g…
paulocastellano Jun 17, 2026
e1170ca
fix(reddit): rescue lookup endpoints, data_get karma, array-cast toke…
paulocastellano Jun 17, 2026
1a14b0e
test(reddit): cover required-on-publish branches, media/oauth/client …
paulocastellano Jun 17, 2026
51033a6
fix(reddit): make idFor pure, cancel debounce timer on row removal
paulocastellano Jun 17, 2026
61eec51
test(reddit): make required-on-publish API tests branch-specific, iso…
paulocastellano Jun 17, 2026
94bb2f2
feat(reddit): gallery posts up to 10 images; sharper API error messages
paulocastellano Jun 17, 2026
6a91ff8
feat(reddit): cap images at 10 to match gallery support
paulocastellano Jun 17, 2026
098a165
chore(reddit): document reddit env vars in docker example
paulocastellano Jun 17, 2026
a785f9b
refactor(reddit): default reddit user-agent to shared TRYPOST_USER_AGENT
paulocastellano Jun 17, 2026
2dee606
docs: require shared config user-agent for outbound HTTP
paulocastellano Jun 17, 2026
bc0d1f7
refactor: centralize outbound User-Agent in config('app.user_agent')
paulocastellano Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,13 @@ DISCORD_CLIENT_SECRET=
DISCORD_BOT_TOKEN=
DISCORD_CLIENT_REDIRECT="${APP_URL}/accounts/discord/callback"

# Reddit (create an app at https://www.reddit.com/prefs/apps — choose "web app")
REDDIT_ENABLED=true
REDDIT_CLIENT_ID=
REDDIT_CLIENT_SECRET=
REDDIT_CLIENT_REDIRECT="${APP_URL}/accounts/reddit/callback"
REDDIT_SCOPES="identity,read,submit,flair,mysubreddits"

# AI Services
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,10 @@ Vue components must have a single root element.
- Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes.
- Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config.

## HTTP User-Agent

- NEVER hardcode a `User-Agent` string for outbound HTTP, and NEVER add a per-platform or per-feature user-agent config key. There is ONE source of truth: `config('app.user_agent')` (backed by `env('TRYPOST_USER_AGENT', ...)` in `config/app.php`). Every outbound request that needs a UA — automation webhook/http nodes, social provider APIs (Reddit, etc.), anything — reads `config('app.user_agent')`.

## TryPost.it Documentation

- All our documentation to final user it's under https://docs.trypost.it
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Automation/Node/RunHttpRequestNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ private function buildRequest(array $config, array $context): PendingRequest
$request = $request->withHeaders($headers);
}

return $request->withUserAgent(config('trypost.user_agent'));
return $request->withUserAgent(config('app.user_agent'));
}

/**
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Automation/Node/RunWebhookNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult

try {
$response = Http::withHeaders($headers)
->withUserAgent(config('trypost.user_agent'))
->withUserAgent(config('app.user_agent'))
->send($method, $url, ['json' => $payload]);
} catch (Throwable $e) {
return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [
Expand Down
9 changes: 9 additions & 0 deletions app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ enum ContentType: string
// Discord
case DiscordMessage = 'discord_message';

// Reddit
case RedditPost = 'reddit_post';

/**
* AI generation format for an Instagram carousel. Not a content type —
* carousel posts are persisted as InstagramFeed.
Expand Down Expand Up @@ -85,6 +88,7 @@ public function label(): string
self::MastodonPost => 'Post',
self::TelegramPost => 'Post',
self::DiscordMessage => 'Message',
self::RedditPost => 'Post',
};
}

Expand All @@ -109,6 +113,7 @@ public function platform(): SocialPlatform
self::MastodonPost => SocialPlatform::Mastodon,
self::TelegramPost => SocialPlatform::Telegram,
self::DiscordMessage => SocialPlatform::Discord,
self::RedditPost => SocialPlatform::Reddit,
};
}

Expand Down Expand Up @@ -179,6 +184,7 @@ public function maxMediaCount(): int
self::MastodonPost => 4,
self::TelegramPost => 10,
self::DiscordMessage => 10,
self::RedditPost => 10,
};
}

Expand All @@ -200,6 +206,7 @@ public function supportsVideo(): bool
self::MastodonPost => true,
self::TelegramPost => true,
self::DiscordMessage => true,
self::RedditPost => false,
};
}

Expand Down Expand Up @@ -240,6 +247,7 @@ public function requiresMedia(): bool
self::FacebookPost => false,
self::InstagramFeed => false,
self::DiscordMessage => false,
self::RedditPost => false,
default => true,
};
}
Expand Down Expand Up @@ -322,6 +330,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::Mastodon => self::MastodonPost,
SocialPlatform::Telegram => self::TelegramPost,
SocialPlatform::Discord => self::DiscordMessage,
SocialPlatform::Reddit => self::RedditPost,
};
}
}
9 changes: 9 additions & 0 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum Platform: string
case Mastodon = 'mastodon';
case Telegram = 'telegram';
case Discord = 'discord';
case Reddit = 'reddit';

public function label(): string
{
Expand All @@ -40,6 +41,7 @@ public function label(): string
self::Mastodon => 'Mastodon',
self::Telegram => 'Telegram',
self::Discord => 'Discord',
self::Reddit => 'Reddit',
};
}

Expand All @@ -59,6 +61,7 @@ public function color(): string
self::Mastodon => '#6364FF',
self::Telegram => '#26A5E4',
self::Discord => '#5865F2',
self::Reddit => '#FF4500',
};
}

Expand All @@ -77,6 +80,7 @@ public function allowedMediaTypes(): array
self::Mastodon => [MediaType::Image, MediaType::Video],
self::Telegram => [MediaType::Image, MediaType::Video],
self::Discord => [MediaType::Image, MediaType::Video],
self::Reddit => [MediaType::Image],
};
}

Expand All @@ -95,6 +99,7 @@ public function maxImages(): int
self::Mastodon => 4,
self::Telegram => 10,
self::Discord => 10,
self::Reddit => 10,
};
}

Expand Down Expand Up @@ -135,6 +140,7 @@ public function maxContentLength(): int
self::Mastodon => 500,
self::Telegram => 4096,
self::Discord => 2000,
self::Reddit => 40000,
};
}

Expand Down Expand Up @@ -180,6 +186,7 @@ public function recommendedAiContentLength(): int
self::Telegram => 400,
// Discord — conversational community posts read best when concise
self::Discord => 280,
self::Reddit => 500,
};
}

Expand All @@ -203,6 +210,7 @@ public function requiredPublishScopes(): array
self::Mastodon => ['write:statuses'],
self::Telegram => [],
self::Discord => [],
self::Reddit => ['submit'],
};
}

Expand All @@ -221,6 +229,7 @@ public function supportsTextOnly(): bool
self::Mastodon => true,
self::Telegram => true,
self::Discord => true,
self::Reddit => true,
};
}

Expand Down
96 changes: 96 additions & 0 deletions app/Exceptions/Social/RedditPublishException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Social;

use Illuminate\Http\Client\Response;
use RuntimeException;
use Throwable;

class RedditPublishException extends SocialPublishException
{
public function __construct(
string $userMessage,
ErrorCategory $category,
?string $platformErrorCode = null,
?string $rawResponse = null,
?Throwable $previous = null,
) {
$this->userMessage = $userMessage;
$this->category = $category;
$this->platformErrorCode = $platformErrorCode;
$this->rawResponse = $rawResponse;

RuntimeException::__construct($userMessage, 0, $previous);
}

public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$status = $response->status();
$rawResponse = $response->body();
$json = $response->json() ?? [];

if ($status === 401 || $status === 403) {
return new static(
userMessage: 'Reddit rejected the request. Check that the account is connected and has permission to post.',
category: ErrorCategory::Permission,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}

if ($status === 429) {
return new static(
userMessage: 'Reddit rate limit reached. Please try again shortly.',
category: ErrorCategory::RateLimit,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}

if ($status >= 500) {
return new static(
userMessage: 'Reddit is temporarily unavailable. Please try again later.',
category: ErrorCategory::ServerError,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}

$message = self::extractApiMessage($json, $status);

return new static(
userMessage: $message,
category: ErrorCategory::Unknown,
platformErrorCode: (string) $status,
rawResponse: $rawResponse,
);
}

/**
* @param array<string, mixed> $json
*/
private static function extractApiMessage(array $json, int $status): string
{
foreach ([
data_get($json, 'json.errors.0.1'),
data_get($json, 'errors.0.1'),
data_get($json, 'explanation'),
data_get($json, 'reason'),
data_get($json, 'message'),
] as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}

return "An unknown Reddit error occurred (HTTP {$status}).";
}

public function platform(): string
{
return 'reddit';
}
}
3 changes: 3 additions & 0 deletions app/Http/Controllers/App/AnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Services\Social\InstagramAnalytics;
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\Reddit\RedditAnalytics;
use App\Services\Social\Telegram\TelegramAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
Expand All @@ -36,6 +37,7 @@ class AnalyticsController extends Controller
Platform::Pinterest,
Platform::YouTube,
Platform::Telegram,
Platform::Reddit,
];

public function index(Request $request): Response
Expand Down Expand Up @@ -80,6 +82,7 @@ public function show(Request $request, SocialAccount $account): JsonResponse
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
Platform::Reddit => app(RedditAnalytics::class)->getMetrics($account),
default => [],
};

Expand Down
58 changes: 58 additions & 0 deletions app/Http/Controllers/App/RedditController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\App;

use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Services\Social\Reddit\RedditClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RedditController extends Controller
{
public function __construct(private readonly RedditClient $client) {}

public function subreddits(Request $request, SocialAccount $account): JsonResponse
{
$this->authorizeRedditAccount($request, $account);

$query = trim((string) $request->query('q', ''));

return response()->json([
'data' => $query === '' ? [] : rescue(
fn () => $this->client->searchSubreddits($account, $query),
[],
report: false,
),
]);
}

public function restrictions(Request $request, SocialAccount $account, string $subreddit): JsonResponse
{
$this->authorizeRedditAccount($request, $account);

return response()->json([
'data' => rescue(
fn () => $this->client->restrictions($account, $subreddit),
['allowed_types' => ['self', 'link', 'image'], 'flair_required' => false, 'flairs' => []],
report: false,
),
]);
}

private function authorizeRedditAccount(Request $request, SocialAccount $account): void
{
$workspace = $request->user()->currentWorkspace;

abort_unless(
$workspace && $account->workspace_id === $workspace->id && $account->platform === SocialPlatform::Reddit,
Response::HTTP_FORBIDDEN,
);

$this->authorize('view', $workspace);
}
}
38 changes: 38 additions & 0 deletions app/Http/Controllers/Auth/RedditController.php
Original file line number Diff line number Diff line change
@@ -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 RedditController extends SocialController
{
protected string $driver = 'reddit';

protected SocialPlatform $platform = SocialPlatform::Reddit;

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.reddit.scopes'));
}

public function callback(Request $request): View
{
return $this->handleCallback($request, $this->platform, $this->driver);
}
}
Loading
Loading