Fetch Telegram channel photo as avatar; extract ConnectTelegramChannel action
This commit is contained in:
parent
58eab0619b
commit
b9cc12aef7
5 changed files with 182 additions and 60 deletions
84
app/Actions/SocialAccount/ConnectTelegramChannel.php
Normal file
84
app/Actions/SocialAccount/ConnectTelegramChannel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
app/Jobs/SyncTelegramAccountAvatar.php
Normal file
50
app/Jobs/SyncTelegramAccountAvatar.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php
Normal file
39
tests/Feature/Jobs/SyncTelegramAccountAvatarTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue