Fetch Telegram channel photo as avatar; extract ConnectTelegramChannel action

This commit is contained in:
Paulo Castellano 2026-06-14 10:18:44 -03:00
parent 58eab0619b
commit b9cc12aef7
5 changed files with 182 additions and 60 deletions

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Actions\SocialAccount;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Features\SocialAccountLimit;
use App\Jobs\SyncTelegramAccountAvatar;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Support\Facades\Cache;
use Laravel\Pennant\Feature;
class ConnectTelegramChannel
{
/**
* Link a Telegram chat to a workspace for a one-off connect nonce.
*
* @param array<string, mixed> $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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\SocialAccount;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
class SyncTelegramAccountAvatar implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public SocialAccount $account) {}
public function handle(): void
{
$token = (string) config('trypost.platforms.telegram.bot_token');
$api = rtrim((string) config('trypost.platforms.telegram.api'), '/');
$chatId = data_get($this->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]);
}
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Jobs\SyncTelegramAccountAvatar;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
config(['trypost.platforms.telegram.bot_token' => '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();
});

View file

@ -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 () {