From 63f393a07008b430f4b99a282d6ffc46a0eed94f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:07:57 -0300 Subject: [PATCH 01/34] feat(reddit): register reddit platform enum --- app/Enums/SocialAccount/Platform.php | 9 +++++++++ tests/Unit/Enums/PlatformTest.php | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 0d645c97..7cd2fe6b 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -22,6 +22,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case Reddit = 'reddit'; public function label(): string { @@ -40,6 +41,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::Reddit => 'Reddit', }; } @@ -59,6 +61,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::Reddit => '#FF4500', }; } @@ -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, MediaType::Video], }; } @@ -95,6 +99,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::Reddit => 20, }; } @@ -135,6 +140,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::Reddit => 40000, }; } @@ -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, }; } @@ -203,6 +210,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::Reddit => ['submit'], }; } @@ -221,6 +229,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::Reddit => true, }; } diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index d28c3ed4..23adfe9f 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -108,3 +108,17 @@ expect($enabled)->not->toContain(Platform::LinkedIn); }); + +test('reddit platform exposes its metadata', function () { + expect(Platform::Reddit->value)->toBe('reddit') + ->and(Platform::Reddit->label())->toBe('Reddit') + ->and(Platform::Reddit->color())->toBe('#FF4500') + ->and(Platform::Reddit->allowedMediaTypes())->toBe([MediaType::Image, MediaType::Video]) + ->and(Platform::Reddit->maxImages())->toBe(20) + ->and(Platform::Reddit->maxContentLength())->toBe(40000) + ->and(Platform::Reddit->recommendedAiContentLength())->toBe(500) + ->and(Platform::Reddit->requiredPublishScopes())->toBe(['submit']) + ->and(Platform::Reddit->supportsTextOnly())->toBeTrue() + ->and(Platform::Reddit->requiresContent())->toBeFalse() + ->and(Platform::Reddit->queue())->toBe('social-reddit'); +}); From a742840cfec1dd2095196f0265d44fbc2e054712 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:11:17 -0300 Subject: [PATCH 02/34] feat(reddit): register reddit_post content type --- app/Enums/PostPlatform/ContentType.php | 8 ++++++++ tests/Unit/Enums/ContentTypeTest.php | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0c525df9..b544608b 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -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. @@ -85,6 +88,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::RedditPost => 'Post', }; } @@ -109,6 +113,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::RedditPost => SocialPlatform::Reddit, }; } @@ -179,6 +184,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::RedditPost => 20, }; } @@ -200,6 +206,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::RedditPost => true, }; } @@ -322,6 +329,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::Reddit => self::RedditPost, }; } } diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 7a771504..ee543985 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -142,3 +142,8 @@ expect(ContentType::BlueskyPost->supportsVideo())->toBeTrue(); expect(ContentType::MastodonPost->supportsVideo())->toBeTrue(); }); + +test('reddit post content type maps to reddit platform', function () { + expect(ContentType::RedditPost->value)->toBe('reddit_post') + ->and(ContentType::RedditPost->platform())->toBe(Platform::Reddit); +}); From 587b06c28b0ee33e7e6675320a7f7794a60a2419 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:12:33 -0300 Subject: [PATCH 03/34] feat(reddit): add reddit platform + oauth config --- .env.example | 8 ++++++++ config/services.php | 7 +++++++ config/trypost.php | 7 +++++++ 3 files changed, 22 insertions(+) diff --git a/.env.example b/.env.example index 235552aa..eaa4db94 100644 --- a/.env.example +++ b/.env.example @@ -169,6 +169,14 @@ 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_USER_AGENT="web:it.trypost:1.0 (by /u/trypost)" +REDDIT_SCOPES="identity,read,submit,flair,mysubreddits" + # AI Services OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/config/services.php b/config/services.php index e72632d6..6489cf2a 100644 --- a/config/services.php +++ b/config/services.php @@ -118,6 +118,13 @@ 'redirect' => env('DISCORD_CLIENT_REDIRECT'), ], + // Reddit + 'reddit' => [ + 'client_id' => env('REDDIT_CLIENT_ID'), + 'client_secret' => env('REDDIT_CLIENT_SECRET'), + 'redirect' => env('REDDIT_CLIENT_REDIRECT'), + ], + 'gtm' => [ 'id' => env('GTM_ID'), ], diff --git a/config/trypost.php b/config/trypost.php index 16bac3f5..b8c995f5 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -176,6 +176,13 @@ 'permissions' => env('DISCORD_PERMISSIONS', '248832'), 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('DISCORD_SCOPES', 'bot,identify,guilds'))))), ], + 'reddit' => [ + 'enabled' => env('REDDIT_ENABLED', true), + 'api' => env('REDDIT_API', 'https://oauth.reddit.com'), + 'oauth_api' => env('REDDIT_OAUTH_API', 'https://www.reddit.com/api/v1'), + 'user_agent' => env('REDDIT_USER_AGENT', 'web:it.trypost:1.0 (by /u/trypost)'), + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('REDDIT_SCOPES', 'identity,read,submit,flair,mysubreddits'))))), + ], ], ]; From 47e5093107be4610e32a66fe76f5d957530160bc Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:14:25 -0300 Subject: [PATCH 04/34] feat(reddit): add reddit socialite provider --- app/Providers/AppServiceProvider.php | 7 ++ app/Socialite/RedditProvider.php | 95 +++++++++++++++++++++ tests/Unit/Socialite/RedditProviderTest.php | 25 ++++++ 3 files changed, 127 insertions(+) create mode 100644 app/Socialite/RedditProvider.php create mode 100644 tests/Unit/Socialite/RedditProviderTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ea3193d..d40e6311 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -34,6 +34,7 @@ use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; +use App\Socialite\RedditProvider; use Carbon\CarbonImmutable; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\VerifyEmail; @@ -193,6 +194,12 @@ protected function configureSocialite(): void return Socialite::buildProvider(DiscordProvider::class, $config); }); + Socialite::extend('reddit', function ($app) { + $config = $app['config']['services.reddit']; + + return Socialite::buildProvider(RedditProvider::class, $config); + }); + Event::listen(SocialiteWasCalled::class, FacebookExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInExtendSocialite::class); Event::listen(SocialiteWasCalled::class, LinkedInPageExtendSocialite::class); diff --git a/app/Socialite/RedditProvider.php b/app/Socialite/RedditProvider.php new file mode 100644 index 00000000..1c55347b --- /dev/null +++ b/app/Socialite/RedditProvider.php @@ -0,0 +1,95 @@ +buildAuthUrlFromBase(config('trypost.platforms.reddit.oauth_api').'/authorize', $state); + } + + /** + * @return array + */ + protected function getCodeFields($state = null): array + { + return array_merge(parent::getCodeFields($state), [ + 'duration' => 'permanent', + ]); + } + + protected function getTokenUrl(): string + { + return config('trypost.platforms.reddit.oauth_api').'/access_token'; + } + + /** + * Reddit's token endpoint authenticates the CLIENT via Basic auth, so the + * code grant is sent with the app credentials, not a bearer token. + * + * @return array + */ + public function getAccessTokenResponse($code): array + { + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + 'auth' => [config('services.reddit.client_id'), config('services.reddit.client_secret')], + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => config('trypost.platforms.reddit.user_agent'), + ], + 'form_params' => [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectUrl, + ], + ]); + + return json_decode((string) $response->getBody(), true); + } + + /** + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get(config('trypost.platforms.reddit.api').'/api/v1/me', [ + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'User-Agent' => config('trypost.platforms.reddit.user_agent'), + ], + ]); + + return (array) json_decode((string) $response->getBody(), true); + } + + /** + * @param array $user + */ + protected function mapUserToObject(array $user): User + { + $icon = (string) data_get($user, 'icon_img', ''); + $icon = $icon !== '' ? strtok($icon, '?') : null; + + return (new User)->setRaw($user)->map([ + 'id' => data_get($user, 'id'), + 'nickname' => data_get($user, 'name'), + 'name' => data_get($user, 'name'), + 'avatar' => $icon, + ]); + } +} diff --git a/tests/Unit/Socialite/RedditProviderTest.php b/tests/Unit/Socialite/RedditProviderTest.php new file mode 100644 index 00000000..1219b31f --- /dev/null +++ b/tests/Unit/Socialite/RedditProviderTest.php @@ -0,0 +1,25 @@ + 'https://www.reddit.com/api/v1', + 'services.reddit.client_id' => 'cid', + 'services.reddit.redirect' => 'https://trypost.test/accounts/reddit/callback', + ]); + + $request = Request::create('/connect/reddit', 'GET'); + $request->setLaravelSession(app('session.store')); + + $provider = new RedditProvider($request, 'cid', 'secret', 'https://trypost.test/accounts/reddit/callback'); + $url = $provider->scopes(['identity', 'submit'])->redirect()->getTargetUrl(); + + expect($url)->toContain('https://www.reddit.com/api/v1/authorize') + ->toContain('duration=permanent') + ->toContain('client_id=cid') + ->toContain('scope=identity'); +}); From a2409d10558a4451dd6c8e6871241ba391d961fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:19:16 -0300 Subject: [PATCH 05/34] feat(reddit): oauth connect + callback --- .../Controllers/Auth/RedditController.php | 38 +++++++++ database/factories/SocialAccountFactory.php | 14 ++++ routes/app.php | 4 + tests/Feature/Social/RedditConnectTest.php | 79 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 app/Http/Controllers/Auth/RedditController.php create mode 100644 tests/Feature/Social/RedditConnectTest.php diff --git a/app/Http/Controllers/Auth/RedditController.php b/app/Http/Controllers/Auth/RedditController.php new file mode 100644 index 00000000..b23338e8 --- /dev/null +++ b/app/Http/Controllers/Auth/RedditController.php @@ -0,0 +1,38 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + if (! $workspace) { + return redirect()->route('app.workspaces.create'); + } + + $this->authorize('manageAccounts', $workspace); + + return $this->redirectToProvider($request, $this->driver, config('trypost.platforms.reddit.scopes')); + } + + public function callback(Request $request): View + { + return $this->handleCallback($request, $this->platform, $this->driver); + } +} diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 927aba12..311412f5 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -154,6 +154,20 @@ public function telegram(): static ]); } + public function reddit(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Reddit, + 'scopes' => Platform::Reddit->requiredPublishScopes(), + 'platform_user_id' => (string) $this->faker->numberBetween(100000, 999999), + 'username' => $this->faker->userName(), + 'display_name' => $this->faker->name(), + 'access_token' => 'reddit-access-token', + 'refresh_token' => 'reddit-refresh-token', + 'token_expires_at' => now()->addHour(), + ]); + } + public function discord(): static { return $this->state(fn (array $attributes) => [ diff --git a/routes/app.php b/routes/app.php index 875748dc..54653f08 100644 --- a/routes/app.php +++ b/routes/app.php @@ -38,6 +38,7 @@ use App\Http\Controllers\Auth\LinkedInPageController; use App\Http\Controllers\Auth\MastodonController; use App\Http\Controllers\Auth\PinterestController; +use App\Http\Controllers\Auth\RedditController; use App\Http\Controllers\Auth\SocialController; use App\Http\Controllers\Auth\TelegramController; use App\Http\Controllers\Auth\ThreadsController; @@ -125,6 +126,9 @@ 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'); + + Route::get('connect/reddit', [RedditController::class, 'connect'])->name('app.social.reddit.connect'); + Route::get('accounts/reddit/callback', [RedditController::class, 'callback'])->name('app.social.reddit.callback'); }); // Routes that require active subscription and completed onboarding diff --git a/tests/Feature/Social/RedditConnectTest.php b/tests/Feature/Social/RedditConnectTest.php new file mode 100644 index 00000000..1b1ff828 --- /dev/null +++ b/tests/Feature/Social/RedditConnectTest.php @@ -0,0 +1,79 @@ +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('reddit connect redirects to the oauth provider', function () { + $driverMock = Mockery::mock(); + $driverMock->shouldReceive('scopes')->andReturnSelf(); + $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ + 'getTargetUrl' => 'https://www.reddit.com/api/v1/authorize?test=1', + ])); + + Socialite::shouldReceive('driver')->with('reddit')->andReturn($driverMock); + + $this->actingAs($this->user) + ->withHeader('X-Inertia', 'true') + ->get(route('app.social.reddit.connect')) + ->assertStatus(409); + + expect(session('social_connect_workspace'))->toBe($this->workspace->id); +}); + +test('reddit oauth callback creates the account', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->shouldReceive('getId')->andReturn('t2_abc123'); + $socialiteUser->shouldReceive('getNickname')->andReturn('redditor_name'); + $socialiteUser->shouldReceive('getName')->andReturn('Redditor Name'); + $socialiteUser->shouldReceive('getAvatar')->andReturn(null); + $socialiteUser->token = 'reddit-access-token'; + $socialiteUser->refreshToken = 'reddit-refresh-token'; + $socialiteUser->expiresIn = 3600; + $socialiteUser->approvedScopes = ['submit']; + + Socialite::shouldReceive('driver')->with('reddit')->andReturn(Mockery::mock(['user' => $socialiteUser])); + + $response = $this->actingAs($this->user)->get(route('app.social.reddit.callback')); + + $response->assertOk(); + $response->assertViewHas('success', true); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Reddit->value, + 'platform_user_id' => 't2_abc123', + 'status' => Status::Connected->value, + ]); +}); + +test('reddit callback fails gracefully on socialite error', function () { + session(['social_connect_workspace' => $this->workspace->id]); + + $mock = Mockery::mock(); + $mock->shouldReceive('user')->andThrow(new RuntimeException('Reddit OAuth failed.')); + + Socialite::shouldReceive('driver')->with('reddit')->andReturn($mock); + + $response = $this->actingAs($this->user)->get(route('app.social.reddit.callback')); + + $response->assertOk(); + $response->assertViewHas('success', false); + + expect($this->workspace->socialAccounts()->where('platform', Platform::Reddit)->count())->toBe(0); +}); From 9d460702861f1bb46a898b21d72b851713e88d53 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:22:57 -0300 Subject: [PATCH 06/34] feat(reddit): token verify + refresh --- app/Services/Social/ConnectionVerifier.php | 40 +++++++++ .../Social/ConnectionVerifierRedditTest.php | 87 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 tests/Feature/Services/Social/ConnectionVerifierRedditTest.php diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 92f62433..fd42fa36 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -70,6 +70,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::Reddit => $this->verifyReddit($account), }; } @@ -103,6 +104,7 @@ public function refreshToken(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), + Platform::Reddit => $this->refreshRedditToken($account), // Facebook / InstagramFacebook use Page tokens that don't expire. // Mastodon tokens don't expire either. default => null, @@ -537,4 +539,42 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } + + private function verifyReddit(SocialAccount $account): bool + { + $response = Http::withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('trypost.platforms.reddit.user_agent')]) + ->get(config('trypost.platforms.reddit.api').'/api/v1/me'); + + if (in_array($response->status(), [401, 403], true)) { + throw new TokenExpiredException('Reddit access token is invalid or expired'); + } + + return $response->successful(); + } + + private function refreshRedditToken(SocialAccount $account): void + { + if (! $account->refresh_token) { + throw new TokenExpiredException('No refresh token available for Reddit account'); + } + + $response = TokenRefreshClient::for(Platform::Reddit)->send(fn () => Http::asForm() + ->withBasicAuth((string) config('services.reddit.client_id'), (string) config('services.reddit.client_secret')) + ->withHeaders(['User-Agent' => (string) config('trypost.platforms.reddit.user_agent')]) + ->post(config('trypost.platforms.reddit.oauth_api').'/access_token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); + + $data = $response->json(); + + $account->update([ + 'access_token' => data_get($data, 'access_token'), + 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), + 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds((int) data_get($data, 'expires_in')) : null, + ]); + + $account->refresh(); + } } diff --git a/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php b/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php new file mode 100644 index 00000000..319f94f2 --- /dev/null +++ b/tests/Feature/Services/Social/ConnectionVerifierRedditTest.php @@ -0,0 +1,87 @@ + Http::response(['name' => 'testuser'], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + $result = (new ConnectionVerifier)->verify($account); + + expect($result)->toBeTrue(); + Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v1/me')); +}); + +test('throws TokenExpiredException when reddit verify returns 401', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/api/v1/me' => Http::response([], 401), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->addDays(30), + ]); + + expect(fn () => (new ConnectionVerifier)->verify($account)) + ->toThrow(TokenExpiredException::class, 'Reddit access token is invalid or expired'); +}); + +test('refreshing a reddit token stores the new access token', function () { + Http::fake([ + config('trypost.platforms.reddit.oauth_api').'/access_token' => Http::response([ + 'access_token' => 'new-access', + 'expires_in' => 3600, + ], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'access_token' => 'old-access', + 'refresh_token' => 'reddit-refresh-token', + ]); + + (new ConnectionVerifier)->refreshToken($account); + + expect($account->fresh()->access_token)->toBe('new-access'); +}); + +test('refreshes reddit token before verifying when expired', function () { + Http::fake([ + config('trypost.platforms.reddit.oauth_api').'/access_token' => Http::response([ + 'access_token' => 'new-access', + 'expires_in' => 3600, + ], 200), + config('trypost.platforms.reddit.api').'/api/v1/me' => Http::response(['name' => 'testuser'], 200), + ]); + + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->subHour(), + 'access_token' => 'old-access', + 'refresh_token' => 'reddit-refresh-token', + ]); + + $result = (new ConnectionVerifier)->verify($account); + + expect($result)->toBeTrue(); + expect($account->fresh()->access_token)->toBe('new-access'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/access_token')); +}); + +test('throws TokenExpiredException when reddit has no refresh token', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'token_expires_at' => now()->subHour(), + 'refresh_token' => null, + ]); + + expect(fn () => (new ConnectionVerifier)->refreshToken($account)) + ->toThrow(TokenExpiredException::class, 'No refresh token available for Reddit account'); +}); From ba39808d57264397477cfe8054ee59ccdcc8cf24 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:25:16 -0300 Subject: [PATCH 07/34] feat(reddit): read-side api client (search, restrictions, flair, info) --- app/Services/Social/Reddit/RedditClient.php | 126 ++++++++++++++++++ .../Services/Social/RedditClientTest.php | 71 ++++++++++ 2 files changed, 197 insertions(+) create mode 100644 app/Services/Social/Reddit/RedditClient.php create mode 100644 tests/Feature/Services/Social/RedditClientTest.php diff --git a/app/Services/Social/Reddit/RedditClient.php b/app/Services/Social/Reddit/RedditClient.php new file mode 100644 index 00000000..bf324c8b --- /dev/null +++ b/app/Services/Social/Reddit/RedditClient.php @@ -0,0 +1,126 @@ + + */ + public function searchSubreddits(SocialAccount $account, string $query): array + { + $response = $this->reddit($account)->get($this->url('/subreddits/search'), [ + 'q' => $query, + 'show' => 'public', + 'sort' => 'activity', + 'show_users' => 'false', + 'limit' => 10, + ]); + + return collect(data_get($response->json(), 'data.children', [])) + ->map(fn ($child) => [ + 'name' => (string) data_get($child, 'data.display_name'), + 'title' => (string) data_get($child, 'data.title'), + 'subscribers' => (int) data_get($child, 'data.subscribers', 0), + 'over_18' => (bool) data_get($child, 'data.over18', false), + ]) + ->filter(fn ($sub) => $sub['name'] !== '') + ->values() + ->all(); + } + + /** + * @return array{allowed_types: list, flair_required: bool, flairs: list} + */ + public function restrictions(SocialAccount $account, string $subreddit): array + { + $about = $this->reddit($account)->get($this->url("/r/{$subreddit}/about"))->json(); + $submissionType = (string) data_get($about, 'data.submission_type', 'any'); + $allowImages = (bool) data_get($about, 'data.allow_images', true); + + $allowedTypes = match ($submissionType) { + 'self' => ['self'], + 'link' => ['link'], + default => ['self', 'link'], + }; + + if ($allowImages) { + $allowedTypes = array_merge($allowedTypes, ['image', 'video', 'gallery']); + } + + $flairRequired = (bool) data_get( + $this->reddit($account)->get($this->url("/api/v1/{$subreddit}/post_requirements"))->json(), + 'is_flair_required', + false + ); + + return [ + 'allowed_types' => $allowedTypes, + 'flair_required' => $flairRequired, + 'flairs' => $this->flairs($account, $subreddit), + ]; + } + + /** + * @return list + */ + public function flairs(SocialAccount $account, string $subreddit): array + { + try { + return collect($this->reddit($account)->get($this->url("/r/{$subreddit}/api/link_flair_v2"))->json()) + ->map(fn ($flair) => [ + 'id' => (string) data_get($flair, 'id'), + 'text' => (string) data_get($flair, 'text'), + ]) + ->filter(fn ($flair) => $flair['id'] !== '') + ->values() + ->all(); + } catch (Throwable) { + return []; + } + } + + /** + * @param list $fullnames Reddit fullnames, e.g. ['t3_abc123']. + * @return array + */ + public function info(SocialAccount $account, array $fullnames): array + { + if ($fullnames === []) { + return []; + } + + $response = $this->reddit($account)->get($this->url('/api/info'), ['id' => implode(',', $fullnames)]); + + return collect(data_get($response->json(), 'data.children', [])) + ->mapWithKeys(fn ($child) => [ + (string) data_get($child, 'data.name') => [ + 'score' => (int) data_get($child, 'data.score', 0), + 'num_comments' => (int) data_get($child, 'data.num_comments', 0), + 'url' => (string) data_get($child, 'data.url', ''), + ], + ]) + ->all(); + } + + private function url(string $path): string + { + return (string) config('trypost.platforms.reddit.api').$path; + } + + private function reddit(SocialAccount $account): PendingRequest + { + return $this->socialHttp() + ->withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('trypost.platforms.reddit.user_agent')]); + } +} diff --git a/tests/Feature/Services/Social/RedditClientTest.php b/tests/Feature/Services/Social/RedditClientTest.php new file mode 100644 index 00000000..d7b932fe --- /dev/null +++ b/tests/Feature/Services/Social/RedditClientTest.php @@ -0,0 +1,71 @@ + 'https://oauth.reddit.com', + 'trypost.platforms.reddit.user_agent' => 'web:it.trypost:1.0', + ]); + $this->account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => Workspace::factory()->create()->id, + 'access_token' => 'tok', + ]); +}); + +test('searchSubreddits returns names from the reddit search endpoint', function () { + Http::fake([ + 'https://oauth.reddit.com/subreddits/search*' => Http::response([ + 'data' => ['children' => [ + ['data' => ['display_name' => 'AskReddit', 'title' => 'Ask Reddit', 'subscribers' => 100, 'over18' => false, 'subreddit_type' => 'public']], + ['data' => ['display_name' => 'pics', 'title' => 'Pics', 'subscribers' => 50, 'over18' => false, 'subreddit_type' => 'public']], + ]], + ], 200), + ]); + + $results = app(RedditClient::class)->searchSubreddits($this->account, 'ask'); + + expect($results)->toHaveCount(2) + ->and($results[0]['name'])->toBe('AskReddit'); +}); + +test('restrictions reports submission type, image allowance and required flair', function () { + Http::fake([ + 'https://oauth.reddit.com/r/AskReddit/about*' => Http::response([ + 'data' => ['submission_type' => 'self', 'allow_images' => false], + ], 200), + 'https://oauth.reddit.com/api/v1/AskReddit/post_requirements*' => Http::response([ + 'is_flair_required' => true, + ], 200), + 'https://oauth.reddit.com/r/AskReddit/api/link_flair_v2*' => Http::response([ + ['id' => 'abc', 'text' => 'Discussion'], + ], 200), + ]); + + $restrictions = app(RedditClient::class)->restrictions($this->account, 'AskReddit'); + + expect($restrictions['allowed_types'])->toBe(['self']) + ->and($restrictions['flair_required'])->toBeTrue() + ->and($restrictions['flairs'][0]['id'])->toBe('abc'); +}); + +test('info sums nothing for empty fullnames and maps children otherwise', function () { + Http::fake([ + 'https://oauth.reddit.com/api/info*' => Http::response([ + 'data' => ['children' => [ + ['data' => ['name' => 't3_abc', 'score' => 12, 'num_comments' => 4, 'url' => 'https://www.reddit.com/r/x/comments/abc/y/']], + ]], + ], 200), + ]); + + expect(app(RedditClient::class)->info($this->account, []))->toBe([]); + + $info = app(RedditClient::class)->info($this->account, ['t3_abc']); + expect($info['t3_abc']['score'])->toBe(12) + ->and($info['t3_abc']['num_comments'])->toBe(4); +}); From 6129d95000c8360fe6c0322958186702048e39a0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:27:50 -0300 Subject: [PATCH 08/34] feat(reddit): publish exception --- .../Social/RedditPublishException.php | 83 ++++++++++++++++ .../Social/RedditPublishExceptionTest.php | 94 +++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 app/Exceptions/Social/RedditPublishException.php create mode 100644 tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php diff --git a/app/Exceptions/Social/RedditPublishException.php b/app/Exceptions/Social/RedditPublishException.php new file mode 100644 index 00000000..72952c10 --- /dev/null +++ b/app/Exceptions/Social/RedditPublishException.php @@ -0,0 +1,83 @@ +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() ?? []; + + $errors = data_get($json, 'errors', []); + $apiMessage = is_array($errors) && count($errors) > 0 + ? (string) data_get($errors, '0.1', '') + : ''; + + 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 = $apiMessage !== '' + ? $apiMessage + : "An unknown Reddit error occurred (HTTP {$status})."; + + return new static( + userMessage: $message, + category: ErrorCategory::Unknown, + platformErrorCode: (string) $status, + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'reddit'; + } +} diff --git a/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php b/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php new file mode 100644 index 00000000..52b22285 --- /dev/null +++ b/tests/Unit/Exceptions/Social/RedditPublishExceptionTest.php @@ -0,0 +1,94 @@ + 'Unauthorized'], 401); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Reddit rejected the request. Check that the account is connected and has permission to post.') + ->and($exception->platformErrorCode)->toBe('401'); +}); + +test('HTTP 403 maps to Permission category', function () { + $response = Http::response(['message' => 'Forbidden'], 403); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->platformErrorCode)->toBe('403'); +}); + +test('HTTP 429 maps to RateLimit category', function () { + $response = Http::response([], 429); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->userMessage)->toBe('Reddit rate limit reached. Please try again shortly.') + ->and($exception->platformErrorCode)->toBe('429'); +}); + +test('HTTP 500 maps to ServerError category', function () { + $response = Http::response([], 500); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Reddit is temporarily unavailable. Please try again later.') + ->and($exception->platformErrorCode)->toBe('500'); +}); + +test('json errors array uses first error human message', function () { + $response = Http::response([ + 'jquery' => [], + 'errors' => [['SUBREDDIT_NOEXIST', 'that subreddit does not exist', 'sr']], + ], 200); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('that subreddit does not exist'); +}); + +test('empty errors array falls back to generic message', function () { + $response = Http::response(['jquery' => [], 'errors' => []], 200); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->userMessage)->toBe('An unknown Reddit error occurred (HTTP 200).'); +}); + +test('previous throwable is forwarded', function () { + $previous = new RuntimeException('original cause'); + $exception = new RedditPublishException( + userMessage: 'something failed', + category: ErrorCategory::Unknown, + previous: $previous, + ); + + expect($exception->getPrevious())->toBe($previous) + ->and($exception->getMessage())->toBe('something failed'); +}); + +test('platform returns reddit', function () { + $response = Http::response(['errors' => []], 400); + $fakeResponse = Http::fake(['*' => $response])->post('https://oauth.reddit.com/api/submit'); + + $exception = RedditPublishException::fromApiResponse($fakeResponse); + + expect($exception->platform())->toBe('reddit'); +}); From bb545f79bbfeb1c45725547f2cb3cab7b67962e8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:30:56 -0300 Subject: [PATCH 09/34] feat(reddit): publisher for self/link + multi-subreddit + partial failure --- .../Social/Reddit/RedditPublisher.php | 192 ++++++++++++++++++ .../Services/Social/RedditPublisherTest.php | 111 ++++++++++ 2 files changed, 303 insertions(+) create mode 100644 app/Services/Social/Reddit/RedditPublisher.php create mode 100644 tests/Feature/Services/Social/RedditPublisherTest.php diff --git a/app/Services/Social/Reddit/RedditPublisher.php b/app/Services/Social/Reddit/RedditPublisher.php new file mode 100644 index 00000000..61ec0e75 --- /dev/null +++ b/app/Services/Social/Reddit/RedditPublisher.php @@ -0,0 +1,192 @@ +socialAccount; + + $subreddits = collect((array) data_get($postPlatform->meta, 'subreddits', [])) + ->filter(fn ($s) => filled(data_get($s, 'name'))) + ->values(); + + if ($subreddits->isEmpty()) { + throw new RedditPublishException( + userMessage: 'No subreddit selected for this Reddit post.', + category: ErrorCategory::Unknown, + ); + } + + $text = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : ''; + + /** @var list $results */ + $results = []; + + foreach ($subreddits as $index => $sub) { + if ($index > 0) { + usleep(self::SUBMIT_DELAY_MICROSECONDS); + } + + try { + $results[] = $this->submitOne($account, $sub, $text); + } catch (Throwable $e) { + $this->persistResults($postPlatform, $results); + + throw new RedditPublishException( + userMessage: 'Published to '.count($results).' subreddit(s); failed on r/'.data_get($sub, 'name').': '.$e->getMessage(), + category: ErrorCategory::Unknown, + previous: $e, + ); + } + } + + $this->persistResults($postPlatform, $results); + + return [ + 'id' => implode(',', array_column($results, 'id')), + 'url' => implode(',', array_filter(array_column($results, 'url'))), + ]; + } + + /** + * @param array $sub + * @return array{subreddit: string, id: string, url: string} + */ + private function submitOne(SocialAccount $account, array $sub, string $text): array + { + $name = (string) data_get($sub, 'name'); + $type = (string) data_get($sub, 'type', 'self'); + + $payload = array_filter([ + 'api_type' => 'json', + 'sr' => $name, + 'title' => (string) data_get($sub, 'title'), + 'kind' => $this->kind($type), + 'nsfw' => (bool) data_get($sub, 'nsfw', false) ? 'true' : null, + 'spoiler' => (bool) data_get($sub, 'spoiler', false) ? 'true' : null, + 'flair_id' => data_get($sub, 'flair_id') ?: null, + 'flair_text' => data_get($sub, 'flair_text') ?: null, + ], fn ($v) => $v !== null); + + if ($type === 'self') { + $payload['text'] = $text; + } elseif ($type === 'link') { + $payload['url'] = (string) data_get($sub, 'url'); + } else { + $payload['url'] = $this->uploadMedia($account, $sub); + } + + $response = $this->reddit($account)->asForm()->post($this->url('/api/submit'), $payload); + + $this->assertOk($response); + + $id = (string) data_get($response->json(), 'json.data.id'); + $fullname = (string) data_get($response->json(), 'json.data.name'); + $fullname = $fullname !== '' ? $fullname : ($id !== '' ? "t3_{$id}" : ''); + + return [ + 'subreddit' => $name, + 'id' => $fullname, + 'url' => $this->resolveUrl($account, $fullname), + ]; + } + + /** + * @param array $sub + */ + private function uploadMedia(SocialAccount $account, array $sub): string + { + throw new RedditPublishException( + userMessage: 'Reddit media upload is not available yet.', + category: ErrorCategory::MediaFormat, + ); + } + + private function resolveUrl(SocialAccount $account, string $fullname): string + { + if ($fullname === '') { + return ''; + } + + return (string) data_get($this->client->info($account, [$fullname]), "{$fullname}.url", ''); + } + + private function kind(string $type): string + { + return match ($type) { + 'link' => 'link', + 'image' => 'image', + 'video' => 'video', + 'gallery' => 'image', + default => 'self', + }; + } + + private function assertOk(Response $response): void + { + if ($response->failed()) { + throw RedditPublishException::fromApiResponse($response); + } + + $errors = (array) data_get($response->json(), 'json.errors', []); + + if ($errors !== []) { + throw new RedditPublishException( + userMessage: (string) (data_get($errors, '0.1') ?: 'Reddit rejected the submission.'), + category: ErrorCategory::Unknown, + ); + } + } + + /** + * @param list $results + */ + private function persistResults(PostPlatform $postPlatform, array $results): void + { + $postPlatform->update(['meta' => array_merge((array) $postPlatform->meta, ['results' => $results])]); + } + + private function url(string $path): string + { + return (string) config('trypost.platforms.reddit.api').$path; + } + + private function reddit(SocialAccount $account): PendingRequest + { + return $this->socialHttp() + ->withToken((string) $account->access_token) + ->withHeaders(['User-Agent' => (string) config('trypost.platforms.reddit.user_agent')]); + } +} diff --git a/tests/Feature/Services/Social/RedditPublisherTest.php b/tests/Feature/Services/Social/RedditPublisherTest.php new file mode 100644 index 00000000..7e0df703 --- /dev/null +++ b/tests/Feature/Services/Social/RedditPublisherTest.php @@ -0,0 +1,111 @@ + 'https://oauth.reddit.com', + 'trypost.platforms.reddit.user_agent' => 'web:it.trypost:1.0', + ]); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'access_token' => 'tok', + ]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Hello Reddit', + ]); +}); + +function redditPlatform(array $subreddits): PostPlatform +{ + return PostPlatform::factory()->create([ + 'post_id' => test()->post->id, + 'social_account_id' => test()->account->id, + 'platform' => Platform::Reddit, + 'content_type' => ContentType::RedditPost, + 'enabled' => true, + 'meta' => ['subreddits' => $subreddits], + ]); +} + +test('publishes a self post and resolves the url via info', function () { + Http::fake([ + 'https://oauth.reddit.com/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_abc', 'id' => 'abc']]], 200), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => [ + ['data' => ['name' => 't3_abc', 'url' => 'https://www.reddit.com/r/test/comments/abc/x/']], + ]]], 200), + ]); + + $platform = redditPlatform([['name' => 'test', 'title' => 'My title', 'type' => 'self']]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('abc')->and($result['url'])->toContain('reddit.com'); + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && $r['kind'] === 'self' && $r['sr'] === 'test' && $r['title'] === 'My title'); +}); + +test('submits a link post with the url', function () { + Http::fake([ + 'https://oauth.reddit.com/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_l', 'id' => 'l']]], 200), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => 'link', 'url' => 'https://example.com']]); + app(RedditPublisher::class)->publish($platform); + + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && $r['kind'] === 'link' && $r['url'] === 'https://example.com'); +}); + +test('submits to multiple subreddits and aggregates ids', function () { + Http::fake([ + 'https://oauth.reddit.com/api/submit*' => Http::sequence() + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_one', 'id' => 'one']]]) + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_two', 'id' => 'two']]]), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([ + ['name' => 'a', 'title' => 'T', 'type' => 'self'], + ['name' => 'b', 'title' => 'T', 'type' => 'self'], + ]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('one')->toContain('two'); +}); + +test('records partial failure when a later subreddit fails', function () { + Http::fake([ + 'https://oauth.reddit.com/api/submit*' => Http::sequence() + ->push(['json' => ['errors' => [], 'data' => ['name' => 't3_one', 'id' => 'one']]]) + ->push(['json' => ['errors' => [['SUBREDDIT_NOEXIST', 'that subreddit does not exist', 'sr']]]], 200), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([ + ['name' => 'ok', 'title' => 'T', 'type' => 'self'], + ['name' => 'bad', 'title' => 'T', 'type' => 'self'], + ]); + + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); + expect(data_get($platform->fresh()->meta, 'results.0.id'))->toContain('one'); +}); + +test('throws when no subreddit is configured', function () { + $platform = redditPlatform([]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); From 982a1ba378ffaa5d8934250a92d3064b3b6e9e52 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:35:57 -0300 Subject: [PATCH 10/34] fix(reddit): guard empty title, correct gallery kind, cover meta fields + media throw --- .../Social/Reddit/RedditPublisher.php | 12 +++++-- .../Services/Social/RedditPublisherTest.php | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/app/Services/Social/Reddit/RedditPublisher.php b/app/Services/Social/Reddit/RedditPublisher.php index 61ec0e75..2e539ba9 100644 --- a/app/Services/Social/Reddit/RedditPublisher.php +++ b/app/Services/Social/Reddit/RedditPublisher.php @@ -88,11 +88,19 @@ private function submitOne(SocialAccount $account, array $sub, string $text): ar { $name = (string) data_get($sub, 'name'); $type = (string) data_get($sub, 'type', 'self'); + $title = trim((string) data_get($sub, 'title')); + + if ($title === '') { + throw new RedditPublishException( + userMessage: "A title is required to post to r/{$name}.", + category: ErrorCategory::Unknown, + ); + } $payload = array_filter([ 'api_type' => 'json', 'sr' => $name, - 'title' => (string) data_get($sub, 'title'), + 'title' => $title, 'kind' => $this->kind($type), 'nsfw' => (bool) data_get($sub, 'nsfw', false) ? 'true' : null, 'spoiler' => (bool) data_get($sub, 'spoiler', false) ? 'true' : null, @@ -149,7 +157,7 @@ private function kind(string $type): string 'link' => 'link', 'image' => 'image', 'video' => 'video', - 'gallery' => 'image', + 'gallery' => 'gallery', default => 'self', }; } diff --git a/tests/Feature/Services/Social/RedditPublisherTest.php b/tests/Feature/Services/Social/RedditPublisherTest.php index 7e0df703..eb4ac16b 100644 --- a/tests/Feature/Services/Social/RedditPublisherTest.php +++ b/tests/Feature/Services/Social/RedditPublisherTest.php @@ -109,3 +109,37 @@ function redditPlatform(array $subreddits): PostPlatform $platform = redditPlatform([]); expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); }); + +test('sends nsfw, spoiler and flair fields in the payload', function () { + Http::fake([ + 'https://oauth.reddit.com/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_abc', 'id' => 'abc']]], 200), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([[ + 'name' => 'test', + 'title' => 'T', + 'type' => 'self', + 'nsfw' => true, + 'spoiler' => true, + 'flair_id' => 'flair-123', + 'flair_text' => 'Discussion', + ]]); + app(RedditPublisher::class)->publish($platform); + + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') + && $r['nsfw'] === 'true' + && $r['spoiler'] === 'true' + && $r['flair_id'] === 'flair-123' + && $r['flair_text'] === 'Discussion'); +}); + +test('throws when a subreddit has no title', function () { + $platform = redditPlatform([['name' => 'test', 'type' => 'self']]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); + +test('throws on media types since upload is not yet supported', function () { + $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => 'image']]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +}); From e1241084a2250ec91a22dca0f879f03ee335316a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:41:06 -0300 Subject: [PATCH 11/34] feat(reddit): single-image upload via asset lease (video/gallery deferred) --- app/Services/Social/Reddit/RedditClient.php | 2 +- .../Social/Reddit/RedditPublisher.php | 60 +++++++++++++++++-- .../Services/Social/RedditClientTest.php | 13 ++++ .../Services/Social/RedditPublisherTest.php | 40 ++++++++++++- 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/app/Services/Social/Reddit/RedditClient.php b/app/Services/Social/Reddit/RedditClient.php index bf324c8b..507c6436 100644 --- a/app/Services/Social/Reddit/RedditClient.php +++ b/app/Services/Social/Reddit/RedditClient.php @@ -54,7 +54,7 @@ public function restrictions(SocialAccount $account, string $subreddit): array }; if ($allowImages) { - $allowedTypes = array_merge($allowedTypes, ['image', 'video', 'gallery']); + $allowedTypes[] = 'image'; } $flairRequired = (bool) data_get( diff --git a/app/Services/Social/Reddit/RedditPublisher.php b/app/Services/Social/Reddit/RedditPublisher.php index 2e539ba9..b6d3e25f 100644 --- a/app/Services/Social/Reddit/RedditPublisher.php +++ b/app/Services/Social/Reddit/RedditPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social\Reddit; +use App\DataTransferObjects\MediaItem; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\RedditPublishException; use App\Models\PostPlatform; @@ -12,6 +13,7 @@ use App\Services\Social\ContentSanitizer; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; +use Illuminate\Support\Facades\Http; use Throwable; /** @@ -27,6 +29,8 @@ class RedditPublisher private const SUBMIT_DELAY_MICROSECONDS = 1_100_000; + private ?MediaItem $currentMedia = null; + public function __construct(private readonly RedditClient $client) {} /** @@ -51,6 +55,8 @@ public function publish(PostPlatform $postPlatform): array ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : ''; + $this->currentMedia = $postPlatform->post->mediaItems->first(); + /** @var list $results */ $results = []; @@ -132,14 +138,62 @@ private function submitOne(SocialAccount $account, array $sub, string $text): ar } /** + * Uploads the post's first image through Reddit's asset lease and returns the + * hosted URL to submit as a link/image post. + * * @param array $sub */ private function uploadMedia(SocialAccount $account, array $sub): string { - throw new RedditPublishException( - userMessage: 'Reddit media upload is not available yet.', + $type = (string) data_get($sub, 'type'); + + if ($type !== 'image') { + throw new RedditPublishException( + userMessage: 'Reddit video and gallery posts are not supported yet.', + category: ErrorCategory::MediaFormat, + ); + } + + $item = $this->currentMedia ?? throw new RedditPublishException( + userMessage: 'No image attached for this Reddit image post.', category: ErrorCategory::MediaFormat, ); + + $mime = (string) ($item->mime_type ?: 'image/jpeg'); + $filename = $item->original_filename ?: (basename((string) $item->path) ?: 'image'); + + $lease = $this->reddit($account)->asForm()->post($this->url('/api/media/asset'), [ + 'filepath' => $filename, + 'mimetype' => $mime, + ]); + $this->assertOk($lease); + + $action = 'https:'.preg_replace('#^https?:#', '', (string) data_get($lease->json(), 'args.action')); + $fields = collect((array) data_get($lease->json(), 'args.fields')) + ->mapWithKeys(fn ($f) => [(string) data_get($f, 'name') => (string) data_get($f, 'value')]) + ->all(); + + $bytes = Http::timeout(120)->get($item->url)->body(); + + $upload = Http::asMultipart(); + foreach ($fields as $name => $value) { + $upload = $upload->attach($name, $value); + } + $upload = $upload->attach('file', $bytes, $filename); + $s3 = $upload->post($action); + + if ($s3->failed()) { + throw new RedditPublishException( + userMessage: 'Failed to upload the image to Reddit.', + category: ErrorCategory::MediaFormat, + ); + } + + if (preg_match('/(.*?)<\/Location>/', $s3->body(), $m)) { + return html_entity_decode($m[1]); + } + + return rtrim($action, '/').'/'.($fields['key'] ?? $filename); } private function resolveUrl(SocialAccount $account, string $fullname): string @@ -156,8 +210,6 @@ private function kind(string $type): string return match ($type) { 'link' => 'link', 'image' => 'image', - 'video' => 'video', - 'gallery' => 'gallery', default => 'self', }; } diff --git a/tests/Feature/Services/Social/RedditClientTest.php b/tests/Feature/Services/Social/RedditClientTest.php index d7b932fe..e2d898e0 100644 --- a/tests/Feature/Services/Social/RedditClientTest.php +++ b/tests/Feature/Services/Social/RedditClientTest.php @@ -54,6 +54,19 @@ ->and($restrictions['flairs'][0]['id'])->toBe('abc'); }); +test('restrictions adds image (not video or gallery) when images are allowed', function () { + Http::fake([ + 'https://oauth.reddit.com/r/pics/about*' => Http::response(['data' => ['submission_type' => 'any', 'allow_images' => true]], 200), + 'https://oauth.reddit.com/api/v1/pics/post_requirements*' => Http::response(['is_flair_required' => false], 200), + 'https://oauth.reddit.com/r/pics/api/link_flair_v2*' => Http::response([], 200), + ]); + + $r = app(RedditClient::class)->restrictions($this->account, 'pics'); + + expect($r['allowed_types'])->toContain('self')->toContain('link')->toContain('image') + ->not->toContain('video')->not->toContain('gallery'); +}); + test('info sums nothing for empty fullnames and maps children otherwise', function () { Http::fake([ 'https://oauth.reddit.com/api/info*' => Http::response([ diff --git a/tests/Feature/Services/Social/RedditPublisherTest.php b/tests/Feature/Services/Social/RedditPublisherTest.php index eb4ac16b..e8b1965b 100644 --- a/tests/Feature/Services/Social/RedditPublisherTest.php +++ b/tests/Feature/Services/Social/RedditPublisherTest.php @@ -139,7 +139,41 @@ function redditPlatform(array $subreddits): PostPlatform expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); }); -test('throws on media types since upload is not yet supported', function () { - $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => 'image']]); - expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); +test('throws for video and gallery since they are not yet supported', function () { + foreach (['video', 'gallery'] as $type) { + $platform = redditPlatform([['name' => 'test', 'title' => 'T', 'type' => $type]]); + expect(fn () => app(RedditPublisher::class)->publish($platform))->toThrow(RedditPublishException::class); + } +}); + +test('uploads an image then submits the asset url', function () { + test()->post->update([ + 'media' => [[ + 'id' => 'm1', + 'path' => 'media/2026-01/photo.jpg', + 'url' => 'https://cdn.test/photo.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'photo.jpg', + ]], + ]); + + Http::fake([ + 'https://oauth.reddit.com/api/media/asset*' => Http::response([ + 'args' => [ + 'action' => '//reddit-uploads.s3.amazonaws.com', + 'fields' => [['name' => 'key', 'value' => 'abc/photo.jpg'], ['name' => 'policy', 'value' => 'p']], + ], + 'asset' => ['asset_id' => 'a1'], + ], 200), + 'https://reddit-uploads.s3.amazonaws.com' => Http::response('https://reddit-uploads.s3.amazonaws.com/abc/photo.jpg', 201), + 'https://cdn.test/photo.jpg' => Http::response('binarybytes', 200), + 'https://oauth.reddit.com/api/submit*' => Http::response(['json' => ['errors' => [], 'data' => ['name' => 't3_img', 'id' => 'img']]], 200), + 'https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200), + ]); + + $platform = redditPlatform([['name' => 'pics', 'title' => 'My photo', 'type' => 'image']]); + $result = app(RedditPublisher::class)->publish($platform); + + expect($result['id'])->toContain('img'); + Http::assertSent(fn ($r) => str_contains($r->url(), '/api/submit') && isset($r['url']) && str_contains((string) $r['url'], 'photo.jpg')); }); From 2f373059270ceb75f43d2be3384bbd533feaf580 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:43:07 -0300 Subject: [PATCH 12/34] feat(reddit): dispatch reddit posts to RedditPublisher --- app/Jobs/PublishToSocialPlatform.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 5bf96054..0d2f1410 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -26,6 +26,7 @@ use App\Services\Social\LinkedInPublisher; use App\Services\Social\MastodonPublisher; use App\Services\Social\PinterestPublisher; +use App\Services\Social\Reddit\RedditPublisher; use App\Services\Social\Telegram\TelegramPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; @@ -223,7 +224,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|DiscordPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|RedditPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -239,6 +240,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::Reddit => app(RedditPublisher::class), }; } From 039983f0c93b5ae55d05678a95b8775bbf83d3cd Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:45:50 -0300 Subject: [PATCH 13/34] feat(reddit): per-platform meta rules + required-on-publish --- app/Support/PostPlatformMetaRules.php | 45 +++++++++++++++++++ database/factories/PostPlatformFactory.php | 8 ++++ tests/Feature/Api/PostApiPlatformMetaTest.php | 39 ++++++++++++++++ .../Feature/Mcp/PostPlatformMetaToolTest.php | 44 ++++++++++++++++++ 4 files changed, 136 insertions(+) diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index d7a12682..f408d989 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -71,6 +71,20 @@ public static function rules(): array '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}$/'], + + // Reddit + 'platforms.*.meta.subreddits' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.subreddits.*.name' => ['required', 'string'], + 'platforms.*.meta.subreddits.*.title' => ['required', 'string', 'max:300'], + 'platforms.*.meta.subreddits.*.type' => ['required', 'string', Rule::in(['self', 'link', 'image'])], + 'platforms.*.meta.subreddits.*.url' => ['sometimes', 'nullable', 'url', 'required_if:platforms.*.meta.subreddits.*.type,link'], + 'platforms.*.meta.subreddits.*.flair_id' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.subreddits.*.flair_text' => ['sometimes', 'nullable', 'string'], + 'platforms.*.meta.subreddits.*.flair_required' => ['sometimes', 'boolean'], + 'platforms.*.meta.subreddits.*.allowed_types' => ['sometimes', 'nullable', 'array'], + 'platforms.*.meta.subreddits.*.allowed_types.*' => ['string'], + 'platforms.*.meta.subreddits.*.nsfw' => ['sometimes', 'boolean'], + 'platforms.*.meta.subreddits.*.spoiler' => ['sometimes', 'boolean'], ]; } @@ -130,11 +144,42 @@ public static function assertStoredPostPublishable(Post $post): void */ private static function requiredMetaViolation(?Platform $platform, mixed $meta): ?array { + $reddit = $platform === Platform::Reddit ? self::redditMetaViolation($meta) : null; + return match (true) { + $reddit !== null => $reddit, $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, }; } + + /** + * @return array{0: string, 1: string}|null + */ + private static function redditMetaViolation(mixed $meta): ?array + { + $subreddits = (array) data_get($meta, 'subreddits', []); + + if ($subreddits === []) { + return ['subreddits', trans('posts.form.reddit.subreddit_required')]; + } + + foreach ($subreddits as $sub) { + if (blank(data_get($sub, 'title'))) { + return ['subreddits', trans('posts.form.reddit.title_required')]; + } + + if (data_get($sub, 'type') === 'link' && blank(data_get($sub, 'url'))) { + return ['subreddits', trans('posts.form.reddit.url_required')]; + } + + if (data_get($sub, 'flair_required') && blank(data_get($sub, 'flair_id'))) { + return ['subreddits', trans('posts.form.reddit.flair_required')]; + } + } + + return null; + } } diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index cb39e970..7cf8202c 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -163,6 +163,14 @@ public function discord(): static ]); } + public function reddit(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Reddit, + 'content_type' => ContentType::RedditPost, + ]); + } + public function facebookReel(): static { return $this->state(fn (array $attributes) => [ diff --git a/tests/Feature/Api/PostApiPlatformMetaTest.php b/tests/Feature/Api/PostApiPlatformMetaTest.php index 4865df41..5318b0a5 100644 --- a/tests/Feature/Api/PostApiPlatformMetaTest.php +++ b/tests/Feature/Api/PostApiPlatformMetaTest.php @@ -156,3 +156,42 @@ expect($platform->fresh()->meta['channel_id'])->toBe('444555666'); Queue::assertPushed(PublishPost::class); }); + +it('persists Reddit subreddits meta on store', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $this->withHeaders($this->headers) + ->postJson(route('api.posts.store'), [ + 'content' => 'Hello Reddit', + 'platforms' => [[ + 'social_account_id' => $account->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => ['subreddits' => [['name' => 'AskReddit', 'title' => 'My title', 'type' => 'self', 'nsfw' => false]]], + ]], + ]) + ->assertCreated(); + + $meta = PostPlatform::where('social_account_id', $account->id)->sole()->meta; + + expect(data_get($meta, 'subreddits.0.name'))->toBe('AskReddit') + ->and(data_get($meta, 'subreddits.0.title'))->toBe('My title'); +}); + +it('rejects publishing a Reddit post with no subreddit', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + $post = Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]); + $platform = PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => []], + ]); + + $this->withHeaders($this->headers) + ->putJson(route('api.posts.update', $post), [ + 'status' => PostStatus::Publishing->value, + 'platforms' => [['id' => $platform->id]], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['platforms.0.meta.subreddits']); +}); diff --git a/tests/Feature/Mcp/PostPlatformMetaToolTest.php b/tests/Feature/Mcp/PostPlatformMetaToolTest.php index 43f6ee26..69f13157 100644 --- a/tests/Feature/Mcp/PostPlatformMetaToolTest.php +++ b/tests/Feature/Mcp/PostPlatformMetaToolTest.php @@ -178,3 +178,47 @@ $response->assertOk(); Queue::assertPushed(PublishPost::class); }); + +test('create post persists Reddit subreddits meta', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Hello Reddit', + 'platforms' => [[ + 'social_account_id' => $account->id, + 'content_type' => ContentType::RedditPost->value, + 'meta' => [ + 'subreddits' => [['name' => 'AskReddit', 'title' => 'My title', 'type' => 'self', 'nsfw' => false]], + ], + ]], + ]); + + $response->assertOk(); + + $meta = PostPlatform::where('social_account_id', $account->id)->sole()->meta; + + expect(data_get($meta, 'subreddits.0.name'))->toBe('AskReddit') + ->and(data_get($meta, 'subreddits.0.title'))->toBe('My title'); +}); + +test('publish post rejects a Reddit platform without a subreddit', function () { + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->workspace->id]); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + PostPlatform::factory()->reddit()->create([ + 'post_id' => $post->id, + 'social_account_id' => $account->id, + 'enabled' => true, + 'meta' => ['subreddits' => []], + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(PublishPostTool::class, ['post_id' => $post->id]); + + $response->assertHasErrors([__('posts.form.reddit.subreddit_required')]); +}); From c15bb6b70b1ba91e88f8c49fa9236ac4500335ea Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:49:59 -0300 Subject: [PATCH 14/34] feat(reddit): subreddit search + restrictions endpoints --- app/Http/Controllers/App/RedditController.php | 48 +++++++++++++ routes/app.php | 9 +++ tests/Feature/Reddit/RedditLookupTest.php | 67 +++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 app/Http/Controllers/App/RedditController.php create mode 100644 tests/Feature/Reddit/RedditLookupTest.php diff --git a/app/Http/Controllers/App/RedditController.php b/app/Http/Controllers/App/RedditController.php new file mode 100644 index 00000000..18b6a90a --- /dev/null +++ b/app/Http/Controllers/App/RedditController.php @@ -0,0 +1,48 @@ +authorizeRedditAccount($request, $account); + + $query = trim((string) $request->query('q', '')); + + return response()->json([ + 'data' => $query === '' ? [] : $this->client->searchSubreddits($account, $query), + ]); + } + + public function restrictions(Request $request, SocialAccount $account, string $subreddit): JsonResponse + { + $this->authorizeRedditAccount($request, $account); + + return response()->json(['data' => $this->client->restrictions($account, $subreddit)]); + } + + 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); + } +} diff --git a/routes/app.php b/routes/app.php index 54653f08..10d35314 100644 --- a/routes/app.php +++ b/routes/app.php @@ -18,6 +18,7 @@ use App\Http\Controllers\App\PostController; use App\Http\Controllers\App\PostTemplateController; use App\Http\Controllers\App\PresenceController; +use App\Http\Controllers\App\RedditController as AppRedditController; use App\Http\Controllers\App\Settings\AccountController; use App\Http\Controllers\App\Settings\AuthenticationController; use App\Http\Controllers\App\Settings\NotificationPreferenceController; @@ -142,6 +143,14 @@ ->middleware('throttle:60,1') ->name('app.discord.mentions'); + // Reddit — live lookups for the composer (subreddit typeahead + restrictions/flair). + Route::get('reddit/accounts/{account}/subreddits', [AppRedditController::class, 'subreddits']) + ->middleware('throttle:60,1') + ->name('app.reddit.subreddits'); + Route::get('reddit/accounts/{account}/subreddits/{subreddit}/restrictions', [AppRedditController::class, 'restrictions']) + ->middleware('throttle:60,1') + ->name('app.reddit.restrictions'); + // Workspaces Route::get('workspaces', [WorkspaceController::class, 'index'])->name('app.workspaces.index'); Route::post('workspaces/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('app.workspaces.switch'); diff --git a/tests/Feature/Reddit/RedditLookupTest.php b/tests/Feature/Reddit/RedditLookupTest.php new file mode 100644 index 00000000..f4f6cbf4 --- /dev/null +++ b/tests/Feature/Reddit/RedditLookupTest.php @@ -0,0 +1,67 @@ + 'https://oauth.reddit.com', + 'trypost.platforms.reddit.user_agent' => 'web:it.trypost:1.0', + ]); + + $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()->reddit()->create(['workspace_id' => $this->workspace->id]); +}); + +test('searches subreddits for a connected account', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/subreddits/search*' => Http::response([ + 'data' => ['children' => [['data' => ['display_name' => 'AskReddit', 'subreddit_type' => 'public', 'title' => 'Ask Reddit', 'subscribers' => 1, 'over18' => false]]]], + ], 200), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $this->account->id, 'q' => 'ask'])) + ->assertOk() + ->assertJsonPath('data.0.name', 'AskReddit'); +}); + +test('returns empty array when query is blank', function () { + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $this->account->id, 'q' => ''])) + ->assertOk() + ->assertJsonPath('data', []); +}); + +test('returns restrictions for a subreddit', function () { + Http::fake([ + config('trypost.platforms.reddit.api').'/r/pics/about*' => Http::response(['data' => ['submission_type' => 'any', 'allow_images' => true]], 200), + config('trypost.platforms.reddit.api').'/api/v1/pics/post_requirements*' => Http::response(['is_flair_required' => false], 200), + config('trypost.platforms.reddit.api').'/r/pics/api/link_flair_v2*' => Http::response([], 200), + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.restrictions', ['account' => $this->account->id, 'subreddit' => 'pics'])) + ->assertOk() + ->assertJsonPath('data.allowed_types.0', 'self'); +}); + +test('forbids looking up a reddit account in another workspace', function () { + $other = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => Workspace::factory()->create()->id, + ]); + + $this->actingAs($this->user) + ->getJson(route('app.reddit.subreddits', ['account' => $other->id, 'q' => 'x'])) + ->assertForbidden(); +}); From 5ec17d184708309b21442ac6f6f2d777a52a3cce Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:54:08 -0300 Subject: [PATCH 15/34] feat(reddit): post + account metrics (upvotes, comments, karma) --- .../Controllers/App/AnalyticsController.php | 3 + app/Services/Post/PostMetricsFetcher.php | 2 + .../Social/Reddit/RedditAnalytics.php | 69 +++++++++++++++++ app/Services/Social/Reddit/RedditClient.php | 14 ++++ lang/en/analytics.php | 2 + lang/es/analytics.php | 2 + lang/pt-BR/analytics.php | 2 + .../Services/Social/RedditAnalyticsTest.php | 74 +++++++++++++++++++ 8 files changed, 168 insertions(+) create mode 100644 app/Services/Social/Reddit/RedditAnalytics.php create mode 100644 tests/Feature/Services/Social/RedditAnalyticsTest.php diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index aee64aad..818ce8ba 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -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; @@ -36,6 +37,7 @@ class AnalyticsController extends Controller Platform::Pinterest, Platform::YouTube, Platform::Telegram, + Platform::Reddit, ]; public function index(Request $request): Response @@ -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 => [], }; diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305..935513f5 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -14,6 +14,7 @@ use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\MastodonAnalytics; 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\XAnalytics; @@ -74,6 +75,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->fetchPostMetrics($postPlatform), Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Reddit => app(RedditAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], }); } diff --git a/app/Services/Social/Reddit/RedditAnalytics.php b/app/Services/Social/Reddit/RedditAnalytics.php new file mode 100644 index 00000000..99bc8bce --- /dev/null +++ b/app/Services/Social/Reddit/RedditAnalytics.php @@ -0,0 +1,69 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + try { + $karma = $this->client->me($account); + } catch (Throwable) { + return []; + } + + return [ + ['label' => __('analytics.metrics.karma'), 'value' => $karma['total_karma']], + ]; + } + + /** + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + + $fullnames = collect(explode(',', (string) $postPlatform->platform_post_id)) + ->map(fn ($id) => trim($id)) + ->filter() + ->values() + ->all(); + + if (! $account || $fullnames === []) { + return []; + } + + try { + $info = $this->client->info($account, $fullnames); + } catch (Throwable) { + return []; + } + + if ($info === []) { + return []; + } + + return [ + ['label' => __('analytics.metrics.upvotes'), 'value' => (int) collect($info)->sum('score'), 'kind' => 'reaction'], + ['label' => __('analytics.metrics.comments'), 'value' => (int) collect($info)->sum('num_comments'), 'kind' => 'comments'], + ]; + } +} diff --git a/app/Services/Social/Reddit/RedditClient.php b/app/Services/Social/Reddit/RedditClient.php index 507c6436..f4f51fee 100644 --- a/app/Services/Social/Reddit/RedditClient.php +++ b/app/Services/Social/Reddit/RedditClient.php @@ -112,6 +112,20 @@ public function info(SocialAccount $account, array $fullnames): array ->all(); } + /** + * @return array{link_karma: int, comment_karma: int, total_karma: int} + */ + public function me(SocialAccount $account): array + { + $data = $this->reddit($account)->get($this->url('/api/v1/me'))->json(); + + return [ + 'link_karma' => (int) data_get($data, 'link_karma', 0), + 'comment_karma' => (int) data_get($data, 'comment_karma', 0), + 'total_karma' => (int) data_get($data, 'total_karma', 0), + ]; + } + private function url(string $path): string { return (string) config('trypost.platforms.reddit.api').$path; diff --git a/lang/en/analytics.php b/lang/en/analytics.php index 99c8190c..2737dda8 100644 --- a/lang/en/analytics.php +++ b/lang/en/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Following', 'impressions' => 'Impressions', 'interactions' => 'Interactions', + 'karma' => 'Karma', 'likes' => 'Likes', 'members' => 'Members', 'minutes_watched' => 'Minutes Watched', @@ -45,6 +46,7 @@ 'saves' => 'Saves', 'shares' => 'Shares', 'subscribers' => 'Subscribers', + 'upvotes' => 'Upvotes', 'subscribers_gained' => 'Subscribers Gained', 'subscribers_lost' => 'Subscribers Lost', 'total_likes' => 'Total Likes', diff --git a/lang/es/analytics.php b/lang/es/analytics.php index da4d2a40..f258cbbd 100644 --- a/lang/es/analytics.php +++ b/lang/es/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Siguiendo', 'impressions' => 'Impresiones', 'interactions' => 'Interacciones', + 'karma' => 'Karma', 'likes' => 'Me gusta', 'members' => 'Miembros', 'minutes_watched' => 'Minutos Vistos', @@ -45,6 +46,7 @@ 'saves' => 'Guardados', 'shares' => 'Compartidos', 'subscribers' => 'Suscriptores', + 'upvotes' => 'Votos positivos', 'subscribers_gained' => 'Suscriptores Ganados', 'subscribers_lost' => 'Suscriptores Perdidos', 'total_likes' => 'Total de Me gusta', diff --git a/lang/pt-BR/analytics.php b/lang/pt-BR/analytics.php index bad4c550..9c8fd496 100644 --- a/lang/pt-BR/analytics.php +++ b/lang/pt-BR/analytics.php @@ -20,6 +20,7 @@ 'following' => 'Seguindo', 'impressions' => 'Impressões', 'interactions' => 'Interações', + 'karma' => 'Karma', 'likes' => 'Curtidas', 'members' => 'Membros', 'minutes_watched' => 'Minutos Assistidos', @@ -45,6 +46,7 @@ 'saves' => 'Salvos', 'shares' => 'Compartilhamentos', 'subscribers' => 'Inscritos', + 'upvotes' => 'Votos positivos', 'subscribers_gained' => 'Inscritos Ganhos', 'subscribers_lost' => 'Inscritos Perdidos', 'total_likes' => 'Curtidas Totais', diff --git a/tests/Feature/Services/Social/RedditAnalyticsTest.php b/tests/Feature/Services/Social/RedditAnalyticsTest.php new file mode 100644 index 00000000..3b1fb37b --- /dev/null +++ b/tests/Feature/Services/Social/RedditAnalyticsTest.php @@ -0,0 +1,74 @@ + 'https://oauth.reddit.com', + 'trypost.platforms.reddit.user_agent' => 'web:it.trypost:1.0', + ]); + $this->account = SocialAccount::factory()->reddit()->create(['workspace_id' => Workspace::factory()->create()->id]); +}); + +test('post metrics sum score and comments across subreddits', function () { + Http::fake(['https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => [ + ['data' => ['name' => 't3_one', 'score' => 10, 'num_comments' => 3]], + ['data' => ['name' => 't3_two', 'score' => 5, 'num_comments' => 2]], + ]]], 200)]); + + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => 't3_one,t3_two', + ]); + + $metrics = app(RedditAnalytics::class)->fetchPostMetrics($platform); + + expect($metrics)->not->toBeEmpty() + ->and((int) collect($metrics)->firstWhere('kind', 'reaction')['value'])->toBe(15) + ->and((int) collect($metrics)->firstWhere('kind', 'comments')['value'])->toBe(5); +}); + +test('account metrics expose total karma', function () { + Http::fake(['https://oauth.reddit.com/api/v1/me*' => Http::response(['total_karma' => 1234, 'link_karma' => 1000, 'comment_karma' => 234], 200)]); + + $metrics = app(RedditAnalytics::class)->getMetrics($this->account); + + expect((int) $metrics[0]['value'])->toBe(1234); +}); + +test('post metrics return empty when platform_post_id is blank', function () { + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => null, + ]); + + expect(app(RedditAnalytics::class)->fetchPostMetrics($platform))->toBe([]); +}); + +test('post metrics return empty when info API returns no children', function () { + Http::fake(['https://oauth.reddit.com/api/info*' => Http::response(['data' => ['children' => []]], 200)]); + + $platform = PostPlatform::factory()->reddit()->create([ + 'social_account_id' => $this->account->id, + 'platform_post_id' => 't3_abc', + ]); + + expect(app(RedditAnalytics::class)->fetchPostMetrics($platform))->toBe([]); +}); + +test('account metrics return empty when me API throws a connection exception', function () { + Http::fake(['https://oauth.reddit.com/api/v1/me*' => fn () => throw new ConnectionException('timeout')]); + + $account = SocialAccount::factory()->reddit()->create(['workspace_id' => $this->account->workspace_id]); + + $metrics = app(RedditAnalytics::class)->getMetrics($account); + + expect($metrics)->toBe([]); +}); From 8d6c5356da4055c4e3abad67c2b2a58a952db248 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 11:57:40 -0300 Subject: [PATCH 16/34] feat(reddit): i18n keys (en, es, pt-BR) --- lang/en/posts.php | 26 ++++++++++++++++++++++++++ lang/es/posts.php | 26 ++++++++++++++++++++++++++ lang/pt-BR/posts.php | 26 ++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) diff --git a/lang/en/posts.php b/lang/en/posts.php index 6fe57b73..0c916543 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -181,6 +181,27 @@ 'embed_image' => 'Image URL', 'embed_color' => 'Color', ], + 'reddit' => [ + 'settings' => 'Reddit Settings', + 'add_subreddit' => 'Add subreddit', + 'remove_subreddit' => 'Remove', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Search subreddits…', + 'title' => 'Title', + 'post_type' => 'Post type', + 'type_self' => 'Text', + 'type_link' => 'Link', + 'type_image' => 'Image', + 'url' => 'Link URL', + 'flair' => 'Flair', + 'no_flair' => 'No flair', + 'nsfw' => 'Mark as NSFW', + 'spoiler' => 'Mark as spoiler', + 'subreddit_required' => 'Add at least one subreddit to publish this post.', + 'title_required' => 'Each subreddit needs a title.', + 'url_required' => 'A link post needs a URL.', + 'flair_required' => 'This subreddit requires a flair.', + ], 'warnings' => [ 'no_variant' => 'Pick a post type to continue.', 'requires_media' => 'This post type requires at least one image or video.', @@ -512,6 +533,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'reddit_post' => [ + 'label' => 'Post', + 'description' => 'Text, link, or image post to a subreddit', + ], ], 'platforms' => [ @@ -616,6 +641,7 @@ 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', 'discord_message' => 'Discord Message', + 'reddit_post' => 'Reddit Post', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/posts.php b/lang/es/posts.php index c1da9111..b36cf16d 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -181,6 +181,27 @@ 'embed_image' => 'URL de la imagen', 'embed_color' => 'Color', ], + 'reddit' => [ + 'settings' => 'Configuración de Reddit', + 'add_subreddit' => 'Añadir subreddit', + 'remove_subreddit' => 'Quitar', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Buscar subreddits…', + 'title' => 'Título', + 'post_type' => 'Tipo de publicación', + 'type_self' => 'Texto', + 'type_link' => 'Enlace', + 'type_image' => 'Imagen', + 'url' => 'URL del enlace', + 'flair' => 'Flair', + 'no_flair' => 'Sin flair', + 'nsfw' => 'Marcar como NSFW', + 'spoiler' => 'Marcar como spoiler', + 'subreddit_required' => 'Añade al menos un subreddit para publicar.', + 'title_required' => 'Cada subreddit necesita un título.', + 'url_required' => 'Una publicación de enlace necesita una URL.', + 'flair_required' => 'Este subreddit requiere un flair.', + ], 'warnings' => [ 'no_variant' => 'Elige un tipo de publicación para continuar.', 'requires_media' => 'Este tipo requiere al menos una imagen o video.', @@ -512,6 +533,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'reddit_post' => [ + 'label' => 'Publicación', + 'description' => 'Publicación de texto, enlace o imagen en un subreddit', + ], ], 'platforms' => [ @@ -617,6 +642,7 @@ 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', 'discord_message' => 'Mensaje de Discord', + 'reddit_post' => 'Publicación en Reddit', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 831b745e..6905ed95 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -181,6 +181,27 @@ 'embed_image' => 'URL da imagem', 'embed_color' => 'Cor', ], + 'reddit' => [ + 'settings' => 'Configurações do Reddit', + 'add_subreddit' => 'Adicionar subreddit', + 'remove_subreddit' => 'Remover', + 'subreddit' => 'Subreddit', + 'search_subreddit' => 'Buscar subreddits…', + 'title' => 'Título', + 'post_type' => 'Tipo de post', + 'type_self' => 'Texto', + 'type_link' => 'Link', + 'type_image' => 'Imagem', + 'url' => 'URL do link', + 'flair' => 'Flair', + 'no_flair' => 'Sem flair', + 'nsfw' => 'Marcar como NSFW', + 'spoiler' => 'Marcar como spoiler', + 'subreddit_required' => 'Adicione ao menos um subreddit para publicar.', + 'title_required' => 'Cada subreddit precisa de um título.', + 'url_required' => 'Um post de link precisa de uma URL.', + 'flair_required' => 'Este subreddit exige um flair.', + ], 'warnings' => [ 'no_variant' => 'Escolha um tipo de publicação para continuar.', 'requires_media' => 'Este tipo exige pelo menos uma imagem ou vídeo.', @@ -512,6 +533,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'reddit_post' => [ + 'label' => 'Post', + 'description' => 'Post de texto, link ou imagem em um subreddit', + ], ], 'platforms' => [ @@ -616,6 +641,7 @@ 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', 'discord_message' => 'Mensagem do Discord', + 'reddit_post' => 'Post no Reddit', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', From 9002e35d8c53ef6de7bfc7dd8efb8fea74e2ae32 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:24:32 -0300 Subject: [PATCH 17/34] feat(reddit): connect-account prompt i18n --- lang/en/accounts.php | 1 + lang/es/accounts.php | 1 + lang/pt-BR/accounts.php | 1 + 3 files changed, 3 insertions(+) diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 52871353..e4b2b455 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'reddit' => 'Connect your Reddit account', ], 'disconnect_modal' => [ diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 3a8ed353..200a24f8 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'reddit' => 'Conecta tu cuenta de Reddit', ], 'disconnect_modal' => [ diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index a4382bb9..4c34162b 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -53,6 +53,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'reddit' => 'Conecte sua conta do Reddit', ], 'disconnect_modal' => [ From 5ff696ddc37a1845a6f35dd5d87ebef0ae502f74 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:25:56 -0300 Subject: [PATCH 18/34] feat(reddit): frontend platform registration + logo --- public/images/accounts/reddit.png | Bin 0 -> 1546 bytes .../js/components/accounts/AddSocialDialog.vue | 5 +++++ resources/js/composables/usePlatformLogo.ts | 3 +++ resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + 5 files changed, 10 insertions(+) create mode 100644 public/images/accounts/reddit.png diff --git a/public/images/accounts/reddit.png b/public/images/accounts/reddit.png new file mode 100644 index 0000000000000000000000000000000000000000..14e73ac797b2a321e1a3e7aa94b6fed065eb2075 GIT binary patch literal 1546 zcmV+l2KD)gP)yh%hsR9J=Wm}zVkRTRg6GxPeEz80Y@c7dR6LLpsPiv$8fSTqeFO#|F-gEB%-gC~qXP%%I@)$z=zX_OQlvX?#r4Q;u%ST5GgYR_y?T4S*sXRnQ8M9ZBy?`k>jL#s!5oPw3WW4<* z3)8@2x3mg^L1JZYaym)ls5(yVWv*UE1gFUOG1hyG65eWZki0whD}hqN}3 zvxMe)eknnf_3JdwZysC5(24As)hb1a;-kniTi4?@WP>M6-KFu6YH2X069vsv*HokZ zsDflCdp>`clc4dk8lE-i(?qig%@%1v%XmKNTp1arfDA*R+DS?0ayi0q`s>d>t-+YE zh%F;aT_y%#2n29RB#*_NLe64^R%lSK~l#&w;~Kwuz?6F_y9Fc1Sf!Z_Flv7`gPsJHlmq7suq?r2*Ul)UZ>?S?de)*@8#%dF}-^2GiaDW>`*tN-I%-$mIcaj7ysJ+IyW9CzoeFElA zw5B!C$JW)cD9H1KmZAP?iq~qQt~<%&R~!0@&Rnn(aymy^zyN3 z6wId=ywD4U>rHfDF2Vy|@POOSqILQKjWwJ;LJ|;f$P>OgjQIQ%N49bE68TGcvDjq5 ztW|vZ8z=VQNThHx>C^NBL~w%tA*xeAg7#Y`z3#DW_}W}Cf`T;^tg-CvZo}^ly~kHU z1h40=R+{S{z~CWpDGisMvG{2Dhz=P6H}PEf>|rcoJC3ot|TNof`A;r@1?JU z=6Y%?s41u6Dgi={v--*ysS{2AoYCg948eNDCE`xTm5jrMAQA8}*o&u+0Y8E8Js}!C zD~t<{#t8m{&^9A3u!U6>g}xqodWQWH{MiYW>T0wd4EzEj3Hr0uf|l+$6Rew&Qo;&? w;i>B~a5B#SxL}l4JQ$@F4@POlgU6KCf8f&sia_GxVE_OC07*qoM6N<$g6Ck`pa1{> literal 0 HcmV?d00001 diff --git a/resources/js/components/accounts/AddSocialDialog.vue b/resources/js/components/accounts/AddSocialDialog.vue index fdee97f1..6654fe66 100644 --- a/resources/js/components/accounts/AddSocialDialog.vue +++ b/resources/js/components/accounts/AddSocialDialog.vue @@ -109,6 +109,11 @@ const platformTheme: Record< rotate: 'rotate-1', image: '/images/accounts/discord.png', }, + reddit: { + bg: 'bg-orange-200', + rotate: '-rotate-1', + image: '/images/accounts/reddit.png', + }, }; const themeFor = (value: string) => diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index e00fd0f1..018c805c 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -13,6 +13,7 @@ const PLATFORM_LOGOS: Record = { mastodon: '/images/accounts/mastodon.png', telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', + reddit: '/images/accounts/reddit.png', }; const PLATFORM_LABELS: Record = { @@ -30,6 +31,7 @@ const PLATFORM_LABELS: Record = { mastodon: 'Mastodon', telegram: 'Telegram', discord: 'Discord', + reddit: 'Reddit', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -51,6 +53,7 @@ const PLATFORM_CONTENT_TYPES: Record = { mastodon: ['mastodon_post'], telegram: ['telegram_post'], discord: ['discord_message'], + reddit: ['reddit_post'], }; export interface ContentTypeOption { diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index 417e6cfc..eaad5c13 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -21,6 +21,7 @@ export const ContentType = { MastodonPost: 'mastodon_post', TelegramPost: 'telegram_post', DiscordMessage: 'discord_message', + RedditPost: 'reddit_post', } as const; export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType]; diff --git a/resources/js/types/platform.ts b/resources/js/types/platform.ts index 82c86318..30cdbb2f 100644 --- a/resources/js/types/platform.ts +++ b/resources/js/types/platform.ts @@ -13,6 +13,7 @@ export const Platform = { Mastodon: 'mastodon', Telegram: 'telegram', Discord: 'discord', + Reddit: 'reddit', } as const; export type PlatformValue = (typeof Platform)[keyof typeof Platform]; From 798ece47b7a235ba7a9641de651b632ee55e08d8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:29:47 -0300 Subject: [PATCH 19/34] feat(reddit): composer subreddit settings panel --- .../js/components/ChannelConfigurator.vue | 9 + .../posts/editor/RedditSettings.vue | 385 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 resources/js/components/posts/editor/RedditSettings.vue diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue index 5295d3db..b4e787f2 100644 --- a/resources/js/components/ChannelConfigurator.vue +++ b/resources/js/components/ChannelConfigurator.vue @@ -7,6 +7,7 @@ import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue'; import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue'; import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue'; import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue'; +import RedditSettings from '@/components/posts/editor/RedditSettings.vue'; import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; @@ -177,6 +178,14 @@ const selectedChannels = computed(() => props.channels.filter((channel) => isSel :preview-only="previewOnly" @update:meta="emit('update:meta', channel.id, $event)" /> + diff --git a/resources/js/components/posts/editor/RedditSettings.vue b/resources/js/components/posts/editor/RedditSettings.vue new file mode 100644 index 00000000..d74c87f5 --- /dev/null +++ b/resources/js/components/posts/editor/RedditSettings.vue @@ -0,0 +1,385 @@ + + + From 1033567d345d1cc1f97e8b02c808f2786232fa71 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:32:06 -0300 Subject: [PATCH 20/34] feat(reddit): post preview --- .../posts/previews/PlatformPreview.vue | 3 + .../posts/previews/RedditPreview.vue | 171 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 resources/js/components/posts/previews/RedditPreview.vue diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index fee914f6..bf42e2b6 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -10,6 +10,7 @@ import InstagramPreview from './InstagramPreview.vue'; import LinkedInPreview from './LinkedInPreview.vue'; import MastodonPreview from './MastodonPreview.vue'; import PinterestPreview from './PinterestPreview.vue'; +import RedditPreview from './RedditPreview.vue'; import TelegramPreview from './TelegramPreview.vue'; import ThreadsPreview from './ThreadsPreview.vue'; import TikTokPreview from './TikTokPreview.vue'; @@ -71,6 +72,8 @@ const previewComponent = computed(() => { return TelegramPreview; case 'discord': return DiscordPreview; + case 'reddit': + return RedditPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/RedditPreview.vue b/resources/js/components/posts/previews/RedditPreview.vue new file mode 100644 index 00000000..04b62fde --- /dev/null +++ b/resources/js/components/posts/previews/RedditPreview.vue @@ -0,0 +1,171 @@ + + + From 622e364353d4c529faf47d3fe8e8009b77252f14 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:35:35 -0300 Subject: [PATCH 21/34] feat(reddit): media optimizer profile for reddit images --- app/Services/Media/MediaOptimizer.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 65de20e3..38ae9953 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -208,6 +208,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Reddit => [ + 'max_width' => 2048, + 'max_size' => 20 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } From 972bb3798295a44f87644f746f1bce208501e1b3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:46:23 -0300 Subject: [PATCH 22/34] fix(reddit): allow text-only posts, drop deferred video, profile url, dead rule --- app/Enums/PostPlatform/ContentType.php | 3 ++- app/Enums/SocialAccount/Platform.php | 2 +- app/Models/SocialAccount.php | 1 + app/Support/PostPlatformMetaRules.php | 2 +- tests/Feature/SocialAccountModelTest.php | 20 ++++++++++++++++++++ tests/Unit/Enums/ContentTypeTest.php | 2 ++ tests/Unit/Enums/PlatformTest.php | 2 +- 7 files changed, 28 insertions(+), 4 deletions(-) diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index b544608b..b20a7913 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -206,7 +206,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, - self::RedditPost => true, + self::RedditPost => false, }; } @@ -247,6 +247,7 @@ public function requiresMedia(): bool self::FacebookPost => false, self::InstagramFeed => false, self::DiscordMessage => false, + self::RedditPost => false, default => true, }; } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 7cd2fe6b..30951fda 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -80,7 +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, MediaType::Video], + self::Reddit => [MediaType::Image], }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index d1fed6b6..0064d974 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -126,6 +126,7 @@ protected function profileUrl(): Attribute ? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}" : null, SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : null, + SocialPlatform::Reddit => $username ? "https://www.reddit.com/user/{$username}" : null, default => null, }; }, diff --git a/app/Support/PostPlatformMetaRules.php b/app/Support/PostPlatformMetaRules.php index f408d989..11ba2af4 100644 --- a/app/Support/PostPlatformMetaRules.php +++ b/app/Support/PostPlatformMetaRules.php @@ -77,7 +77,7 @@ public static function rules(): array 'platforms.*.meta.subreddits.*.name' => ['required', 'string'], 'platforms.*.meta.subreddits.*.title' => ['required', 'string', 'max:300'], 'platforms.*.meta.subreddits.*.type' => ['required', 'string', Rule::in(['self', 'link', 'image'])], - 'platforms.*.meta.subreddits.*.url' => ['sometimes', 'nullable', 'url', 'required_if:platforms.*.meta.subreddits.*.type,link'], + 'platforms.*.meta.subreddits.*.url' => ['sometimes', 'nullable', 'url'], 'platforms.*.meta.subreddits.*.flair_id' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.subreddits.*.flair_text' => ['sometimes', 'nullable', 'string'], 'platforms.*.meta.subreddits.*.flair_required' => ['sometimes', 'boolean'], diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index d28bbf70..fbbfb1f5 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -186,3 +186,23 @@ Event::assertDispatched(NotificationCreated::class); Mail::assertQueued(AccountDisconnected::class); }); + +// ---- profileUrl ---- + +test('reddit account profileUrl returns correct reddit user url', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'redditor_one', + ]); + + expect($account->profileUrl)->toBe('https://www.reddit.com/user/redditor_one'); +}); + +test('reddit account profileUrl returns null when username is blank', function () { + $account = SocialAccount::factory()->reddit()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => '', + ]); + + expect($account->profileUrl)->toBeNull(); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index ee543985..38c53af4 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -73,6 +73,7 @@ expect(ContentType::YouTubeShort->supportsVideo())->toBeTrue(); expect(ContentType::LinkedInCarousel->supportsVideo())->toBeFalse(); expect(ContentType::PinterestPin->supportsVideo())->toBeFalse(); + expect(ContentType::RedditPost->supportsVideo())->toBeFalse(); }); test('content type supports image correctly', function () { @@ -97,6 +98,7 @@ expect(ContentType::ThreadsPost->requiresMedia())->toBeFalse(); expect(ContentType::BlueskyPost->requiresMedia())->toBeFalse(); expect(ContentType::MastodonPost->requiresMedia())->toBeFalse(); + expect(ContentType::RedditPost->requiresMedia())->toBeFalse(); }); test('can get content types for platform', function () { diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 23adfe9f..2a263ff5 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -113,7 +113,7 @@ expect(Platform::Reddit->value)->toBe('reddit') ->and(Platform::Reddit->label())->toBe('Reddit') ->and(Platform::Reddit->color())->toBe('#FF4500') - ->and(Platform::Reddit->allowedMediaTypes())->toBe([MediaType::Image, MediaType::Video]) + ->and(Platform::Reddit->allowedMediaTypes())->toBe([MediaType::Image]) ->and(Platform::Reddit->maxImages())->toBe(20) ->and(Platform::Reddit->maxContentLength())->toBe(40000) ->and(Platform::Reddit->recommendedAiContentLength())->toBe(500) From 5be02b31ca511daa735ecc3eb772d27faf01ae22 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 12:59:55 -0300 Subject: [PATCH 23/34] chore: remove competitor reference from media optimizer comment --- app/Services/Media/MediaOptimizer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 38ae9953..b1181c36 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -69,8 +69,8 @@ public function optimizeImage(string $filePath, Platform $platform): string file_put_contents($tempFile, (string) $encoded); // If still above the platform size budget, iteratively shrink DIMENSIONS - // (not quality) by 10 % per step until it fits. Postiz-style, preserves - // pixel quality while lowering the byte count. + // (not quality) by 10 % per step until it fits, preserving pixel quality + // while lowering the byte count. while (filesize($tempFile) > $maxSize) { $newWidth = (int) ($image->width() * 0.9); $newHeight = (int) ($image->height() * 0.9); From c4b7537f34cf983798fa139be01a99bdc408673c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Wed, 17 Jun 2026 13:16:27 -0300 Subject: [PATCH 24/34] fix(reddit): row-state alignment, debounce race, preview link/media gating, flair reload --- lang/en/posts.php | 2 + lang/es/posts.php | 2 + lang/pt-BR/posts.php | 2 + .../posts/editor/RedditSettings.vue | 123 +++++++++++++----- .../posts/previews/RedditPreview.vue | 20 ++- 5 files changed, 118 insertions(+), 31 deletions(-) diff --git a/lang/en/posts.php b/lang/en/posts.php index 0c916543..f35ab489 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -183,6 +183,8 @@ ], 'reddit' => [ 'settings' => 'Reddit Settings', + 'posting_to' => 'Posting to', + 'searching' => 'Searching…', 'add_subreddit' => 'Add subreddit', 'remove_subreddit' => 'Remove', 'subreddit' => 'Subreddit', diff --git a/lang/es/posts.php b/lang/es/posts.php index b36cf16d..27335f5a 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -183,6 +183,8 @@ ], 'reddit' => [ 'settings' => 'Configuración de Reddit', + 'posting_to' => 'Publicando en', + 'searching' => 'Buscando…', 'add_subreddit' => 'Añadir subreddit', 'remove_subreddit' => 'Quitar', 'subreddit' => 'Subreddit', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 6905ed95..09e6b9ec 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -183,6 +183,8 @@ ], 'reddit' => [ 'settings' => 'Configurações do Reddit', + 'posting_to' => 'Publicando em', + 'searching' => 'Buscando…', 'add_subreddit' => 'Adicionar subreddit', 'remove_subreddit' => 'Remover', 'subreddit' => 'Subreddit', diff --git a/resources/js/components/posts/editor/RedditSettings.vue b/resources/js/components/posts/editor/RedditSettings.vue index d74c87f5..0c984b8f 100644 --- a/resources/js/components/posts/editor/RedditSettings.vue +++ b/resources/js/components/posts/editor/RedditSettings.vue @@ -1,7 +1,7 @@