From b9cc12aef7d7abd0a1f520970019b38f2a3cf86a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 14 Jun 2026 10:18:44 -0300 Subject: [PATCH] Fetch Telegram channel photo as avatar; extract ConnectTelegramChannel action --- .../SocialAccount/ConnectTelegramChannel.php | 84 +++++++++++++++++++ .../Webhooks/TelegramWebhookController.php | 63 +------------- app/Jobs/SyncTelegramAccountAvatar.php | 50 +++++++++++ .../Jobs/SyncTelegramAccountAvatarTest.php | 39 +++++++++ .../Feature/Social/TelegramConnectionTest.php | 6 ++ 5 files changed, 182 insertions(+), 60 deletions(-) create mode 100644 app/Actions/SocialAccount/ConnectTelegramChannel.php create mode 100644 app/Jobs/SyncTelegramAccountAvatar.php create mode 100644 tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php new file mode 100644 index 00000000..0e2514f4 --- /dev/null +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -0,0 +1,84 @@ + $chat The `chat` object from the Bot API update. + * @return SocialAccount|null The linked account, or null when blocked (account + * limit reached or the code was already consumed). + */ + public static function execute(Workspace $workspace, array $chat, string $nonce): ?SocialAccount + { + $chatId = (string) data_get($chat, 'id'); + $username = data_get($chat, 'username'); + + // Block only brand-new accounts against the plan limit, never reconnects. + $isNewAccount = ! $workspace->socialAccounts() + ->where('platform', Platform::Telegram->value) + ->where('platform_user_id', $chatId) + ->exists(); + + if ($isNewAccount && self::workspaceAtAccountLimit($workspace)) { + return null; + } + + // Consume the code once so a leaked code can't be replayed to link another chat. + if (! Cache::add("telegram:connect:{$nonce}", true, now()->addMinutes(15))) { + return null; + } + + $account = $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => Platform::Telegram->value, + 'platform_user_id' => $chatId, + ], + [ + 'username' => $username, + 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", + 'access_token' => '', + 'refresh_token' => '', + 'token_expires_at' => null, + 'scopes' => [], + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'chat_id' => $chatId, + 'username' => $username, + 'type' => data_get($chat, 'type'), + 'connect_nonce' => $nonce, + ], + ], + ); + + SyncTelegramAccountAvatar::dispatch($account); + + return $account; + } + + private static function workspaceAtAccountLimit(Workspace $workspace): bool + { + if (config('trypost.self_hosted')) { + return false; + } + + $limit = Feature::for($workspace->account)->value(SocialAccountLimit::class); + + return $workspace->socialAccounts()->count() >= $limit; + } +} diff --git a/app/Http/Controllers/Webhooks/TelegramWebhookController.php b/app/Http/Controllers/Webhooks/TelegramWebhookController.php index 6aa0e05f..04400724 100644 --- a/app/Http/Controllers/Webhooks/TelegramWebhookController.php +++ b/app/Http/Controllers/Webhooks/TelegramWebhookController.php @@ -4,16 +4,12 @@ namespace App\Http\Controllers\Webhooks; -use App\Enums\SocialAccount\Platform as SocialPlatform; -use App\Enums\SocialAccount\Status; -use App\Features\SocialAccountLimit; +use App\Actions\SocialAccount\ConnectTelegramChannel; use App\Http\Controllers\Controller; use App\Models\Workspace; use App\Services\Social\TelegramConnectCode; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Cache; -use Laravel\Pennant\Feature; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class TelegramWebhookController extends Controller @@ -43,63 +39,10 @@ public function handle(Request $request): Response $payload = TelegramConnectCode::decode($matches[1]); $workspace = $payload === null ? null : Workspace::find(data_get($payload, 'workspace_id')); - if ($workspace === null) { - return response()->noContent(); + if ($workspace !== null) { + ConnectTelegramChannel::execute($workspace, $chat, data_get($payload, 'nonce')); } - $chatId = (string) data_get($chat, 'id'); - $username = data_get($chat, 'username'); - - // Mirror the controller's limit gate: block only brand-new accounts, never reconnects. - $isNewAccount = ! $workspace->socialAccounts() - ->where('platform', SocialPlatform::Telegram->value) - ->where('platform_user_id', $chatId) - ->exists(); - - if ($isNewAccount && $this->workspaceAtAccountLimit($workspace)) { - return response()->noContent(); - } - - // Consume the code once so a leaked code can't be replayed to link another chat. - if (! Cache::add("telegram:connect:{$payload['nonce']}", true, now()->addMinutes(15))) { - return response()->noContent(); - } - - $workspace->socialAccounts()->updateOrCreate( - [ - 'platform' => SocialPlatform::Telegram->value, - 'platform_user_id' => $chatId, - ], - [ - 'username' => $username, - 'display_name' => data_get($chat, 'title') ?? $username ?? "Telegram {$chatId}", - 'access_token' => '', - 'refresh_token' => '', - 'token_expires_at' => null, - 'scopes' => [], - 'status' => Status::Connected, - 'error_message' => null, - 'disconnected_at' => null, - 'meta' => [ - 'chat_id' => $chatId, - 'username' => $username, - 'type' => data_get($chat, 'type'), - 'connect_nonce' => data_get($payload, 'nonce'), - ], - ], - ); - return response()->noContent(); } - - private function workspaceAtAccountLimit(Workspace $workspace): bool - { - if (config('trypost.self_hosted')) { - return false; - } - - $limit = Feature::for($workspace->account)->value(SocialAccountLimit::class); - - return $workspace->socialAccounts()->count() >= $limit; - } } diff --git a/app/Jobs/SyncTelegramAccountAvatar.php b/app/Jobs/SyncTelegramAccountAvatar.php new file mode 100644 index 00000000..de2268b4 --- /dev/null +++ b/app/Jobs/SyncTelegramAccountAvatar.php @@ -0,0 +1,50 @@ +account->meta, 'chat_id'); + + if ($token === '' || $chatId === null) { + return; + } + + $chat = Http::get("{$api}/bot{$token}/getChat", ['chat_id' => $chatId]); + $fileId = data_get($chat->json(), 'result.photo.big_file_id'); + + if (! is_string($fileId)) { + return; + } + + $file = Http::get("{$api}/bot{$token}/getFile", ['file_id' => $fileId]); + $filePath = data_get($file->json(), 'result.file_path'); + + if (! is_string($filePath)) { + return; + } + + $avatar = uploadFromUrl("{$api}/file/bot{$token}/{$filePath}"); + + if ($avatar !== null) { + $this->account->update(['avatar_url' => $avatar]); + } + } +} diff --git a/tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php b/tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php new file mode 100644 index 00000000..9072c7f6 --- /dev/null +++ b/tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php @@ -0,0 +1,39 @@ + 'TESTTOKEN']); + Storage::fake(); +}); + +it('stores the telegram channel photo as the account avatar', function () { + $account = SocialAccount::factory()->telegram()->create(['avatar_url' => null]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => ['photo' => ['big_file_id' => 'BIGFILE']]], 200), + '*/botTESTTOKEN/getFile*' => Http::response(['ok' => true, 'result' => ['file_path' => 'photos/file_1.jpg']], 200), + '*/file/botTESTTOKEN/photos/file_1.jpg' => Http::response('image-bytes', 200, ['Content-Type' => 'image/jpeg']), + ]); + + (new SyncTelegramAccountAvatar($account))->handle(); + + expect($account->fresh()->getRawOriginal('avatar_url'))->not->toBeNull(); +}); + +it('does nothing when the channel has no photo', function () { + $account = SocialAccount::factory()->telegram()->create(['avatar_url' => null]); + + Http::fake([ + '*/botTESTTOKEN/getChat*' => Http::response(['ok' => true, 'result' => []], 200), + ]); + + (new SyncTelegramAccountAvatar($account))->handle(); + + expect($account->fresh()->getRawOriginal('avatar_url'))->toBeNull(); +}); diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index d39f9094..2f8451d5 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -4,14 +4,18 @@ use App\Enums\SocialAccount\Platform; use App\Enums\UserWorkspace\Role; +use App\Jobs\SyncTelegramAccountAvatar; use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; use App\Services\Social\TelegramConnectCode; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Queue; beforeEach(function () { + Queue::fake(); + config([ 'trypost.platforms.telegram.bot_token' => 'TESTTOKEN', 'trypost.platforms.telegram.bot_username' => 'TryPostBot', @@ -72,6 +76,8 @@ function telegramUpdate(string $code, array $chat = []): array expect(data_get($account->meta, 'chat_id'))->toBe('-1001234567890'); expect(data_get($account->meta, 'connect_nonce')) ->toBe(data_get(TelegramConnectCode::decode($code), 'nonce')); + + Queue::assertPushed(SyncTelegramAccountAvatar::class); }); it('links a private channel that has no username', function () {